fix(sqlite,browser): resolve SQLite connection pool deadlock, disable stale SW cache, and update BRAIN.md
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 38s

This commit is contained in:
sandrodach committed 2026-10-24 21:27:51 +02:00
1 parent d6bc2976d9
commit 5c4e130862
8 files changed
+414 -231

No files matched your search

+114 -162
View File
@@ -1,190 +1,142 @@
# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet
> **Zweck:** Rein internes, ungeschöntes Nachschlagewerk für uns Entwickler. Keine Marketing-Texte, nur knallharte Fakten, Farbwerte, State-Machines, Namenskonventionen und Quick-Commands.
# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet & Architecture Log
> **Zweck:** Zentrales, technisch präzises Nachschlagewerk für Entwicklung, Design-System, Datenbank-Architektur, State-Machines und aktuellen Umsetzungsstand. Wird bei jeder Änderung aktuell gehalten.
---
## 🎨 1. Design-Tokens & Template-Aesthetic (Copy & Paste)
## 📋 0. Aktueller Projektplan & Umsetzungs-Status
### Erledigte Meilensteine
- [x] **CI/CD & Multi-Stage Docker Build (`Dockerfile`, `.gitea/workflows/build-and-publish.yaml`):**
- Alle Frontend-Checks (`tsc --noEmit`, `vite build`) und Backend-Tests (`go test -v ./...`) laufen direkt im Multi-Stage Container-Build ohne doppelte Vorab-Steps.
- Push auf `dev` baut und überschreibt `git.dsit.me/sandrodach/mindsift:dev`.
- [x] **Monochromes, eckiges Desktop-Design & Zentrale CSS-Steuerung (`frontend/src/index.css`, `frontend/tailwind.config.js`):**
- Zentrale CSS-Variablen (`--bg-app`, `--bg-panel`, `--accent-primary`, `--border-color`, `--radius-card: 0px`) in `frontend/src/index.css`.
- Eckige Tabs, eckige Panels/Karten (`0px` Radius), nur die Schnell-Eingabe-Blase oben im Header und Profil-Avatare sind rund (`rounded-full`).
- Untere doppelte Navigationsleiste entfernt; Schnell-Notiz-Blase (`InstantCaptureBar`) sitzt ganz oben im Header über den Tabs.
- Workspace links als eckiger, monochromer Datei-/Ordner-Baum (File Explorer Tree mit Unterordnern).
- [x] **Quick Dump Notes + Triage Swipen + Direkt-Zuordnung (`InstantCaptureBar.tsx`, `TriageDeck.tsx`, `server.go`):**
- **Quick Dump (Standard):** Text oben eingeben + `Enter` wirft den Gedanken sofort als Quick Dump (`status: 'inbox'`) ins Triage-Deck.
- **Direkt-Zuordnung bei der Eingabe:** Über das Auswahl-Menü (`Quick Dump ▾`) in der Eingabeleiste kann ein Eintrag alternativ direkt als **Aufgabe** (`target: 'task'`), direkt auf eine **Geteilte Liste** (`target: 'list_item'`) oder direkt als **Workspace-Notiz** (`target: 'document'`) gespeichert werden.
- **Triage-Entscheidung & Warteschlange:** Im Triage-Deck per Swipe/Klick **Wegwerfen** (`←` / `H`), **Später** (`Leertaste` – ans Ende schieben), **Auf Liste**, **Als Notiz** (`D`), **Mit Termin** (`T`) oder **Als Aufgabe** (`→` / `L`). Inklusive Inline-Textbearbeitung und Warteschlangen-Liste unter der aktiven Karte.
- [x] **Root-Cause Fixes für Browser-Hänger & Cross-Browser-Kompatibilität (Chrome & Firefox):**
- **SQLite Connection-Pool Deadlock behoben (`internal/storage/sqlite.go`):** Vorher war `db.SetMaxOpenConns(1)` aktiv, während `GetAllListsForUser`, `GetListsForSpace` und `GetSpacesForUser` innerhalb von `for rows.Next()` (bei offenem Cursor!) Unterabfragen (`s.GetListItems`, `s.GetSpaceMembers`) aufriefen. Sobald eine Liste existierte, blockierte die einzige DB-Verbindung dauerhaft und alle nachfolgenden Requests (`POST /api/v1/inbox` etc.) hingen bis zum 15-Minuten-TCP-Timeout. Jetzt werden alle `rows` strikt vor jeder Unterabfrage geschlossen (`rows.Close()`), WAL-Pragmas + `busy_timeout(10000)` per DSN übergeben und `MaxOpenConns(8)` gesetzt.
- **Stale JWT User-Auflösung (`internal/api/server.go`):** Wenn nach einem Container-Neustart eine neue SQLite-DB initialisiert wird, löst `authMiddleware` alte Browser-Tokens automatisch auf `EnsureDefaultUser()` (mit `sync.Mutex` gegen Race-Conditions) auf.
- **Service-Worker & Cache-Busting (`frontend/public/sw.js`, `frontend/index.html`, `internal/api/server.go`):** Kein Caching veralteter Bundles in Firefox/Chrome; `Cache-Control: no-store` auf allen `/api/*`-Routen und `index.html`.
- **Optimistisches Input-Clearing & Request-Timeout (`frontend/src/lib/api.ts`, `InstantCaptureBar.tsx`):** Eingabefeld leert sich sofort (<10ms) bei `Enter`, alle Fetch-Calls haben einen 8-Sekunden-`AbortController`-Timeout.
---
## 🎨 1. Design-System & CSS-Architektur (`frontend/src/index.css`)
Alle Farben, Abstände und Radien werden zentral in [`frontend/src/index.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/index.css) über CSS-Variablen gesteuert:
### Farbpalette (Deep Dark Theme & Squircles)
```css
/* Backgrounds (Canvas & Surfaces) */
--bg-canvas: #16171a; /* Deep Charcoal Canvas */
--bg-surface: #222428; /* Card & Capsule Surface */
--bg-elevated: #282a2f; /* Elevated Container / Hover */
--bg-field: #1a1b1e; /* Input Field Fill */
:root {
/* Monochromer Hintergrund (Dunkles Schiefer/Anthrazit) */
--bg-app: #121316;
--bg-panel: #1a1c20;
--bg-panel-elevated: #22252a;
--bg-panel-hover: #282c33;
/* Borders & Dividers */
--border-subtle: rgba(255, 255, 255, 0.08); /* 1px Hairline Kante */
--border-focus: rgba(29, 140, 248, 0.50); /* Electric Blue Hairline */
/* Einzige Akzentfarbe + hellere/gedimmte Variante */
--accent-primary: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.14);
--accent-border: rgba(59, 130, 246, 0.45);
/* Akzente & Status */
--color-primary: #1d8cf8; /* Electric Blue Primary */
--color-on-pri: #ffffff; /* Text on Primary */
--color-success: #22c55e; /* Vivid Green (Kept / Check / Done) */
--color-danger: #ef4444; /* Vibrant Red (Discard / Delete) */
--color-warning: #f97316; /* Vivid Orange (High Priority) */
--color-purple: #a855f7; /* Accent Purple */
/* Kanten & Linien */
--border-color: rgba(255, 255, 255, 0.09);
--border-subtle: rgba(255, 255, 255, 0.05);
/* Typografie */
--text-primary: #ffffff; /* High Emphasis */
--text-secondary: #d1d5db; /* Medium Emphasis */
--text-muted: #9ca3af; /* Low Emphasis / Subtle */
```
### UI-Regeln & Radien
* **Keine grellen Farbverläufe:** Niemals Blau-zu-Lila-Gradienten wie billige AI-Templates. Strikte neutrale Anthrazit-Flächen mit satten Einzelfarben (`#1d8cf8`, `#22c55e`, `#f97316`).
* **Squircles für Icons:** `w-10 h-10` oder `w-9 h-9` mit `rounded-xl` in soliden Farben.
* **Pill-Buttons:** `rounded-full` (`border-radius: 9999px`) mit reinweißem Text auf `#1d8cf8`.
* **Karten:** `rounded-2xl` (`16px`) oder `rounded-3xl` (`24px`), `bg-[#222428]`, feine Hairline-Border `1px solid rgba(255, 255, 255, 0.08)`.
* **Fokus/Active Outline:** `border border-[#22c55e]/70` mit dezentem Glow für aktive Elemente.
* **Floating Bottom Dock:** Feste Daumenleiste mit zentriertem runden `+` Button in `#1d8cf8`.
* **Gesten-Physik (Framer Motion):**
* Swipe-Schwellenwert: `dragConstraints={{ left: -150, right: 150 }}`
* Spring-Config: `{ type: "spring", stiffness: 350, damping: 25 }`
* Swipe-Winkel-Rotation: `rotate = x * 0.1`
---
## ⚙️ 2. State-Machine: Gedanken-Lifecycle
```
[ INSTANT CAPTURE ]
│
▼
status: 'inbox'
│
┌───────────┴───────────┐
│ (Swipe Left) │ (Swipe Right)
▼ ▼
status: 'discarded' status: 'kept'
(Soft Delete) │
┌───────┴───────┐
│ (Convert) │ (Direct Task)
▼ ▼
status: 'converted' due_date != null
├── target: 'task'
├── target: 'document'
└── target: 'list_item'
```
### Gültige Status-Enums
* `inbox` – Frisch eingekippt, wartet auf Triage.
* `discarded` – Nach links gewischt (bleibt für 30 Tage im Papierkorb, dann Hard Delete).
* `kept` – Nach rechts gewischt, bleibt als To-Do oder Gedanke archiviert.
* `converted` – In vollwertiges Dokument, Aufgabe oder Listen-Item transformiert.
---
## 📡 3. WebSocket Realtime Protocol (Family Lists)
Verbindungs-URL: `wss://<host>/api/v1/ws/spaces/:spaceId`
### Nachrichten-Format (JSON)
```typescript
interface WSMessage<T = unknown> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'USER_PRESENCE';
space_id: string;
list_id: string;
payload: T;
timestamp: string; // ISO 8601
/* Eckiges Layout (0px für Karten/Panels, minimal für Inputs) */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
}
```
### Schnelle Event-Referenz
* `ITEM_TOGGLED`: Wenn jemand die Hafermilch abhakt:
`{ "item_id": "itm_123", "is_completed": true, "completed_by": "usr_sandro" }`
* `Optimistic UI`: Frontend schlägt das Item sofort lokal durch und rollt nur bei WebSocket-Fehler zurück.
### Zentrale Komponenten-Klassen
- `.panel-surface` / `.panel-elevated`: Eckige Container mit `1px solid var(--border-color)`.
- `.btn-action`: Monochromer, kompakter Button (`border-radius: var(--radius-btn)`).
- `.btn-primary`: Akzent-Button auf `--accent-primary`.
- `.input-field`: Minimal gerundetes (`2px`) Eingabefeld für Eigenschaften/Optionen.
- `.tab-btn` / `.tab-btn-active`: Eckige, monochrome Header-Tabs.
- `.badge-accent`: Kompaktes Akzent-Badge für Zähler und Tags.
- **Ausnahme für Rundungen (`rounded-full`):** Nur die Schnell-Eingabe-Blase (`InstantCaptureBar`) im Header, der Profil-Avatar und Toggle-Switches sind rund.
---
## 🗂️ 4. Ordnerstruktur & Code-Konventionen
## ⚙️ 2. State-Machine: Quick Dump, Direkt-Zuordnung & Triage
```
mindsift/
├── cmd/
│ └── server/ # Go Main Entrypoint (bindet embed.FS ein)
├── internal/
│ ├── api/ # HTTP Chi Handler, Routing & Middleware
│ ├── domain/ # Entities, Interfaces & Domain Logic
│ ├── storage/ # SQLite / Postgres Adapter (sqlc generiert)
│ ├── ws/ # WebSocket Hub & Raumverwaltung
│ └── webhook/ # Smarthome Ingestion Logic
├── migrations/ # Reine .sql Migrationsdateien
├── frontend/
│ ├── src/
│ │ ├── components/ # UI Primitives (Button, Pill, Input, Modal)
│ │ ├── features/
│ │ │ ├── capture/ # Instant-Input & Voice Recording
│ │ │ ├── triage/ # Framer Motion Swipe Deck
│ │ │ ├── workspace/ # Tiptap Block Editor
│ │ │ └── lists/ # Realtime Shopping / Todo Lists
│ │ ├── lib/ # WS Client, API Client, Sound/Haptics
│ │ └── index.css # Tailwind v4 & Design-Tokens / CSS Variablen
├── .gitea/workflows/ # Gitea Actions CI/CD
├── Dockerfile # Multi-Stage Build (< 35 MB)
├── docker-compose.yml
├── README.md # Öffentliche Doku & Vision
└── BRAIN.md # Dieses Dokument (Dev Cheat Sheet)
[ HEADER QUICK-CAPTURE BAR ]
│
┌───────────────────────┼───────────────────────┐
│ Standard (Enter) │ Direkt-Option gewählt │
▼ ▼ ▼
status: 'inbox' status: 'converted' status: 'converted'
(Quick Dump Deck) ├── target: 'task' ├── target: 'list_item'
│ └── target: 'document' └── Broadcast via WS
│
├──► Swipe Links / "Wegwerfen" (H / ←) ──► status: 'discarded'
├──► "Später" (Leertaste) ──► ans Ende der lokalen Queue
├──► Swipe Rechts / "Als Aufgabe" (L/→)──► status: 'converted' (type: 'task')
├──► "Mit Termin" (T) ──► status: 'converted' (type: 'task', dueDate)
├──► "Als Notiz" (D) ──► status: 'converted' (type: 'document' + Doc angelegt)
└──► "Auf Liste" ──► status: 'converted' (type: 'list_item' + Item angelegt)
```
### Namens- & ID-Konventionen
* **Entity IDs:** Präfix-basierte Strings für Lesbarkeit beim Debuggen:
* `usr_` = User (`usr_9k2m1a`)
* `th_` = Thought (`th_4x8z19`)
* `doc_` = Document (`doc_81na72`)
* `lst_` = List (`lst_33fa09`)
* `itm_` = List Item (`itm_77bb41`)
* **Datenbank:** `snake_case` (z. B. `is_completed`, `due_date`)
* **JSON API:** `camelCase` (z. B. `isCompleted`, `dueDate`)
* **Timestamps:** Immer UTC ISO-8601 (`2026-10-24T14:20:00Z`)
### API-Endpunkt für Capture (`POST /api/v1/inbox`)
Akzeptiert neben `content`, `priority`, `source`, `dueDate` auch direkt:
- `target`: `'inbox'` (Standard Quick Dump) | `'task'` | `'list_item'` | `'document'`
- `targetListId`: ID der Zielliste (wenn `target === 'list_item'`)
---
## ⌨️ 5. Power-User Tastatur-Shortcuts (Desktop)
## 🛡️ 3. Kritische Backend-Regeln (SQLite & HTTP)
1. **NIEMALS verschachtelte Queries bei offenem `sql.Rows`-Cursor ausführen:**
- In [`internal/storage/sqlite.go`](file:///home/sandro/Dokumente/git/MindSift/internal/storage/sqlite.go) muss jede Schleife `for rows.Next()` zuerst alle Zeilen in ein Slice einlesen und sofort `_ = rows.Close()` aufrufen, **bevor** Folgeabfragen (`GetListItems`, `GetSpaceMembers`, `QueryRow` etc.) ausgeführt werden.
2. **SQLite DSN-Pragmas:**
- Jede Verbindung im `sql.DB`-Pool erhält über den DSN automatisch:
`?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)`
3. **Thread-Safe Default User (`EnsureDefaultUser`):**
- Durch `s.initMu.Lock()` abgesichert, damit parallele Browser-Requests beim ersten Laden niemals in einen `UNIQUE constraint`-Konflikt laufen.
4. **Kein Browser-Caching auf API & `index.html`:**
- `writeJSON` und der SPA-Fallback setzen `Cache-Control: no-store, no-cache, must-revalidate`, damit Chrome und Firefox nach jedem Container-Update sofort den aktuellen Stand zeigen.
---
## 📡 4. WebSocket Realtime Protocol (Geteilte Listen)
Verbindungs-URL: `ws(s)://<host>/api/v1/ws/spaces/:spaceId?token=<jwt>`
### Events
- `ITEM_CREATED` (auch ausgelöst, wenn ein Gedanke per Direkt-Zuordnung oder Triage auf eine Liste geschoben wird)
- `ITEM_TOGGLED`
- `ITEM_DELETED`
- `LIST_CREATED` / `LIST_DELETED` / `LIST_CLEARED`
- `USER_PRESENCE`
---
## ⌨️ 5. Tastatur-Shortcuts (Desktop)
| Taste | Kontext | Aktion |
| :--- | :--- | :--- |
| `C` oder `/` | Überall | Fokus sofort auf das Capture-Eingabefeld |
| `H` oder `←` | Triage | Karte nach links wischen (Verwerfen) |
| `L` oder `→` | Triage | Karte nach rechts wischen (Behalten) |
| `T` | Triage | Sofort in Aufgabe mit Fälligkeitsdatum verwandeln |
| `D` | Triage | In Workspace-Dokument umwandeln |
| `Esc` | Modal/Capture | Abbrechen / Eingabe leeren |
| `C` oder `/` | Überall | Fokus sofort oben in die Quick-Dump-Eingabeleiste |
| `Enter` | Capture-Leiste | Sofort speichern (Quick Dump oder gewähltes Direkt-Ziel) |
| `Esc` | Capture-Leiste | Eingabe leeren & Dropdown schließen |
| `H` oder `←` | Triage-Deck | Aktiven Quick Dump wegwerfen (`discard`) |
| `L` oder `→` | Triage-Deck | Aktiven Quick Dump sofort zur Aufgabe machen (`task`) |
| `T` | Triage-Deck | Datums-Auswahl für Termin-Aufgabe öffnen |
| `D` | Triage-Deck | Aktiven Quick Dump sofort in Workspace-Notiz umwandeln |
| `Leertaste` | Triage-Deck | Karte überspringen („Später" – ans Ende des Stapels schieben) |
---
## ⚡ 6. Cheat Sheet: Lokale Entwickler-Befehle
```bash
# 1. Frontend Dev Server (Hot Reload)
cd frontend && npm run dev
# 2. Go Backend starten (Hot Reload via air oder go run)
go run ./cmd/server
# 3. Datenbank-Migrationen lokal ausführen
go run ./cmd/migrate -direction=up
# 4. Lokalen Multi-Stage Docker-Build testen
docker build -t mindsift:dev .
docker run -p 8080:8080 -v $(pwd)/dev-data:/data -e JWT_SECRET="dev-secret-12345" mindsift:dev
# 5. SQLite DB direkt per CLI inspizieren
sqlite3 ./dev-data/mindsift.db ".tables" "SELECT * FROM thoughts LIMIT 5;"
```
---
## 🚀 7. Gitea CI/CD & Docker Tagging-Strategie
* **Aktiver Arbeits-Branch:** `dev` (hier entwickeln und pushen wir immer)
* **Registry:** `git.dsit.me/sandrodach/mindsift`
* **Entwickler-Image:** `git.dsit.me/sandrodach/mindsift:dev` (wird bei jedem Push auf `dev` überschrieben – kein Zumüllen der Registry!)
* **Gitea Secret für Registry-Login:** `secrets.PUSH_TOKEN`
* **API-Endpunkt zum Auslesen der Pipeline:** `https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks`
* **Branch- & Release-Regeln ([`.gitea/workflows/build-and-publish.yaml`](file:///home/sandro/Dokumente/git/MindSift/.gitea/workflows/build-and-publish.yaml)):**
* **Push auf `dev`:** Führt Linter, TypeScript-Checks (`tsc --noEmit`), Unit-Tests aus **und** überschreibt das Image `:dev`.
* **Push auf `main`:** Ignoriert (löst keinen Runner aus).
* **Git-Tag / Release (z. B. `v1.0`, `1.1`, `latest`):** Wird manuell bei echten Releases erstellt und baut das entsprechende Release-Image.
* **Cross-Compile Speedup:** `Dockerfile` nutzt `FROM --platform=$BUILDPLATFORM` für Node & Go und kompiliert `amd64` + `arm64` nativ über `GOOS=${TARGETOS} GOARCH=${TARGETARCH}` ohne langsames QEMU.
## 🚀 6. Gitea CI/CD & Docker Workflow
- **Arbeits-Branch:** `dev` (`origin/dev`)
- **Ziel-Image:** `git.dsit.me/sandrodach/mindsift:dev`
- **Build-Prinzip:** Ein einziger Multi-Stage-Build ([`Dockerfile`](file:///home/sandro/Dokumente/git/MindSift/Dockerfile)), der Frontend-Typecheck, Vite-Build und Go-Unit-Tests (`go test -v ./...` inkl. [`internal/api/server_test.go`](file:///home/sandro/Dokumente/git/MindSift/internal/api/server_test.go)) direkt beim Image-Bau ausführt und bei Erfolg sofort pusht.
+6 -3
View File
@@ -15,10 +15,13 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
navigator.serviceWorker.getRegistrations().then((regs) => {
regs.forEach((r) => r.unregister());
}).catch(() => {});
}
</script>
</body>
+9 -24
View File
@@ -1,33 +1,18 @@
const CACHE_NAME = 'mindsift-pwa-v1';
const ASSETS = ['/', '/index.html', '/manifest.json'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
caches
.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || event.request.url.includes('/api/')) {
return;
}
event.respondWith(
fetch(event.request)
.then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return res;
})
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('/')))
);
self.addEventListener('fetch', () => {
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
return;
});
@@ -148,40 +148,46 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const effectiveTarget = overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = isListening ? 'voice' : 'manual';
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
setPriority('normal');
setDueDate('');
setTarget('inbox');
setShowOptions(false);
setIsSubmitting(true);
triggerHaptic(15);
const savedMsg =
effectiveTarget === 'task'
? 'Als Aufgabe!'
: effectiveTarget === 'list_item'
? 'Auf Liste!'
: effectiveTarget === 'document'
? 'Als Notiz!'
: 'Im Dump!';
setJustSavedLabel(savedMsg);
setTimeout(() => setJustSavedLabel(null), 1600);
inputRef.current?.focus();
try {
const created = await api.captureThought(
trimmed,
priority,
isListening ? 'voice' : 'manual',
dueDate,
capturedPriority,
capturedSource,
capturedDueDate,
{
target: effectiveTarget,
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
}
);
setContent('');
setPriority('normal');
setDueDate('');
setTarget('inbox');
setShowOptions(false);
const savedMsg =
effectiveTarget === 'task'
? 'Als Aufgabe!'
: effectiveTarget === 'list_item'
? 'Auf Liste!'
: effectiveTarget === 'document'
? 'Als Notiz!'
: 'Im Dump!';
setJustSavedLabel(savedMsg);
setTimeout(() => setJustSavedLabel(null), 1600);
onCaptured(created, effectiveTarget);
inputRef.current?.focus();
} catch {
// Restore text if capture unexpectedly failed completely
setContent(trimmed);
} finally {
setIsSubmitting(false);
}
@@ -221,6 +227,12 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
executeCapture();
}
}}
placeholder={
isListening
? 'Ich höre zu…'
+16 -4
View File
@@ -40,6 +40,7 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
...(options.headers as Record<string, string>),
};
@@ -47,16 +48,27 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(path, {
...options,
headers,
});
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
let res: Response;
try {
res = await fetch(path, {
...options,
headers,
cache: 'no-store',
signal: controller.signal,
});
} finally {
window.clearTimeout(timeoutId);
}
if (res.status === 401 && !isRetry && !path.includes('/auth/login') && !path.includes('/auth/quickstart')) {
try {
const qsRes = await fetch('/api/v1/auth/quickstart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
if (qsRes.ok) {
const qsData = await qsRes.json();
+12
View File
@@ -112,8 +112,19 @@ func (s *Server) Router() http.Handler {
path = "index.html"
}
if _, err := fs.Stat(s.staticFS, path); err != nil {
// Never return index.html for missing JS/CSS assets
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
http.NotFound(w, req)
return
}
// SPA client-side routing fallback to index.html
req.URL.Path = "/"
path = "index.html"
}
if path == "index.html" || path == "sw.js" || path == "manifest.json" {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
fileServer.ServeHTTP(w, req)
})
@@ -126,6 +137,7 @@ func (s *Server) Router() http.Handler {
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
+186
View File
@@ -0,0 +1,186 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
user, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("failed to ensure default user: %v", err)
}
cfg := &config.Config{
Port: "8080",
DBPath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: true,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
return srv, store, token
}
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
endpoints := []string{
"/api/v1/auth/me",
"/api/v1/triage/deck",
"/api/v1/lists",
"/api/v1/spaces",
"/api/v1/tasks",
"/api/v1/documents",
}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
for _, ep := range endpoints {
wg.Add(1)
go func(path string) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String())
}
}(ep)
}
}
wg.Wait()
}
func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
// Generate a stale token from a previous container run (non-existent user ID)
staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro")
if err != nil {
t.Fatalf("failed to generate stale token: %v", err)
}
// 1. Capture Quick Dump (default target: inbox) using stale token
dumpPayload := map[string]string{
"content": "Schneller Quick Dump Testgedanke #test",
"priority": "high",
"target": "inbox",
}
body, _ := json.Marshal(dumpPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Quick Dump, got %d: %s", rec.Code, rec.Body.String())
}
var createdDump domain.Thought
if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil {
t.Fatalf("failed to decode created dump: %v", err)
}
if createdDump.Status != domain.StatusInbox {
t.Fatalf("expected status 'inbox', got %q", createdDump.Status)
}
// 2. Capture Direct Task (target: task)
taskPayload := map[string]string{
"content": "Direkte Aufgabe ohne Triage",
"priority": "urgent",
"target": "task",
"dueDate": "2026-11-01",
}
body, _ = json.Marshal(taskPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String())
}
var createdTask domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdTask)
if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" {
t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType)
}
// 3. Capture Direct List Item (target: list_item)
defUser, _ := store.EnsureDefaultUser()
lists, err := store.GetAllListsForUser(defUser.ID)
if err != nil || len(lists) == 0 {
t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err)
}
targetListID := lists[0].ID
listPayload := map[string]string{
"content": "Frische Haferflocken",
"target": "list_item",
"targetListId": targetListID,
}
body, _ = json.Marshal(listPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String())
}
var createdListItemThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought)
if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" {
t.Fatalf("expected converted list_item, got %+v", createdListItemThought)
}
// 4. Swipe Triage card to Task
triagePayload := map[string]string{
"action": "keep",
"convertTo": "task",
}
body, _ = json.Marshal(triagePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String())
}
}
+38 -17
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
@@ -15,22 +16,30 @@ import (
)
type Store struct {
db *sql.DB
db *sql.DB
initMu sync.Mutex
}
func NewStore(dbPath string) (*Store, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
}
}
db, err := sql.Open("sqlite", dbPath)
dsn := dbPath
if !strings.Contains(dsn, "?") {
dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
}
db.SetMaxOpenConns(1) // SQLite WAL optimal writer serialization in single process
db.SetMaxIdleConns(1)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
store := &Store{db: db}
if err := store.migrate(); err != nil {
@@ -266,6 +275,9 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
// EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
s.initMu.Lock()
defer s.initMu.Unlock()
count, err := s.CountUsers()
if err != nil {
return nil, err
@@ -280,7 +292,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
}
user, err := s.CreateUser("admin@mindsift.local", hash, "Sandro")
if err != nil {
return nil, err
return s.GetFirstUser()
}
_ = s.SeedStarterContent(user)
return user, nil
@@ -872,7 +884,6 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
if err != nil {
return nil, err
}
defer rows.Close()
spaces := make([]domain.Space, 0)
for rows.Next() {
@@ -880,11 +891,15 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
var personalInt int
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
sp.IsPersonal = personalInt != 0
sp.Members, _ = s.GetSpaceMembers(sp.ID)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, sp.ID).Scan(&sp.ListsCount)
spaces = append(spaces, sp)
}
}
_ = rows.Close()
for i := range spaces {
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
}
return spaces, nil
}
@@ -929,16 +944,19 @@ func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
if err != nil {
return nil, err
}
defer rows.Close()
lists := make([]domain.SharedList, 0)
for rows.Next() {
var l domain.SharedList
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
l.Items, _ = s.GetListItems(l.ID)
lists = append(lists, l)
}
}
_ = rows.Close()
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
}
return lists, nil
}
@@ -954,16 +972,19 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
if err != nil {
return nil, err
}
defer rows.Close()
lists := make([]domain.SharedList, 0)
for rows.Next() {
var l domain.SharedList
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
l.Items, _ = s.GetListItems(l.ID)
lists = append(lists, l)
}
}
_ = rows.Close()
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
}
return lists, nil
}
@@ -1183,12 +1204,12 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
)
thoughts := make([]domain.Thought, 0)
if err == nil {
defer tRows.Close()
for tRows.Next() {
if t, err := scanThought(tRows); err == nil {
thoughts = append(thoughts, *t)
}
}
_ = tRows.Close()
}
dRows, err := s.db.Query(
@@ -1198,12 +1219,12 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
)
docs := make([]domain.Document, 0)
if err == nil {
defer dRows.Close()
for dRows.Next() {
if d, err := scanDocument(dRows); err == nil {
docs = append(docs, *d)
}
}
_ = dRows.Close()
}
return thoughts, docs, nil