diff --git a/Doku/README.md b/Doku/README.md index 6a2c4c6..32115a3 100644 --- a/Doku/README.md +++ b/Doku/README.md @@ -18,6 +18,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code | [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben | | [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare | | [theming.md](theming.md) | Themes und Design-Variablen | +| [ui-patterns.md](ui-patterns.md) | Einheitliche Bedienelemente: Personensuche, Profilbild-Stapel, Zähler | | [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden | ## Überblick in 60 Sekunden diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6dc26a8..8087706 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -38,7 +38,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` |---|---|---| | POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) | | GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) | -| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen | +| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` | | DELETE | `/thoughts/discarded/purge` | Papierkorb leeren | | GET | `/triage/deck` | Posteingang (max. 100) | | PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` | diff --git a/Doku/database.md b/Doku/database.md index 001fe4c..3e2d337 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -19,6 +19,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera | Tabelle | Zweck | Wichtige Spalten | |---|---|---| | `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) | +| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` | | `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) | | `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) | @@ -48,7 +49,7 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca |---|---| | `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) | | `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) | -| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` | +| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by` (leer bei alten Einträgen), `sort_order` | ### Gruppen | Tabelle | Zweck | diff --git a/Doku/modules/lists.md b/Doku/modules/lists.md index be17af6..f0daa59 100644 --- a/Doku/modules/lists.md +++ b/Doku/modules/lists.md @@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi ## Datenmodell - `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`). -- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`. +- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by`, `sort_order`. - `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`. ## Funktionen @@ -16,4 +16,6 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi - **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`). - **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts. - **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort. +- **Wer hat's hinzugefügt:** In geteilten Listen zeigt jeder Eintrag ein kleines Profilbild der Person, die ihn angelegt hat (`createdBy`, `createdByName`). Einträge von vor dieser Funktion haben keinen Autor und zeigen nichts. +- **Tabs** zeigen offene Einträge als runden Zähler und die Personen mit Zugriff als Profilbild-Stapel ([ui-patterns.md](../ui-patterns.md)); die API liefert sie je Liste als `members`. - Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen. diff --git a/Doku/modules/tasks.md b/Doku/modules/tasks.md index 1ad28a5..587c647 100644 --- a/Doku/modules/tasks.md +++ b/Doku/modules/tasks.md @@ -4,7 +4,7 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe ## Datenmodell -Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich. +Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich. ## Ansichten @@ -21,6 +21,9 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus - **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“. - Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen. - Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade. +- **Zuweisen** (nur in geteilten Listen, also mit mindestens einer Person außer dem Eigentümer): Im Detail-Fenster unter „Zugewiesen“ lassen sich eine oder mehrere Personen mit Zugriff auf die Liste zuweisen, über die einheitliche Personensuche ([ui-patterns.md](../ui-patterns.md)). Zuweisen dürfen Eigentümer und Bearbeiter. Zeilen und Kanban-Karten zeigen die Zugewiesenen als Profilbild-Stapel. + - Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`). + - **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Ändern von `team_id`, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen. - Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen). ## Alarme diff --git a/Doku/sharing-and-permissions.md b/Doku/sharing-and-permissions.md index 157f658..662a341 100644 --- a/Doku/sharing-and-permissions.md +++ b/Doku/sharing-and-permissions.md @@ -20,6 +20,7 @@ - **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live. - Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe. - Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder. +- **Aufgaben-Zuweisungen** hängen am Zugriff: Verliert jemand den letzten Zugriffsweg auf eine Aufgabenliste, entfernt der Server ihn aus allen Zuweisungen dieser Liste ([modules/tasks.md](modules/tasks.md)). - Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist. ## Aufgabenlisten teilen (Details) diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md new file mode 100644 index 0000000..7fd8ef0 --- /dev/null +++ b/Doku/ui-patterns.md @@ -0,0 +1,41 @@ +# UI-Muster + +Wiederkehrende Bedienelemente, die überall gleich aussehen und sich gleich verhalten. Wer eines davon neu braucht, nimmt die vorhandene Komponente und baut keine eigene Variante. + +## Personen suchen (Type-to-Search) + +Komponente: [UserAutocomplete.tsx](../frontend/src/features/UserAutocomplete.tsx) + +Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`GroupsView`) und beim Zuweisen von Aufgaben (`TaskDetailModal`). + +- **Keine Liste vorab:** Solange das Feld leer ist, erscheint nichts, auch nicht beim Reinklicken. Kein Dropdown mit allen Personen. +- **Erst beim Tippen** erscheinen Treffer direkt unter dem Feld, **höchstens 3** (`MAX_RESULTS`). Gesucht wird in Anzeigename, `@Benutzername` und E-Mail. +- Der **erste Treffer ist vorausgewählt**: Tippen + Enter übernimmt ihn. Pfeiltasten wechseln, Escape schließt. +- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet. +- **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×. +- **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei. + +## Profilbilder als Stapel + +Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt `UserAvatar`) + +- Kleine runde Profilbilder, **leicht überlappend**, **höchstens 3**. Weitere Personen erscheinen als monochromer Kreis „+n“. +- Ohne Profilbild zeigt `UserAvatar` die Initialen. +- `ringClass` bekommt die Farbe der Fläche dahinter (meist `ring-[var(--bg-panel)]` oder `ring-[var(--bg-app)]`), damit die Kreise sauber getrennt sind. +- Größen: `xxs` (16 px) in Tabs, Kanban-Karten und Listeneinträgen, `xs` (20 px) in Aufgabenzeilen und Kopfzeilen. +- **Kein Hover-Effekt** im Stapel selbst. Einzige Ausnahme ist die Anwesenheit in Notizen ([PresenceAvatars.tsx](../frontend/src/features/workspace/PresenceAvatars.tsx)): Dort zeigt Hover oder Tastaturfokus die Liste „Gerade online“. + +| Ort | Wer wird gezeigt | +|---|---| +| Notiz-Kopfzeile | wer die Notiz gerade geöffnet hat (Profilbilder aus `/users/directory`) | +| Tabs der Aufgabenlisten und Listen | alle mit Zugriff außer mir (`members`) | +| Aufgabenzeile und Kanban-Karte | zugewiesene Personen (nur in geteilten Listen) | +| Eintrag einer geteilten Liste | wer ihn hinzugefügt hat (`createdBy`); ältere Einträge ohne Autor zeigen nichts | + +## Zähler + +CSS-Klasse `badge-count` in [index.css](../frontend/src/index.css): runder Zähler in der Form der Zähler der Navigationsleiste, aber **monochrom** (grau gefüllt) statt in der Akzentfarbe. + +- Verwendet für die Anzahl offener Einträge in den Tabs der Aufgabenlisten und der Listen. +- Die Zähler der Navigationsleiste bleiben blau (Akzent), weil sie auf neue Inhalte aufmerksam machen. +- Kein eckiges `badge-neutral` mehr für Zähler in Tabs. diff --git a/e2e/share.e2e.mjs b/e2e/share.e2e.mjs index 24c67c0..862acc9 100644 --- a/e2e/share.e2e.mjs +++ b/e2e/share.e2e.mjs @@ -69,8 +69,7 @@ const main = async () => { await search.click(); await search.type('user'); await sleep(300); - await search.press('ArrowDown'); - await search.press('Enter'); + await search.press('Enter'); // the first match is preselected const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last(); check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar'); await roleSelect.selectOption('editor'); diff --git a/frontend/src/features/UserAutocomplete.tsx b/frontend/src/features/UserAutocomplete.tsx index c3bb558..debcde6 100644 --- a/frontend/src/features/UserAutocomplete.tsx +++ b/frontend/src/features/UserAutocomplete.tsx @@ -3,6 +3,10 @@ import { Search, X, Check } from 'lucide-react'; import { User } from '../lib/types'; import { UserAvatar } from '../components/UserAvatar'; +// Shared type-to-search for picking people (sharing, groups, task assignees). Nothing is listed until +// something is typed, then at most MAX_RESULTS matches appear below the field. See Doku/ui-patterns.md. +const MAX_RESULTS = 3; + interface UserAutocompleteProps { users: User[]; selectedUser: User | null; @@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC = ({ // Filter users based on query and exclusions const filteredUsers = React.useMemo(() => { const q = query.trim().toLowerCase(); + if (!q) return []; const available = users.filter((u) => !excludeUserIds.includes(u.id)); - if (!q) { - return available.slice(0, 15); - } return available .filter((u) => { const nameMatch = (u.displayName || '').toLowerCase().includes(q); @@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC = ({ const userMatch = (u.username || '').toLowerCase().includes(q); return nameMatch || emailMatch || userMatch; }) - .slice(0, 15); + .slice(0, MAX_RESULTS); }, [users, query, excludeUserIds]); + const showResults = isOpen && query.trim() !== ''; const handleSelect = (user: User) => { onSelectUser(user); @@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (!isOpen) { - if (e.key === 'ArrowDown' || e.key === 'Enter') { + if (!showResults) { + if (e.key === 'ArrowDown') { setIsOpen(true); } return; @@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC = ({ onChange={(e) => { setQuery(e.target.value); setIsOpen(true); - setHighlightedIndex(-1); + // First match is preselected so Enter picks it right away + setHighlightedIndex(0); }} onFocus={() => setIsOpen(true)} onKeyDown={handleKeyDown} @@ -147,8 +151,8 @@ export const UserAutocomplete: React.FC = ({ className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50" /> - {isOpen && ( -
+ {showResults && ( +
{filteredUsers.length === 0 ? (
Kein passender Benutzer gefunden diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 0ae8948..1a3dfc5 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -13,6 +13,7 @@ import { import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; +import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { @@ -144,10 +145,32 @@ export const SharedListsView: React.FC = ({ onRefreshGloba .catch(() => {}); }, []); + // People shown in tabs: everyone with access except me + const otherMembers = (lst: SharedList): AvatarPerson[] => + (lst.members || []) + .filter((m) => m.userId !== currentUser?.id) + .map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl })); + const activeList = useMemo(() => { return lists.find((l) => l.id === activeListId) || lists[0] || null; }, [lists, activeListId]); + // In shared lists every item shows who added it (older items without an author show nothing) + const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0; + const renderItemAuthor = (item: ListItem) => { + if (!isActiveListShared || !item.createdBy) return null; + const member = activeList?.members?.find((m) => m.userId === item.createdBy); + const name = member?.displayName || member?.email || item.createdByName || ''; + return ( + + ); + }; + useEffect(() => { if (!activeList?.id) return; @@ -838,8 +861,14 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {lst.icon} {lst.title} {openCount > 0 && ( - {openCount} + {openCount} )} + {isSelected && lists.length > 1 && (
+ {renderItemAuthor(item)}
+ {renderItemAuthor(item)} {item.completedByName && ( ✓ {item.completedByName} )} diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 93720ef..c514e1a 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -16,8 +16,11 @@ import { Folder, Webhook, Lock, + UserPlus, } from 'lucide-react'; -import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; +import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types'; +import { UserAvatar } from '../../components/UserAvatar'; +import { UserAutocomplete } from '../UserAutocomplete'; import { triggerHaptic } from '../../lib/api'; import { MarkdownViewer } from '../../lib/markdown'; @@ -34,6 +37,8 @@ interface TaskDetailModalProps { onUpdateTaskList: (task: Thought, newListId: string) => Promise; onUpdateNotes: (task: Thought, newNotes: string) => Promise; onUpdateTags?: (task: Thought, newTags: string[]) => Promise; + assignableMembers?: TaskListMember[]; // empty unless the task's list is shared + onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise; onToggleSubtask: (task: Thought, subtaskId: string) => Promise; onAddSubtask: (task: Thought, title: string) => Promise; onDeleteSubtask: (task: Thought, subtaskId: string) => Promise; @@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC = ({ onUpdateTaskList, onUpdateNotes, onUpdateTags, + assignableMembers = [], + onUpdateAssignees, onToggleSubtask, onAddSubtask, onDeleteSubtask, @@ -525,6 +532,65 @@ export const TaskDetailModal: React.FC = ({
+ {/* Assignees (shared lists only): chips + the shared type-to-search */} + {assignableMembers.length > 0 && (() => { + const assigned = (task.assignees || []) + .map((id) => assignableMembers.find((m) => m.userId === id)) + .filter((m): m is TaskListMember => !!m); + const assignedIds = assigned.map((m) => m.userId); + const candidates: User[] = assignableMembers.map((m) => ({ + id: m.userId, + email: m.email, + displayName: m.displayName, + avatarUrl: m.avatarUrl, + role: 'member', + isActive: true, + createdAt: '', + })); + return ( +
+ + {assigned.length > 0 ? ( +
+ {assigned.map((m) => ( + + + {m.displayName || m.email} + {!readOnly && onUpdateAssignees && ( + + )} + + ))} +
+ ) : ( + readOnly &&

Niemandem zugewiesen

+ )} + {!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && ( + u && onUpdateAssignees(task, [...assignedIds, u.id])} + excludeUserIds={assignedIds} + placeholder="Person zuweisen (Name tippen)…" + /> + )} +
+ ); + })()} + {/* Hashtags Section */}
+ {renderAssignees(task, 'xs')} + {/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()}> ))} + {getAssigneePeople(task).length > 0 && ( + {renderAssignees(task, 'xxs')} + )}
{/* Column Navigation controls: Move Left / Move Right */} diff --git a/frontend/src/index.css b/frontend/src/index.css index 9b5c1c4..edc497f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -293,6 +293,22 @@ body { font-weight: 500; } + /* Round count bubble: same shape as the nav bar counters, but monochrome (tabs of task lists & lists) */ + .badge-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 0.25rem; + border-radius: 9999px; + background-color: var(--text-muted); + color: var(--bg-panel); + font-size: 9px; + font-weight: 700; + line-height: 1; + } + .badge-ok { display: inline-flex; align-items: center; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 33d1369..65a86b8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -362,6 +362,7 @@ export const api = { notes?: string; subtasks?: SubTask[]; tags?: string[]; + assignees?: string[]; } ): Promise { return request(`/api/v1/thoughts/${thoughtId}`, { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8e29b00..9fdf9b4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -62,6 +62,7 @@ export interface Thought { kanbanStatus?: string; notes?: string; subtasks?: SubTask[]; + assignees?: string[]; // user IDs, only people with access to the task list alarmSent?: boolean; sortOrder?: number; createdAt: string; @@ -349,6 +350,8 @@ export interface ListItem { isCompleted: boolean; completedBy?: string; completedByName?: string; + createdBy?: string; + createdByName?: string; sortOrder: number; createdAt: string; completedAt?: string; @@ -378,6 +381,7 @@ export interface SharedList { listType: 'shopping' | 'checklist' | 'vacation'; items: ListItem[]; shares?: ListShare[]; + members?: TaskListMember[]; // owner + everyone the list is shared with updatedAt: string; createdAt: string; } diff --git a/internal/api/server.go b/internal/api/server.go index 1545f67..91a14d7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -299,7 +299,7 @@ func writeError(w http.ResponseWriter, status int, message string) { // writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status. func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) { - if errors.Is(err, storage.ErrTaskListReadOnly) { + if errors.Is(err, storage.ErrTaskListReadOnly) || errors.Is(err, storage.ErrAssigneeNoAccess) { writeError(w, http.StatusForbidden, err.Error()) return } @@ -914,6 +914,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { Notes *string `json:"notes"` Subtasks *[]domain.SubTask `json:"subtasks"` Tags *[]string `json:"tags"` + Assignees *[]string `json:"assignees"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -926,6 +927,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { writeStoreError(w, http.StatusBadRequest, err) return } + if req.Assignees != nil { + assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees) + if err != nil { + writeStoreError(w, http.StatusBadRequest, err) + return + } + updated.Assignees = assignees + } if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" { _, head, _ := s.store.GetDocOpsSince(docID, 0, 0) s.notifyDocChanged(docID, head) @@ -1652,6 +1661,7 @@ func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") var req struct { Content string `json:"content"` @@ -1663,7 +1673,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { return } - item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0) + item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0, userID) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/domain/models.go b/internal/domain/models.go index 44cbd18..4e127fd 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -181,6 +181,7 @@ type Thought struct { KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done Notes string `json:"notes,omitempty"` Subtasks []SubTask `json:"subtasks"` + Assignees []string `json:"assignees"` // user IDs; only people with access to the task list AlarmSent bool `json:"alarmSent,omitempty"` SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` @@ -426,24 +427,27 @@ type ListItem struct { IsCompleted bool `json:"isCompleted"` CompletedBy string `json:"completedBy,omitempty"` CompletedByName string `json:"completedByName,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedByName string `json:"createdByName,omitempty"` SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` CompletedAt string `json:"completedAt,omitempty"` } type SharedList struct { - ID string `json:"id"` - UserID string `json:"userId"` - OwnerName string `json:"ownerName,omitempty"` - IsOwner bool `json:"isOwner"` - Role string `json:"role,omitempty"` // editor | viewer - Title string `json:"title"` - Icon string `json:"icon"` - ListType string `json:"listType"` // shopping | checklist | vacation - Items []ListItem `json:"items"` - Shares []ListShare `json:"shares,omitempty"` - UpdatedAt string `json:"updatedAt"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + UserID string `json:"userId"` + OwnerName string `json:"ownerName,omitempty"` + IsOwner bool `json:"isOwner"` + Role string `json:"role,omitempty"` // editor | viewer + Title string `json:"title"` + Icon string `json:"icon"` + ListType string `json:"listType"` // shopping | checklist | vacation + Items []ListItem `json:"items"` + Shares []ListShare `json:"shares,omitempty"` + Members []TaskListMember `json:"members,omitempty"` // owner + everyone the list is shared with + UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt"` } type APIKey struct { diff --git a/internal/storage/seed.go b/internal/storage/seed.go index 33bd256..aa500fc 100644 --- a/internal/storage/seed.go +++ b/internal/storage/seed.go @@ -119,7 +119,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error { {"Spülmaschinentabs", "Drogerie", "1 Pack"}, } for i, it := range items { - item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i) + item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i, user.ID) if err == nil && (i == 1 || i == 6 || i == 8) { _, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName) } @@ -132,7 +132,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error { {"Erste-Hilfe-Set", "Ausrüstung"}, {"Stirnlampe", "Ausrüstung"}, {"Trinkflaschen", "Ausrüstung"}, {"Personalausweise", "Dokumente"}, {"Hüttenschlafsack", "Ausrüstung"}, } { - _, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i) + _, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i, user.ID) } } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 90f8698..eaa3619 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -398,6 +398,13 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);", "CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);", "CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);", + + // Task assignments: who a task in a shared task list is assigned to + "CREATE TABLE IF NOT EXISTS task_assignees (task_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(task_id, user_id), FOREIGN KEY(task_id) REFERENCES thoughts(id) ON DELETE CASCADE);", + "CREATE INDEX IF NOT EXISTS idx_task_assignees_user ON task_assignees(user_id);", + + // Who added an item to a (shopping) list + "ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -812,6 +819,7 @@ func (s *Store) UpdateUserPassword(userID, passwordHash string) error { func (s *Store) DeleteUser(userID string) error { _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) + _, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID) _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) return err } @@ -956,6 +964,7 @@ func scanThought(rows interface { t.DocID = parts[0] t.BlockID = parts[1] } + t.Assignees = []string{} t.Subtasks = []domain.SubTask{} if subtasksStr != "" && subtasksStr != "null" { _ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks) @@ -980,7 +989,12 @@ func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, thoughtID, userID, userID, userID, userID, userID, ) - return scanThought(row) + t, err := scanThought(row) + if err != nil { + return nil, err + } + t.Assignees = s.getTaskAssignees(t.ID) + return t, nil } func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) { @@ -1055,6 +1069,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er } } _ = rows.Close() + s.attachTaskAssignees(result) return result, nil } @@ -1190,7 +1205,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target } else if convertTo == "list_item" && targetListID != "" { newStatus = domain.StatusConverted convertedType = "list_item" - item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0) + item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID) if err == nil && item != nil { convertedRefID = item.ID } @@ -1208,6 +1223,9 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target if err != nil { return nil, err } + if taskListID != thought.TaskListID { + s.PruneTaskAssignees() + } return s.GetThoughtByID(userID, thoughtID) } @@ -1285,6 +1303,10 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, if err != nil { return nil, err } + if taskListID != nil { + s.PruneTaskAssignees() + thought.Assignees = s.getTaskAssignees(thoughtID) + } return thought, nil } @@ -3625,6 +3647,7 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { for i := range lists { lists[i].Items, _ = s.GetListItems(lists[i].ID) lists[i].Shares, _ = s.GetListShares(lists[i].ID) + lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID) } return lists, nil } @@ -3673,9 +3696,38 @@ func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) { l.Role = effectiveRole l.Items, _ = s.GetListItems(l.ID) l.Shares, _ = s.GetListShares(l.ID) + l.Members = s.getListMembers(l.ID, l.UserID) return &l, nil } +// getListMembers returns the owner and everyone a list is shared with (directly or via a team). +func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember { + members := make([]domain.TaskListMember, 0) + rows, err := s.db.Query( + `SELECT id, display_name, email, avatar_url FROM users WHERE id IN ( + SELECT user_id FROM lists WHERE id = ? + UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user' + UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team') + ) ORDER BY display_name`, + listID, listID, listID, + ) + if err != nil { + return members + } + defer rows.Close() + for rows.Next() { + var m domain.TaskListMember + if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil { + m.Role = "member" + if m.UserID == ownerID { + m.Role = "owner" + } + members = append(members, m) + } + } + return members +} + func (s *Store) DeleteList(userID, listID string) error { var ownerID string err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) @@ -3691,9 +3743,11 @@ func (s *Store) DeleteList(userID, listID string) error { func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { rows, err := s.db.Query( - `SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at - FROM list_items WHERE list_id = ? - ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, + `SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name, + li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at + FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != '' + WHERE li.list_id = ? + ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`, listID, ) if err != nil { @@ -3705,7 +3759,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { for rows.Next() { var it domain.ListItem var doneInt int - if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil { + if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil { it.IsCompleted = doneInt != 0 items = append(items, it) } @@ -3713,7 +3767,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { return items, nil } -func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int) (*domain.ListItem, error) { +func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) { content = strings.TrimSpace(content) if content == "" { return nil, errors.New("item content cannot be empty") @@ -3743,13 +3797,17 @@ func (s *Store) CreateListItem(listID, content, category, quantity string, sortO Quantity: strings.TrimSpace(quantity), IsCompleted: false, SortOrder: sortOrder, + CreatedBy: createdBy, CreatedAt: now, } + if createdBy != "" { + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName) + } _, err := s.db.Exec( - `INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at) - VALUES (?, ?, ?, ?, ?, 0, ?, ?)`, - it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, + `INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`, + it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy, ) if err != nil { return nil, err @@ -4173,6 +4231,9 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin // ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks. var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)") +// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list. +var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden") + // GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user. func (s *Store) GetTaskListPermission(userID, listID string) string { var ownerID, teamID string @@ -4283,6 +4344,7 @@ func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error if targetType == "team" { _, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID) } + s.PruneTaskAssignees() return err } @@ -4333,6 +4395,129 @@ func (s *Store) queryUserIDs(query string, args ...interface{}) []string { return ids } +// taskListAccessSQL selects every user ID with access to the task list given as the single parameter. +// It mirrors GetTaskListUserIDs and is correlated into PruneTaskAssignees. +const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s + UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user' + UNION SELECT tm.user_id FROM team_members tm + WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team') + OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = %[1]s AND team_id != '')` + +func (s *Store) getTaskAssignees(taskID string) []string { + ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID) + return ids +} + +// attachTaskAssignees fills Assignees for a batch of tasks with a single query. +func (s *Store) attachTaskAssignees(tasks []domain.Thought) { + if len(tasks) == 0 { + return + } + idx := make(map[string]int, len(tasks)) + placeholders := make([]string, 0, len(tasks)) + args := make([]interface{}, 0, len(tasks)) + for i := range tasks { + idx[tasks[i].ID] = i + placeholders = append(placeholders, "?") + args = append(args, tasks[i].ID) + } + rows, err := s.db.Query( + `SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`, + args..., + ) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var taskID, uid string + if rows.Scan(&taskID, &uid) == nil { + if i, ok := idx[taskID]; ok { + tasks[i].Assignees = append(tasks[i].Assignees, uid) + } + } + } +} + +// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only +// people who can see the list are accepted. +func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) { + thought, err := s.GetThoughtByID(userID, taskID) + if err != nil { + return nil, err + } + if thought.TaskListID == "" { + if len(assignees) > 0 { + return nil, ErrAssigneeNoAccess + } + return []string{}, nil + } + if !s.CanEditTaskList(userID, thought.TaskListID) { + return nil, ErrTaskListReadOnly + } + allowed := make(map[string]bool) + for _, id := range s.GetTaskListUserIDs(thought.TaskListID) { + allowed[id] = true + } + clean := make([]string, 0, len(assignees)) + seen := make(map[string]bool) + for _, id := range assignees { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + if !allowed[id] { + return nil, ErrAssigneeNoAccess + } + seen[id] = true + clean = append(clean, id) + } + + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + keep := make([]interface{}, 0, len(clean)+1) + keep = append(keep, taskID) + ph := make([]string, 0, len(clean)) + for _, id := range clean { + keep = append(keep, id) + ph = append(ph, "?") + } + delQuery := `DELETE FROM task_assignees WHERE task_id = ?` + if len(ph) > 0 { + delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)` + } + if _, err := tx.Exec(delQuery, keep...); err != nil { + return nil, err + } + now := domain.NowISO() + for _, id := range clean { + if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.getTaskAssignees(taskID), nil +} + +// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed, +// left the team, team deleted, task moved to another list or out of any list). Called after each change +// that can take access away, so assignments never outlive permissions. +func (s *Store) PruneTaskAssignees() { + _, _ = s.db.Exec( + `DELETE FROM task_assignees WHERE rowid IN ( + SELECT ta.rowid FROM task_assignees ta + LEFT JOIN thoughts t ON t.id = ta.task_id + WHERE t.id IS NULL OR t.task_list_id = '' + OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `) + )`, + ) +} + // GetTaskListUserIDs returns every user who can see the task list: owner, direct shares, members of shared teams // and members of the legacy team_id. func (s *Store) GetTaskListUserIDs(listID string) []string { @@ -4574,6 +4759,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID if err != nil { return nil, err } + if teamID != nil { + s.PruneTaskAssignees() + } return s.GetTaskListByID(userID, listID) } @@ -4581,6 +4769,7 @@ func (s *Store) DeleteTaskList(userID, listID string) error { _, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID) _, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID) _, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID) + s.PruneTaskAssignees() return err } @@ -4709,6 +4898,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error func (s *Store) DeleteTeam(teamID string) error { _, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID) + s.PruneTaskAssignees() return err } @@ -4888,6 +5078,7 @@ func (s *Store) RemoveTeamMember(teamID, userID string) error { } } _, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID) + s.PruneTaskAssignees() return err } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 33cfb1e..702425f 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -54,7 +54,7 @@ func TestStoreLifecycle(t *testing.T) { if err != nil { t.Fatalf("CreateList failed: %v", err) } - item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0) + item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0, user.ID) if err != nil { t.Fatalf("CreateListItem failed: %v", err) } diff --git a/internal/storage/task_assignees_test.go b/internal/storage/task_assignees_test.go new file mode 100644 index 0000000..d895e36 --- /dev/null +++ b/internal/storage/task_assignees_test.go @@ -0,0 +1,116 @@ +package storage + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestTaskAssigneesFollowListAccess(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "assignees.db")) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + owner, _ := store.EnsureDefaultUser() + direct, _ := store.CreateUser("direct@example.com", "hash", "Direkt", "member") + viaTeam, _ := store.CreateUser("team@example.com", "hash", "Team", "member") + stranger, _ := store.CreateUser("stranger@example.com", "hash", "Fremd", "member") + + tl, err := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "") + if err != nil { + t.Fatalf("CreateTaskList failed: %v", err) + } + task, err := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", tl.ID, "todo", "") + if err != nil { + t.Fatalf("CreateThoughtWithDetails failed: %v", err) + } + if _, err := store.UpdateThoughtTriage(owner.ID, task.ID, "keep", "task", "", "", tl.ID); err != nil { + t.Fatalf("UpdateThoughtTriage failed: %v", err) + } + + org, _ := store.CreateOrganization(owner.ID, "Familie") + team, err := store.CreateTeam(org.ID, "WG", "") + if err != nil { + t.Fatalf("CreateTeam failed: %v", err) + } + _ = store.AddTeamMember(team.ID, owner.ID, "admin") + _ = store.AddTeamMember(team.ID, viaTeam.ID, "member") + if _, err := store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID); err != nil { + t.Fatalf("AddTaskListShare user failed: %v", err) + } + if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "viewer", owner.ID); err != nil { + t.Fatalf("AddTaskListShare team failed: %v", err) + } + + if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{stranger.ID}); !errors.Is(err, ErrAssigneeNoAccess) { + t.Fatalf("assigning someone without access must fail, got %v", err) + } + if _, err := store.SetTaskAssignees(viaTeam.ID, task.ID, []string{viaTeam.ID}); !errors.Is(err, ErrTaskListReadOnly) { + t.Fatalf("viewer must not assign, got %v", err) + } + got, err := store.SetTaskAssignees(direct.ID, task.ID, []string{owner.ID, direct.ID, viaTeam.ID, direct.ID}) + if err != nil || len(got) != 3 { + t.Fatalf("expected 3 assignees, got %v (%v)", got, err) + } + + tasks, _ := store.GetTasks(owner.ID, tl.ID) + if len(tasks) != 1 || len(tasks[0].Assignees) != 3 { + t.Fatalf("GetTasks should carry assignees, got %+v", tasks) + } + + // Leaving the team removes the team member's assignment + if err := store.RemoveTeamMember(team.ID, viaTeam.ID); err != nil { + t.Fatalf("RemoveTeamMember failed: %v", err) + } + assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID) + + // Removing the direct share removes that user's assignment, the owner stays + if err := store.RemoveTaskListShare(tl.ID, direct.ID); err != nil { + t.Fatalf("RemoveTaskListShare failed: %v", err) + } + assertAssignees(t, store, owner.ID, task.ID, owner.ID) + + // Access through a second path keeps the assignment + _ = store.AddTeamMember(team.ID, direct.ID, "member") + _, _ = store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID) + if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{owner.ID, direct.ID}); err != nil { + t.Fatalf("re-assign failed: %v", err) + } + _ = store.RemoveTaskListShare(tl.ID, direct.ID) + assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID) + + // Deleting the team takes the last path away + if err := store.DeleteTeam(team.ID); err != nil { + t.Fatalf("DeleteTeam failed: %v", err) + } + assertAssignees(t, store, owner.ID, task.ID, owner.ID) + + // Moving the task out of the list drops all assignments + empty := "" + if _, err := store.UpdateThoughtDetails(owner.ID, task.ID, nil, nil, nil, nil, &empty, nil, nil, nil, nil); err != nil { + t.Fatalf("UpdateThoughtDetails failed: %v", err) + } + assertAssignees(t, store, owner.ID, task.ID) +} + +func assertAssignees(t *testing.T, store *Store, viewerID, taskID string, want ...string) { + t.Helper() + th, err := store.GetThoughtByID(viewerID, taskID) + if err != nil { + t.Fatalf("GetThoughtByID failed: %v", err) + } + if len(th.Assignees) != len(want) { + t.Fatalf("expected assignees %v, got %v", want, th.Assignees) + } + have := map[string]bool{} + for _, id := range th.Assignees { + have[id] = true + } + for _, id := range want { + if !have[id] { + t.Fatalf("expected assignees %v, got %v", want, th.Assignees) + } + } +}