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 |
|
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
| [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 |
|
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||||
|
|
||||||
## Überblick in 60 Sekunden
|
## Ü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) |
|
| 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 | `/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 |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
| 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 |
|
| 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` …) |
|
| `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_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`) |
|
| `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`) |
|
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||||
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
| `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
|
### Gruppen
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
|||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
- `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`.
|
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
||||||
|
|
||||||
## Funktionen
|
## 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`).
|
- **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.
|
- **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.
|
- **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.
|
- 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
|
## 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
|
## 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“.
|
- **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.
|
- 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.
|
- 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).
|
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
|
||||||
|
|
||||||
## Alarme
|
## 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.
|
- **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.
|
- 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.
|
- 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 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)
|
## 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.click();
|
||||||
await search.type('user');
|
await search.type('user');
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
await search.press('ArrowDown');
|
await search.press('Enter'); // the first match is preselected
|
||||||
await search.press('Enter');
|
|
||||||
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
|
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');
|
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
|
||||||
await roleSelect.selectOption('editor');
|
await roleSelect.selectOption('editor');
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { Search, X, Check } from 'lucide-react';
|
|||||||
import { User } from '../lib/types';
|
import { User } from '../lib/types';
|
||||||
import { UserAvatar } from '../components/UserAvatar';
|
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 {
|
interface UserAutocompleteProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
selectedUser: User | null;
|
selectedUser: User | null;
|
||||||
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
// Filter users based on query and exclusions
|
// Filter users based on query and exclusions
|
||||||
const filteredUsers = React.useMemo(() => {
|
const filteredUsers = React.useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||||
if (!q) {
|
|
||||||
return available.slice(0, 15);
|
|
||||||
}
|
|
||||||
return available
|
return available
|
||||||
.filter((u) => {
|
.filter((u) => {
|
||||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||||
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||||
return nameMatch || emailMatch || userMatch;
|
return nameMatch || emailMatch || userMatch;
|
||||||
})
|
})
|
||||||
.slice(0, 15);
|
.slice(0, MAX_RESULTS);
|
||||||
}, [users, query, excludeUserIds]);
|
}, [users, query, excludeUserIds]);
|
||||||
|
const showResults = isOpen && query.trim() !== '';
|
||||||
|
|
||||||
const handleSelect = (user: User) => {
|
const handleSelect = (user: User) => {
|
||||||
onSelectUser(user);
|
onSelectUser(user);
|
||||||
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (!isOpen) {
|
if (!showResults) {
|
||||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
if (e.key === 'ArrowDown') {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setHighlightedIndex(-1);
|
// First match is preselected so Enter picks it right away
|
||||||
|
setHighlightedIndex(0);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isOpen && (
|
{showResults && (
|
||||||
<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">
|
<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 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<div className="p-3 text-xs text-muted text-center">
|
<div className="p-3 text-xs text-muted text-center">
|
||||||
Kein passender Benutzer gefunden
|
Kein passender Benutzer gefunden
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
@@ -144,10 +145,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
.catch(() => {});
|
.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(() => {
|
const activeList = useMemo(() => {
|
||||||
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||||
}, [lists, activeListId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!activeList?.id) return;
|
if (!activeList?.id) return;
|
||||||
|
|
||||||
@@ -838,8 +861,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<span>{lst.icon}</span>
|
<span>{lst.icon}</span>
|
||||||
<span>{lst.title}</span>
|
<span>{lst.title}</span>
|
||||||
{openCount > 0 && (
|
{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>
|
</button>
|
||||||
{isSelected && lists.length > 1 && (
|
{isSelected && lists.length > 1 && (
|
||||||
<button
|
<button
|
||||||
@@ -1267,6 +1296,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderItemAuthor(item)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -1375,6 +1405,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{renderItemAuthor(item)}
|
||||||
{item.completedByName && (
|
{item.completedByName && (
|
||||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
Webhook,
|
Webhook,
|
||||||
Lock,
|
Lock,
|
||||||
|
UserPlus,
|
||||||
} from 'lucide-react';
|
} 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 { triggerHaptic } from '../../lib/api';
|
||||||
import { MarkdownViewer } from '../../lib/markdown';
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
|
||||||
@@ -34,6 +37,8 @@ interface TaskDetailModalProps {
|
|||||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||||
onUpdateTags?: (task: Thought, newTags: 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>;
|
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
@@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onUpdateTaskList,
|
onUpdateTaskList,
|
||||||
onUpdateNotes,
|
onUpdateNotes,
|
||||||
onUpdateTags,
|
onUpdateTags,
|
||||||
|
assignableMembers = [],
|
||||||
|
onUpdateAssignees,
|
||||||
onToggleSubtask,
|
onToggleSubtask,
|
||||||
onAddSubtask,
|
onAddSubtask,
|
||||||
onDeleteSubtask,
|
onDeleteSubtask,
|
||||||
@@ -525,6 +532,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Hashtags Section */}
|
||||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
<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">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||||
|
|||||||
@@ -26,23 +26,14 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Webhook,
|
Webhook,
|
||||||
} from 'lucide-react';
|
} 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 { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
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 {
|
interface TasksViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
onRefreshStats: () => void;
|
onRefreshStats: () => void;
|
||||||
@@ -245,6 +236,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const isTaskReadOnly = (task: Thought) =>
|
const isTaskReadOnly = (task: Thought) =>
|
||||||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
!!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)
|
// Active Kanban Columns (from selected list, or global custom, or default)
|
||||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -473,6 +500,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
await api.updateThought(task.id, { notes: newNotes });
|
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[]) => {
|
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||||||
await api.updateThought(task.id, { tags: newTags });
|
await api.updateThought(task.id, { tags: newTags });
|
||||||
@@ -1283,6 +1321,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderAssignees(task, 'xs')}
|
||||||
|
|
||||||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
{/* 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()}>
|
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
@@ -1355,7 +1395,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<span>{tl.icon}</span>
|
<span>{tl.icon}</span>
|
||||||
<span>{tl.title}</span>
|
<span>{tl.title}</span>
|
||||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
{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);
|
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);
|
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||||
if (otherMembers.length > 0) {
|
if (otherMembers.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
<AvatarStack
|
||||||
{otherMembers.slice(0, 2).map((m) => (
|
people={otherMembers.map(memberToPerson)}
|
||||||
<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">
|
size="xxs"
|
||||||
{m.avatarUrl ? (
|
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||||
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
className="ml-0.5"
|
||||||
) : (
|
/>
|
||||||
getInitials(m.displayName || m.email)[0]
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -2113,6 +2148,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||||
onUpdateNotes={handleUpdateTaskNotes}
|
onUpdateNotes={handleUpdateTaskNotes}
|
||||||
onUpdateTags={handleUpdateTaskTags}
|
onUpdateTags={handleUpdateTaskTags}
|
||||||
|
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||||||
|
onUpdateAssignees={handleUpdateTaskAssignees}
|
||||||
onToggleSubtask={handleToggleSubtask}
|
onToggleSubtask={handleToggleSubtask}
|
||||||
onAddSubtask={handleAddSubtask}
|
onAddSubtask={handleAddSubtask}
|
||||||
onDeleteSubtask={handleDeleteSubtask}
|
onDeleteSubtask={handleDeleteSubtask}
|
||||||
@@ -2315,6 +2352,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
#{tag}
|
#{tag}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{getAssigneePeople(task).length > 0 && (
|
||||||
|
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Navigation controls: Move Left / Move Right */}
|
{/* Column Navigation controls: Move Left / Move Right */}
|
||||||
|
|||||||
@@ -293,6 +293,22 @@ body {
|
|||||||
font-weight: 500;
|
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 {
|
.badge-ok {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ export const api = {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
assignees?: string[];
|
||||||
}
|
}
|
||||||
): Promise<Thought> {
|
): Promise<Thought> {
|
||||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface Thought {
|
|||||||
kanbanStatus?: string;
|
kanbanStatus?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
|
assignees?: string[]; // user IDs, only people with access to the task list
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -349,6 +350,8 @@ export interface ListItem {
|
|||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
completedBy?: string;
|
completedBy?: string;
|
||||||
completedByName?: string;
|
completedByName?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
createdByName?: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
@@ -378,6 +381,7 @@ export interface SharedList {
|
|||||||
listType: 'shopping' | 'checklist' | 'vacation';
|
listType: 'shopping' | 'checklist' | 'vacation';
|
||||||
items: ListItem[];
|
items: ListItem[];
|
||||||
shares?: ListShare[];
|
shares?: ListShare[];
|
||||||
|
members?: TaskListMember[]; // owner + everyone the list is shared with
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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.
|
// 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) {
|
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())
|
writeError(w, http.StatusForbidden, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -914,6 +914,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||||
Tags *[]string `json:"tags"`
|
Tags *[]string `json:"tags"`
|
||||||
|
Assignees *[]string `json:"assignees"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
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)
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
return
|
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 != "" {
|
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
|
||||||
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
||||||
s.notifyDocChanged(docID, head)
|
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) {
|
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
listID := chi.URLParam(r, "id")
|
listID := chi.URLParam(r, "id")
|
||||||
var req struct {
|
var req struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -1663,7 +1673,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
+16
-12
@@ -181,6 +181,7 @@ type Thought struct {
|
|||||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Subtasks []SubTask `json:"subtasks"`
|
Subtasks []SubTask `json:"subtasks"`
|
||||||
|
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
|
||||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
@@ -426,24 +427,27 @@ type ListItem struct {
|
|||||||
IsCompleted bool `json:"isCompleted"`
|
IsCompleted bool `json:"isCompleted"`
|
||||||
CompletedBy string `json:"completedBy,omitempty"`
|
CompletedBy string `json:"completedBy,omitempty"`
|
||||||
CompletedByName string `json:"completedByName,omitempty"`
|
CompletedByName string `json:"completedByName,omitempty"`
|
||||||
|
CreatedBy string `json:"createdBy,omitempty"`
|
||||||
|
CreatedByName string `json:"createdByName,omitempty"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
CompletedAt string `json:"completedAt,omitempty"`
|
CompletedAt string `json:"completedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedList struct {
|
type SharedList struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
OwnerName string `json:"ownerName,omitempty"`
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
IsOwner bool `json:"isOwner"`
|
IsOwner bool `json:"isOwner"`
|
||||||
Role string `json:"role,omitempty"` // editor | viewer
|
Role string `json:"role,omitempty"` // editor | viewer
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||||
Items []ListItem `json:"items"`
|
Items []ListItem `json:"items"`
|
||||||
Shares []ListShare `json:"shares,omitempty"`
|
Shares []ListShare `json:"shares,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
Members []TaskListMember `json:"members,omitempty"` // owner + everyone the list is shared with
|
||||||
CreatedAt string `json:"createdAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
|||||||
{"Spülmaschinentabs", "Drogerie", "1 Pack"},
|
{"Spülmaschinentabs", "Drogerie", "1 Pack"},
|
||||||
}
|
}
|
||||||
for i, it := range items {
|
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) {
|
if err == nil && (i == 1 || i == 6 || i == 8) {
|
||||||
_, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName)
|
_, _ = 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"},
|
{"Erste-Hilfe-Set", "Ausrüstung"}, {"Stirnlampe", "Ausrüstung"}, {"Trinkflaschen", "Ausrüstung"},
|
||||||
{"Personalausweise", "Dokumente"}, {"Hüttenschlafsack", "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 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_tag ON block_tags(tag);",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
|
"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 {
|
for _, q := range columnMigrations {
|
||||||
_, _ = s.db.Exec(q)
|
_, _ = s.db.Exec(q)
|
||||||
@@ -812,6 +819,7 @@ func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
|||||||
|
|
||||||
func (s *Store) DeleteUser(userID 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 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)
|
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -956,6 +964,7 @@ func scanThought(rows interface {
|
|||||||
t.DocID = parts[0]
|
t.DocID = parts[0]
|
||||||
t.BlockID = parts[1]
|
t.BlockID = parts[1]
|
||||||
}
|
}
|
||||||
|
t.Assignees = []string{}
|
||||||
t.Subtasks = []domain.SubTask{}
|
t.Subtasks = []domain.SubTask{}
|
||||||
if subtasksStr != "" && subtasksStr != "null" {
|
if subtasksStr != "" && subtasksStr != "null" {
|
||||||
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
_ = 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()+`))`,
|
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||||
thoughtID, userID, userID, userID, userID, userID,
|
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) {
|
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()
|
_ = rows.Close()
|
||||||
|
s.attachTaskAssignees(result)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,7 +1205,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
} else if convertTo == "list_item" && targetListID != "" {
|
} else if convertTo == "list_item" && targetListID != "" {
|
||||||
newStatus = domain.StatusConverted
|
newStatus = domain.StatusConverted
|
||||||
convertedType = "list_item"
|
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 {
|
if err == nil && item != nil {
|
||||||
convertedRefID = item.ID
|
convertedRefID = item.ID
|
||||||
}
|
}
|
||||||
@@ -1208,6 +1223,9 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if taskListID != thought.TaskListID {
|
||||||
|
s.PruneTaskAssignees()
|
||||||
|
}
|
||||||
|
|
||||||
return s.GetThoughtByID(userID, thoughtID)
|
return s.GetThoughtByID(userID, thoughtID)
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1303,10 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if taskListID != nil {
|
||||||
|
s.PruneTaskAssignees()
|
||||||
|
thought.Assignees = s.getTaskAssignees(thoughtID)
|
||||||
|
}
|
||||||
return thought, nil
|
return thought, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3625,6 +3647,7 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
|||||||
for i := range lists {
|
for i := range lists {
|
||||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||||
lists[i].Shares, _ = s.GetListShares(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
|
return lists, nil
|
||||||
}
|
}
|
||||||
@@ -3673,9 +3696,38 @@ func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
|
|||||||
l.Role = effectiveRole
|
l.Role = effectiveRole
|
||||||
l.Items, _ = s.GetListItems(l.ID)
|
l.Items, _ = s.GetListItems(l.ID)
|
||||||
l.Shares, _ = s.GetListShares(l.ID)
|
l.Shares, _ = s.GetListShares(l.ID)
|
||||||
|
l.Members = s.getListMembers(l.ID, l.UserID)
|
||||||
return &l, nil
|
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 {
|
func (s *Store) DeleteList(userID, listID string) error {
|
||||||
var ownerID string
|
var ownerID string
|
||||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
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) {
|
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||||
rows, err := s.db.Query(
|
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
|
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||||
FROM list_items WHERE list_id = ?
|
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
|
||||||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
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,
|
listID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3705,7 +3759,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it domain.ListItem
|
var it domain.ListItem
|
||||||
var doneInt int
|
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
|
it.IsCompleted = doneInt != 0
|
||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
}
|
}
|
||||||
@@ -3713,7 +3767,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
|||||||
return items, nil
|
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)
|
content = strings.TrimSpace(content)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return nil, errors.New("item content cannot be empty")
|
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),
|
Quantity: strings.TrimSpace(quantity),
|
||||||
IsCompleted: false,
|
IsCompleted: false,
|
||||||
SortOrder: sortOrder,
|
SortOrder: sortOrder,
|
||||||
|
CreatedBy: createdBy,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
|
if createdBy != "" {
|
||||||
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName)
|
||||||
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at)
|
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||||
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt,
|
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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)")
|
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.
|
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
|
||||||
func (s *Store) GetTaskListPermission(userID, listID string) string {
|
func (s *Store) GetTaskListPermission(userID, listID string) string {
|
||||||
var ownerID, teamID string
|
var ownerID, teamID string
|
||||||
@@ -4283,6 +4344,7 @@ func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error
|
|||||||
if targetType == "team" {
|
if targetType == "team" {
|
||||||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
|
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
|
||||||
}
|
}
|
||||||
|
s.PruneTaskAssignees()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4333,6 +4395,129 @@ func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
|
|||||||
return ids
|
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
|
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares, members of shared teams
|
||||||
// and members of the legacy team_id.
|
// and members of the legacy team_id.
|
||||||
func (s *Store) GetTaskListUserIDs(listID string) []string {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if teamID != nil {
|
||||||
|
s.PruneTaskAssignees()
|
||||||
|
}
|
||||||
return s.GetTaskListByID(userID, listID)
|
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(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID)
|
||||||
_, _ = s.db.Exec(`DELETE FROM task_list_shares 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)
|
_, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID)
|
||||||
|
s.PruneTaskAssignees()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4709,6 +4898,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
|
|||||||
|
|
||||||
func (s *Store) DeleteTeam(teamID string) error {
|
func (s *Store) DeleteTeam(teamID string) error {
|
||||||
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
||||||
|
s.PruneTaskAssignees()
|
||||||
return err
|
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)
|
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
|
||||||
|
s.PruneTaskAssignees()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateList failed: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateListItem failed: %v", err)
|
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