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
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:
1 parent
1bd38f4d50
commit
73fad1c5db
21 files changed
+598
-67
No files matched your search
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -362,6 +362,7 @@ export const api = {
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
assignees?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+16
-12
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user