feat(tasks): assign tasks in shared lists, unified people search, list avatars
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m36s

- Tasks in shared task lists can be assigned to one or more people with access
  (task_assignees); assignments are pruned whenever access is lost
- UserAutocomplete: type-to-search everywhere, nothing before typing, max 3 results,
  first match preselected
- Task list and list tabs: avatar stack of members and round monochrome counter
- Shared list items remember who added them (list_items.created_by)
- Doku/ui-patterns.md documents the shared UI patterns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 20:26:55 +01:00
1 parent 1bd38f4d50
commit 73fad1c5db
21 files changed
+586 -55

No files matched your search

+1
View File
@@ -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
+1 -1
View File
@@ -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` |
+2 -1
View File
@@ -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 |
+3 -1
View File
@@ -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.
+4 -1
View File
@@ -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
+1
View File
@@ -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)
+41
View File
@@ -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.
+1 -2
View File
@@ -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');
+13 -9
View File
@@ -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<UserAutocompleteProps> = ({
// 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<UserAutocompleteProps> = ({
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<UserAutocompleteProps> = ({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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<UserAutocompleteProps> = ({
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<UserAutocompleteProps> = ({
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 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{showResults && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{filteredUsers.length === 0 ? (
<div className="p-3 text-xs text-muted text-center">
Kein passender Benutzer gefunden
@@ -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<SharedListsViewProps> = ({ 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 (
<AvatarStack
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
size="xxs"
ringClass="ring-transparent"
title={name ? `Hinzugefügt von ${name}` : undefined}
/>
);
};
useEffect(() => {
if (!activeList?.id) return;
@@ -838,8 +861,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<span>{lst.icon}</span>
<span>{lst.title}</span>
{openCount > 0 && (
<span className="ml-1 badge-neutral">{openCount}</span>
<span className="ml-1 badge-count">{openCount}</span>
)}
<AvatarStack
people={otherMembers(lst)}
size="xxs"
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
</button>
{isSelected && lists.length > 1 && (
<button
@@ -1267,6 +1296,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
{renderItemAuthor(item)}
<button
type="button"
draggable={false}
@@ -1375,6 +1405,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
<div className="flex items-center gap-2">
{renderItemAuthor(item)}
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
)}
@@ -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<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
assignableMembers?: TaskListMember[]; // empty unless the task's list is shared
onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise<void>;
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onAddSubtask: (task: Thought, title: string) => Promise<void>;
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
@@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateTaskList,
onUpdateNotes,
onUpdateTags,
assignableMembers = [],
onUpdateAssignees,
onToggleSubtask,
onAddSubtask,
onDeleteSubtask,
@@ -525,6 +532,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div>
</div>
{/* 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 (
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<UserPlus className="w-3.5 h-3.5 text-accent" />
<span>Zugewiesen:</span>
</label>
{assigned.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{assigned.map((m) => (
<span
key={m.userId}
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
>
<UserAvatar name={m.displayName || m.email} avatarUrl={m.avatarUrl || ''} size="xxs" showTitle={false} />
<span>{m.displayName || m.email}</span>
{!readOnly && onUpdateAssignees && (
<button
type="button"
onClick={() => onUpdateAssignees(task, assignedIds.filter((id) => id !== m.userId))}
className="text-muted hover:text-status-err"
title="Zuweisung entfernen"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
) : (
readOnly && <p className="text-xs text-muted">Niemandem zugewiesen</p>
)}
{!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && (
<UserAutocomplete
users={candidates}
selectedUser={null}
onSelectUser={(u) => u && onUpdateAssignees(task, [...assignedIds, u.id])}
excludeUserIds={assignedIds}
placeholder="Person zuweisen (Name tippen)…"
/>
)}
</div>
);
})()}
{/* Hashtags Section */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
+63 -23
View File
@@ -26,23 +26,14 @@ import {
Filter,
Webhook,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { useMobileActions } from '../../context/MobileActionContext';
const getInitials = (name?: string): string => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
};
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
@@ -245,6 +236,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
const isTaskReadOnly = (task: Thought) =>
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
// assignees who lost access before the task payload caught up
const membersByList = useMemo(() => {
const m = new Map<string, TaskListMember[]>();
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
return m;
}, [safeTaskLists]);
const assignableMembers = (task: Thought): TaskListMember[] => {
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
return members.length > 1 ? members : [];
};
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
id: m.userId,
name: m.displayName || m.email,
avatarUrl: m.avatarUrl,
});
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
const members = assignableMembers(task);
return (task.assignees || [])
.map((id) => members.find((m) => m.userId === id))
.filter((m): m is TaskListMember => !!m)
.map(memberToPerson);
};
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
const people = getAssigneePeople(task);
if (people.length === 0) return null;
return (
<AvatarStack
people={people}
size={size}
ringClass="ring-[var(--bg-panel)]"
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
/>
);
};
// Active Kanban Columns (from selected list, or global custom, or default)
const activeColumns: KanbanColumn[] = useMemo(() => {
if (
@@ -473,6 +500,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
await api.updateThought(task.id, { notes: newNotes });
};
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
try {
const updated = await api.updateThought(task.id, { assignees });
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
}
};
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
await api.updateThought(task.id, { tags: newTags });
@@ -1283,6 +1321,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
</div>
{renderAssignees(task, 'xs')}
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<button
@@ -1355,7 +1395,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>{tl.icon}</span>
<span>{tl.title}</span>
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
<span className="ml-1 badge-count">{tl.tasksCount}</span>
)}
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
@@ -1363,17 +1403,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
if (otherMembers.length > 0) {
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
{m.avatarUrl ? (
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
) : (
getInitials(m.displayName || m.email)[0]
)}
</span>
))}
</div>
<AvatarStack
people={otherMembers.map(memberToPerson)}
size="xxs"
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
);
}
return (
@@ -2113,6 +2148,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
onUpdateAssignees={handleUpdateTaskAssignees}
onToggleSubtask={handleToggleSubtask}
onAddSubtask={handleAddSubtask}
onDeleteSubtask={handleDeleteSubtask}
@@ -2315,6 +2352,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
#{tag}
</button>
))}
{getAssigneePeople(task).length > 0 && (
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
)}
</div>
{/* Column Navigation controls: Move Left / Move Right */}
+16
View File
@@ -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;
+1
View File
@@ -362,6 +362,7 @@ export const api = {
notes?: string;
subtasks?: SubTask[];
tags?: string[];
assignees?: string[];
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
+4
View File
@@ -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;
}
+12 -2
View File
@@ -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
+4
View File
@@ -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,6 +427,8 @@ 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"`
@@ -442,6 +445,7 @@ type SharedList struct {
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"`
}
+2 -2
View File
@@ -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)
}
}
+201 -10
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+116
View File
@@ -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)
}
}
}