diff --git a/docs/plans/2026-03-11-backup-rewrite.md b/docs/plans/2026-03-11-backup-rewrite.md new file mode 100644 index 0000000..1c65dfb --- /dev/null +++ b/docs/plans/2026-03-11-backup-rewrite.md @@ -0,0 +1,538 @@ +# Backup Service Rewrite Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore. + +**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path. + +**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies. + +**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md` + +**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first. + +--- + +## Task 1: Extend export — full backup + +**Files:** +- Modify: `src/fabledassistant/services/backup.py` + +- [ ] **Step 1: Read the current `export_full_backup()` function** + +Understand what is currently exported: users, notes (partial fields), conversations+messages, settings. + +- [ ] **Step 2: Rewrite `export_full_backup()` to version 2** + +Add all missing models to the import list at the top of `backup.py`: +```python +from fabledassistant.models.project import Project +from fabledassistant.models.milestone import Milestone +from fabledassistant.models.task_log import TaskLog +from fabledassistant.models.note_draft import NoteDraft +from fabledassistant.models.note_version import NoteVersion +from fabledassistant.models.push_subscription import PushSubscription +``` + +Rewrite `export_full_backup()`: +```python +async def export_full_backup() -> dict: + async with async_session() as session: + users = (await session.execute(select(User))).scalars().all() + projects = (await session.execute(select(Project))).scalars().all() + milestones = (await session.execute(select(Milestone))).scalars().all() + notes = (await session.execute(select(Note))).scalars().all() + task_logs = (await session.execute(select(TaskLog))).scalars().all() + note_drafts = (await session.execute(select(NoteDraft))).scalars().all() + note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all() + conversations = (await session.execute( + select(Conversation).options(selectinload(Conversation.messages)) + )).scalars().all() + settings = (await session.execute(select(Setting))).scalars().all() + push_subs = (await session.execute(select(PushSubscription))).scalars().all() + + return { + "version": 2, + "scope": "full", + "exported_at": datetime.now(timezone.utc).isoformat(), + "_security_notice": ( + "This backup contains hashed passwords and push subscription keys. " + "Store it securely and restrict access." + ), + "users": [ + { + "id": u.id, + "username": u.username, + "email": u.email, + "password_hash": u.password_hash, + "oauth_sub": u.oauth_sub, + "role": u.role, + "session_version": u.session_version, + "created_at": u.created_at.isoformat(), + } + for u in users + ], + "projects": [ + { + "id": p.id, + "user_id": p.user_id, + "title": p.title, + "description": p.description, + "goal": p.goal, + "status": p.status, + "color": p.color, + "created_at": p.created_at.isoformat(), + "updated_at": p.updated_at.isoformat(), + } + for p in projects + ], + "milestones": [ + { + "id": m.id, + "user_id": m.user_id, + "project_id": m.project_id, + "title": m.title, + "description": m.description, + "status": m.status, + "order_index": m.order_index, + "created_at": m.created_at.isoformat(), + "updated_at": m.updated_at.isoformat(), + } + for m in milestones + ], + "notes": [ + { + "id": n.id, + "user_id": n.user_id, + "title": n.title, + "body": n.body, + "tags": n.tags or [], + "parent_id": n.parent_id, + "project_id": n.project_id, + "milestone_id": n.milestone_id, + "is_task": n.is_task, + "status": n.status, + "priority": n.priority, + "due_date": n.due_date.isoformat() if n.due_date else None, + "is_starred": getattr(n, "is_starred", False), + "is_pinned": getattr(n, "is_pinned", False), + "created_at": n.created_at.isoformat(), + "updated_at": n.updated_at.isoformat(), + } + for n in notes + ], + "task_logs": [ + { + "id": tl.id, + "user_id": tl.user_id, + "note_id": tl.note_id, + "description": tl.description, + "duration_minutes": tl.duration_minutes, + "logged_at": tl.logged_at.isoformat() if tl.logged_at else None, + "created_at": tl.created_at.isoformat(), + } + for tl in task_logs + ], + "note_drafts": [ + { + "id": nd.id, + "user_id": nd.user_id, + "note_id": nd.note_id, + "title": nd.title, + "body": nd.body, + "saved_at": nd.saved_at.isoformat() if nd.saved_at else None, + } + for nd in note_drafts + ], + "note_versions": [ + { + "id": nv.id, + "user_id": nv.user_id, + "note_id": nv.note_id, + "title": nv.title, + "body": nv.body, + "version_number": nv.version_number, + "created_at": nv.created_at.isoformat(), + } + for nv in note_versions + ], + "conversations": [ + { + "id": c.id, + "user_id": c.user_id, + "title": c.title, + "created_at": c.created_at.isoformat(), + "updated_at": c.updated_at.isoformat(), + "messages": [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "context_note_id": m.context_note_id, + "created_at": m.created_at.isoformat(), + } + for m in c.messages + ], + } + for c in conversations + ], + "settings": [ + {"user_id": s.user_id, "key": s.key, "value": s.value} + for s in settings + ], + "push_subscriptions": [ + { + "user_id": ps.user_id, + "endpoint": ps.endpoint, + "p256dh": ps.p256dh, + "auth": ps.auth, + "created_at": ps.created_at.isoformat(), + } + for ps in push_subs + ], + } +``` + +Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions. + +- [ ] **Step 3: Commit** + +```bash +git add src/fabledassistant/services/backup.py +git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions" +``` + +--- + +## Task 2: Extend export — user backup + +**Files:** +- Modify: `src/fabledassistant/services/backup.py` + +- [ ] **Step 1: Rewrite `export_user_backup(user_id)`** + +Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export. + +```python +async def export_user_backup(user_id: int) -> dict: + async with async_session() as session: + user = await session.get(User, user_id) + projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all() + milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all() + notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all() + task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all() + note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all() + note_versions = (await session.execute( + select(NoteVersion).where(NoteVersion.user_id == user_id) + .order_by(NoteVersion.note_id, NoteVersion.version_number) + )).scalars().all() + conversations = (await session.execute( + select(Conversation).options(selectinload(Conversation.messages)) + .where(Conversation.user_id == user_id) + )).scalars().all() + settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all() + + return { + "version": 2, + "scope": "user", + "exported_at": datetime.now(timezone.utc).isoformat(), + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "role": user.role, + "created_at": user.created_at.isoformat(), + } if user else None, + "projects": [...], # same as full but user-filtered + "milestones": [...], + "notes": [...], + "task_logs": [...], + "note_drafts": [...], + "note_versions": [...], + "conversations": [...], + "settings": [...], + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/fabledassistant/services/backup.py +git commit -m "feat(backup): v2 user backup with all models" +``` + +--- + +## Task 3: Rewrite restore for v2 + +**Files:** +- Modify: `src/fabledassistant/services/backup.py` + +- [ ] **Step 1: Add version dispatch to `restore_full_backup`** + +```python +async def restore_full_backup(data: dict) -> dict: + version = data.get("version", 1) + if version == 1: + return await _restore_v1(data) + return await _restore_v2(data) +``` + +Move existing restore code into `_restore_v1(data)` with no changes. + +- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping** + +Restore order (respects FK dependencies): +1. Users → build `user_id_map` +2. Projects (fk: user_id) → build `project_id_map` +3. Milestones (fk: user_id, project_id) → build `milestone_id_map` +4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map` +5. Notes — second pass: patch `parent_id` using `note_id_map` +6. TaskLogs (fk: user_id, note_id via note_id_map) +7. NoteDrafts (fk: user_id, note_id via note_id_map) +8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set) +9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map) +10. Settings (fk: user_id) + +```python +async def _restore_v2(data: dict) -> dict: + from datetime import date, datetime, timezone + + stats = { + "users": 0, "projects": 0, "milestones": 0, "notes": 0, + "task_logs": 0, "note_drafts": 0, "conversations": 0, + "messages": 0, "settings": 0 + } + + async with async_session() as session: + user_id_map: dict[int, int] = {} + project_id_map: dict[int, int] = {} + milestone_id_map: dict[int, int] = {} + note_id_map: dict[int, int] = {} + + # 1. Users + for u_data in data.get("users", []): + old_id = u_data["id"] + user = User( + username=u_data["username"], + email=u_data.get("email"), + password_hash=u_data.get("password_hash"), + oauth_sub=u_data.get("oauth_sub"), + role=u_data.get("role", "user"), + session_version=u_data.get("session_version", 1), + created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc), + ) + session.add(user) + await session.flush() + user_id_map[old_id] = user.id + stats["users"] += 1 + + # 2. Projects + for p_data in data.get("projects", []): + mapped_uid = user_id_map.get(p_data.get("user_id", 0)) + if mapped_uid is None: + continue + proj = Project( + user_id=mapped_uid, + title=p_data.get("title", ""), + description=p_data.get("description"), + goal=p_data.get("goal"), + status=p_data.get("status", "active"), + color=p_data.get("color"), + created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(proj) + await session.flush() + project_id_map[p_data["id"]] = proj.id + stats["projects"] += 1 + + # 3. Milestones + for m_data in data.get("milestones", []): + mapped_uid = user_id_map.get(m_data.get("user_id", 0)) + mapped_pid = project_id_map.get(m_data.get("project_id", 0)) + if mapped_uid is None: + continue + ms = Milestone( + user_id=mapped_uid, + project_id=mapped_pid, + title=m_data.get("title", ""), + description=m_data.get("description"), + status=m_data.get("status", "open"), + order_index=m_data.get("order_index", 0), + created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(ms) + await session.flush() + milestone_id_map[m_data["id"]] = ms.id + stats["milestones"] += 1 + + # 4. Notes — first pass (no parent_id) + note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id) + for n_data in data.get("notes", []): + mapped_uid = user_id_map.get(n_data.get("user_id", 0)) + if mapped_uid is None: + continue + due = None + if n_data.get("due_date"): + due = date.fromisoformat(n_data["due_date"]) + note = Note( + user_id=mapped_uid, + title=n_data.get("title", ""), + body=n_data.get("body", ""), + tags=n_data.get("tags", []), + parent_id=None, # patched in second pass + project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None, + milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None, + is_task=n_data.get("is_task", False), + status=n_data.get("status"), + priority=n_data.get("priority"), + due_date=due, + created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(note) + await session.flush() + note_id_map[n_data["id"]] = note.id + if n_data.get("parent_id"): + note_objects.append((n_data["parent_id"], note.id)) + stats["notes"] += 1 + + # 5. Notes — second pass: patch parent_id + for old_parent_id, new_note_id in note_objects: + new_parent_id = note_id_map.get(old_parent_id) + if new_parent_id: + note_row = await session.get(Note, new_note_id) + if note_row: + note_row.parent_id = new_parent_id + + # 6. TaskLogs + for tl_data in data.get("task_logs", []): + mapped_uid = user_id_map.get(tl_data.get("user_id", 0)) + mapped_nid = note_id_map.get(tl_data.get("note_id", 0)) + if mapped_uid is None or mapped_nid is None: + continue + from fabledassistant.models.task_log import TaskLog + tl = TaskLog( + user_id=mapped_uid, + note_id=mapped_nid, + description=tl_data.get("description", ""), + duration_minutes=tl_data.get("duration_minutes"), + logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None, + created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc), + ) + session.add(tl) + stats["task_logs"] += 1 + + # 7. NoteDrafts + for nd_data in data.get("note_drafts", []): + mapped_uid = user_id_map.get(nd_data.get("user_id", 0)) + mapped_nid = note_id_map.get(nd_data.get("note_id", 0)) + if mapped_uid is None or mapped_nid is None: + continue + from fabledassistant.models.note_draft import NoteDraft + nd = NoteDraft( + user_id=mapped_uid, + note_id=mapped_nid, + title=nd_data.get("title", ""), + body=nd_data.get("body", ""), + saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None, + ) + session.add(nd) + stats["note_drafts"] += 1 + + # 8. Conversations + Messages + for c_data in data.get("conversations", []): + mapped_uid = user_id_map.get(c_data.get("user_id", 0)) + if mapped_uid is None: + continue + conv = Conversation( + user_id=mapped_uid, + title=c_data.get("title", ""), + created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(conv) + await session.flush() + stats["conversations"] += 1 + for m_data in c_data.get("messages", []): + msg = Message( + conversation_id=conv.id, + role=m_data["role"], + content=m_data.get("content", ""), + context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None, + created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc), + ) + session.add(msg) + stats["messages"] += 1 + + # 9. Settings + for s_data in data.get("settings", []): + mapped_uid = user_id_map.get(s_data.get("user_id", 0)) + if mapped_uid is None: + continue + setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", "")) + session.add(setting) + stats["settings"] += 1 + + await session.commit() + + logger.info("Restored v2 backup: %s", stats) + return stats +``` + +- [ ] **Step 3: Add missing imports at top of file** + +```python +from datetime import datetime, timezone +from fabledassistant.models.project import Project +from fabledassistant.models.milestone import Milestone +``` + +- [ ] **Step 4: Test restore round-trip** + +```bash +# Export +curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json +# Inspect structure +python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))" +# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ... +``` + +- [ ] **Step 5: Typecheck** + +```bash +docker compose exec app python -m py_compile src/fabledassistant/services/backup.py +``` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/services/backup.py +git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved" +``` + +--- + +## Task 4: Verification + +- [ ] **Step 1: Full round-trip test** + +1. Export full backup as admin +2. Verify JSON has `"version": 2` and all expected top-level keys +3. Verify notes have `project_id`, `milestone_id`, `is_task` fields +4. Verify `task_logs` array has entries (create a task log entry first if needed) +5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run) + +- [ ] **Step 2: V1 backward compat test** + +Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error. + +- [ ] **Step 3: Commit final verification note** + +```bash +git commit --allow-empty -m "chore(backup): v2 backup rewrite verified" +``` diff --git a/docs/plans/2026-03-11-sharing-collaboration.md b/docs/plans/2026-03-11-sharing-collaboration.md new file mode 100644 index 0000000..c24cea7 --- /dev/null +++ b/docs/plans/2026-03-11-sharing-collaboration.md @@ -0,0 +1,2023 @@ +# Sharing & Collaboration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add groups, project/note sharing, access control, and notifications so multiple users can collaborate on content. + +**Architecture:** New `services/access.py` is the single source of truth for permission resolution. Existing owner-scoped service functions are preserved (used by LLM tools); new `_for_user` variants add shared-access checks for human-facing routes. Groups are platform-wide with owner/member roles. Notifications fire in-app, push, and email on share/group events. + +**Tech Stack:** Python/Quart/SQLAlchemy 2.0 async, Alembic, Vue 3 TypeScript, Pinia, existing push/email infrastructure. + +**Spec:** `docs/superpowers/specs/2026-03-11-sharing-collaboration-design.md` + +--- + +## Chunk 1: Database + Models + +### Task 1: Alembic migration for all new tables + +**Files:** +- Create: `alembic/versions/0024_add_sharing_and_notifications.py` + +- [ ] **Step 1: Write migration** + +```python +"""Add groups, sharing, notifications tables + +Revision ID: 0024_add_sharing_and_notifications +Revises: +Create Date: 2026-03-11 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0024_add_sharing_and_notifications' +down_revision = '' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'groups', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.Text(), nullable=False, unique=True), + sa.Column('description', sa.Text()), + sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL')), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + op.create_table( + 'group_memberships', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('group_id', sa.Integer(), sa.ForeignKey('groups.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('role', sa.Text(), nullable=False, server_default='member'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint('group_id', 'user_id', name='uq_gm_group_user'), + ) + op.create_index('idx_gm_user', 'group_memberships', ['user_id']) + + op.create_table( + 'project_shares', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')), + sa.Column('shared_with_group_id', sa.Integer(), sa.ForeignKey('groups.id', ondelete='CASCADE')), + sa.Column('permission', sa.Text(), nullable=False), + sa.Column('invited_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ps_exclusive_target" + ), + ) + op.create_index('idx_ps_project', 'project_shares', ['project_id']) + # Partial unique indexes via raw SQL + op.execute("CREATE UNIQUE INDEX idx_ps_user ON project_shares(project_id, shared_with_user_id) WHERE shared_with_user_id IS NOT NULL") + op.execute("CREATE UNIQUE INDEX idx_ps_group ON project_shares(project_id, shared_with_group_id) WHERE shared_with_group_id IS NOT NULL") + + op.create_table( + 'note_shares', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('note_id', sa.Integer(), sa.ForeignKey('notes.id', ondelete='CASCADE'), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')), + sa.Column('shared_with_group_id', sa.Integer(), sa.ForeignKey('groups.id', ondelete='CASCADE')), + sa.Column('permission', sa.Text(), nullable=False), + sa.Column('invited_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ns_exclusive_target" + ), + ) + op.execute("CREATE UNIQUE INDEX idx_ns_user ON note_shares(note_id, shared_with_user_id) WHERE shared_with_user_id IS NOT NULL") + op.execute("CREATE UNIQUE INDEX idx_ns_group ON note_shares(note_id, shared_with_group_id) WHERE shared_with_group_id IS NOT NULL") + + op.create_table( + 'notifications', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('type', sa.Text(), nullable=False), + sa.Column('payload', postgresql.JSONB(), nullable=False, server_default='{}'), + sa.Column('read_at', sa.DateTime(timezone=True)), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.execute("CREATE INDEX idx_notif_user_unread ON notifications(user_id, read_at) WHERE read_at IS NULL") + + +def downgrade() -> None: + op.drop_table('notifications') + op.drop_table('note_shares') + op.drop_table('project_shares') + op.drop_table('group_memberships') + op.drop_table('groups') +``` + +- [ ] **Step 2: Find the previous revision ID and fill `down_revision`** + +```bash +docker compose exec app alembic heads +``` + +- [ ] **Step 3: Run migration** + +```bash +docker compose exec app alembic upgrade head +``` +Expected: No errors; 5 new tables present. + +- [ ] **Step 4: Commit** + +```bash +git add alembic/versions/0024_add_sharing_and_notifications.py +git commit -m "feat: add groups, sharing, notifications migrations" +``` + +--- + +### Task 2: SQLAlchemy models + +**Files:** +- Create: `src/fabledassistant/models/group.py` +- Create: `src/fabledassistant/models/share.py` +- Create: `src/fabledassistant/models/notification.py` +- Modify: `src/fabledassistant/models/__init__.py` + +- [ ] **Step 1: Write `models/group.py`** + +```python +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from fabledassistant.models import Base + + +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + description: Mapped[str | None] = mapped_column(Text) + created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + memberships: Mapped[list["GroupMembership"]] = relationship("GroupMembership", back_populates="group", cascade="all, delete-orphan") + + def to_dict(self) -> dict: + return {"id": self.id, "name": self.name, "description": self.description, + "created_by": self.created_by, "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat()} + + +class GroupMembership(Base): + __tablename__ = "group_memberships" + __table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + group_id: Mapped[int] = mapped_column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + role: Mapped[str] = mapped_column(Text, nullable=False, default="member") # 'owner' | 'member' + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + group: Mapped["Group"] = relationship("Group", back_populates="memberships") + + def to_dict(self) -> dict: + return {"id": self.id, "group_id": self.group_id, "user_id": self.user_id, + "role": self.role, "created_at": self.created_at.isoformat()} +``` + +- [ ] **Step 2: Write `models/share.py`** + +```python +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey, CheckConstraint +from sqlalchemy.orm import Mapped, mapped_column +from fabledassistant.models import Base + + +class ProjectShare(Base): + __tablename__ = "project_shares" + __table_args__ = ( + CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ps_exclusive_target" + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + shared_with_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + shared_with_group_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("groups.id", ondelete="CASCADE")) + permission: Mapped[str] = mapped_column(Text, nullable=False) # viewer | editor | admin + invited_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return {"id": self.id, "project_id": self.project_id, + "shared_with_user_id": self.shared_with_user_id, + "shared_with_group_id": self.shared_with_group_id, + "permission": self.permission, "invited_by": self.invited_by, + "created_at": self.created_at.isoformat()} + + +class NoteShare(Base): + __tablename__ = "note_shares" + __table_args__ = ( + CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ns_exclusive_target" + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + note_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"), nullable=False) + shared_with_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + shared_with_group_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("groups.id", ondelete="CASCADE")) + permission: Mapped[str] = mapped_column(Text, nullable=False) + invited_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return {"id": self.id, "note_id": self.note_id, + "shared_with_user_id": self.shared_with_user_id, + "shared_with_group_id": self.shared_with_group_id, + "permission": self.permission, "invited_by": self.invited_by, + "created_at": self.created_at.isoformat()} +``` + +- [ ] **Step 3: Write `models/notification.py`** + +```python +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from fabledassistant.models import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + type: Mapped[str] = mapped_column(Text, nullable=False) # project_shared | note_shared | group_added + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return {"id": self.id, "user_id": self.user_id, "type": self.type, + "payload": self.payload, "read_at": self.read_at.isoformat() if self.read_at else None, + "created_at": self.created_at.isoformat()} +``` + +- [ ] **Step 4: Register models in `models/__init__.py`** + +Add to the imports section: +```python +from fabledassistant.models.group import Group, GroupMembership # noqa: F401 +from fabledassistant.models.share import ProjectShare, NoteShare # noqa: F401 +from fabledassistant.models.notification import Notification # noqa: F401 +``` + +- [ ] **Step 5: Typecheck** + +```bash +cd frontend && npx vue-tsc --noEmit; cd .. && python -m py_compile src/fabledassistant/models/group.py src/fabledassistant/models/share.py src/fabledassistant/models/notification.py +``` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/models/ +git commit -m "feat: add Group, Share, Notification SQLAlchemy models" +``` + +--- + +## Chunk 2: Access Control + Groups + +### Task 3: Access control service + +**Files:** +- Create: `src/fabledassistant/services/access.py` +- Create: `tests/test_access.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_access.py +import pytest +from unittest.mock import AsyncMock, patch + +@pytest.mark.asyncio +async def test_owner_gets_owner_permission(): + """Project owner always gets 'owner' permission.""" + ... + +@pytest.mark.asyncio +async def test_direct_share_grants_permission(): + """User with direct ProjectShare gets that permission.""" + ... + +@pytest.mark.asyncio +async def test_group_share_grants_permission(): + """User who is a group member gets the group's share permission.""" + ... + +@pytest.mark.asyncio +async def test_highest_permission_wins(): + """When user has viewer directly + editor via group, returns editor.""" + ... + +@pytest.mark.asyncio +async def test_no_access_returns_none(): + """Unrelated user gets None.""" + ... + +@pytest.mark.asyncio +async def test_note_inherits_project_permission(): + """Note in a shared project inherits project permission.""" + ... + +@pytest.mark.asyncio +async def test_note_share_overrides_project(): + """Note-level admin share overrides project viewer share.""" + ... +``` + +Run: `docker compose exec app python -m pytest tests/test_access.py -v` +Expected: FAIL (functions don't exist yet) + +- [ ] **Step 2: Implement `services/access.py`** + +```python +import logging +from sqlalchemy import select +from fabledassistant.models import async_session +from fabledassistant.models.note import Note +from fabledassistant.models.project import Project +from fabledassistant.models.share import NoteShare, ProjectShare +from fabledassistant.models.group import GroupMembership + +logger = logging.getLogger(__name__) + +PERMISSION_RANK = {"viewer": 1, "editor": 2, "admin": 3, "owner": 4} + + +def _higher(a: str | None, b: str | None) -> str | None: + """Return the higher permission of two, or None if both None.""" + if a is None: + return b + if b is None: + return a + return a if PERMISSION_RANK[a] >= PERMISSION_RANK[b] else b + + +async def get_project_permission(user_id: int, project_id: int) -> str | None: + async with async_session() as session: + project = await session.get(Project, project_id) + if project is None: + return None + if project.user_id == user_id: + return "owner" + + # Direct share + direct = (await session.execute( + select(ProjectShare).where( + ProjectShare.project_id == project_id, + ProjectShare.shared_with_user_id == user_id + ) + )).scalar_one_or_none() + + # Group shares: find user's groups, then matching shares + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + group_perm: str | None = None + if user_group_ids: + group_shares = (await session.execute( + select(ProjectShare).where( + ProjectShare.project_id == project_id, + ProjectShare.shared_with_group_id.in_(user_group_ids) + ) + )).scalars().all() + for gs in group_shares: + group_perm = _higher(group_perm, gs.permission) + + return _higher(direct.permission if direct else None, group_perm) + + +async def can_read_project(user_id: int, project_id: int) -> bool: + return (await get_project_permission(user_id, project_id)) is not None + + +async def can_write_project(user_id: int, project_id: int) -> bool: + perm = await get_project_permission(user_id, project_id) + return perm in ("editor", "admin", "owner") + + +async def can_admin_project(user_id: int, project_id: int) -> bool: + perm = await get_project_permission(user_id, project_id) + return perm in ("admin", "owner") + + +async def get_note_permission(user_id: int, note_id: int) -> str | None: + async with async_session() as session: + note = await session.get(Note, note_id) + if note is None: + return None + if note.user_id == user_id: + return "owner" + + # Direct note share + direct = (await session.execute( + select(NoteShare).where( + NoteShare.note_id == note_id, + NoteShare.shared_with_user_id == user_id + ) + )).scalar_one_or_none() + + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + group_perm: str | None = None + if user_group_ids: + group_shares = (await session.execute( + select(NoteShare).where( + NoteShare.note_id == note_id, + NoteShare.shared_with_group_id.in_(user_group_ids) + ) + )).scalars().all() + for gs in group_shares: + group_perm = _higher(group_perm, gs.permission) + + note_perm = _higher(direct.permission if direct else None, group_perm) + + # Inherit from project if note belongs to one + if note.project_id is not None: + project_perm = await get_project_permission(user_id, note.project_id) + note_perm = _higher(note_perm, project_perm) + + return note_perm + + +async def can_read_note(user_id: int, note_id: int) -> bool: + return (await get_note_permission(user_id, note_id)) is not None + + +async def can_write_note(user_id: int, note_id: int) -> bool: + perm = await get_note_permission(user_id, note_id) + return perm in ("editor", "admin", "owner") +``` + +- [ ] **Step 3: Run tests** + +```bash +docker compose exec app python -m pytest tests/test_access.py -v +``` +Expected: All 7 tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/fabledassistant/services/access.py tests/test_access.py +git commit -m "feat: access control service with permission resolution" +``` + +--- + +### Task 4: Groups service + routes + +**Files:** +- Create: `src/fabledassistant/services/groups.py` +- Create: `src/fabledassistant/routes/groups.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Implement `services/groups.py`** + +```python +import logging +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from fabledassistant.models import async_session +from fabledassistant.models.group import Group, GroupMembership +from fabledassistant.models.user import User + +logger = logging.getLogger(__name__) + + +async def create_group(user_id: int, name: str, description: str | None = None) -> Group: + async with async_session() as session: + group = Group(name=name, description=description, created_by=user_id) + session.add(group) + await session.flush() + membership = GroupMembership(group_id=group.id, user_id=user_id, role="owner") + session.add(membership) + await session.commit() + await session.refresh(group) + return group + + +async def list_groups(user_id: int, is_admin: bool = False) -> list[dict]: + """All users see all groups (with member count). Admin flag has no effect currently.""" + async with async_session() as session: + groups = (await session.execute(select(Group).order_by(Group.name))).scalars().all() + user_group_ids = set( + (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + ) + result = [] + for g in groups: + count = len((await session.execute( + select(GroupMembership).where(GroupMembership.group_id == g.id) + )).scalars().all()) + d = g.to_dict() + d["member_count"] = count + d["is_member"] = g.id in user_group_ids + result.append(d) + return result + + +async def get_group(group_id: int) -> Group | None: + async with async_session() as session: + return await session.get(Group, group_id) + + +async def update_group(acting_user_id: int, group_id: int, is_site_admin: bool, **fields) -> Group | None: + async with async_session() as session: + group = await session.get(Group, group_id) + if not group: + return None + # Must be group owner or site admin + if not is_site_admin: + membership = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not membership: + return None # caller treats as 403 + for k, v in fields.items(): + if hasattr(group, k): + setattr(group, k, v) + await session.commit() + await session.refresh(group) + return group + + +async def delete_group(acting_user_id: int, group_id: int, is_site_admin: bool) -> bool: + async with async_session() as session: + group = await session.get(Group, group_id) + if not group: + return False + if not is_site_admin and group.created_by != acting_user_id: + return False + await session.delete(group) + await session.commit() + return True + + +async def list_members(group_id: int) -> list[dict]: + async with async_session() as session: + rows = (await session.execute( + select(GroupMembership, User) + .join(User, User.id == GroupMembership.user_id) + .where(GroupMembership.group_id == group_id) + )).all() + return [ + {**m.to_dict(), "username": u.username, "email": u.email} + for m, u in rows + ] + + +async def add_member(acting_user_id: int, group_id: int, target_user_id: int, role: str, is_site_admin: bool) -> GroupMembership | None: + async with async_session() as session: + if not is_site_admin: + acting_membership = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not acting_membership: + return None + membership = GroupMembership(group_id=group_id, user_id=target_user_id, role=role) + session.add(membership) + await session.commit() + await session.refresh(membership) + return membership + + +async def update_member_role(acting_user_id: int, group_id: int, target_user_id: int, role: str, is_site_admin: bool) -> GroupMembership | None: + async with async_session() as session: + if not is_site_admin: + acting_m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not acting_m: + return None + m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == target_user_id + ) + )).scalar_one_or_none() + if not m: + return None + m.role = role + await session.commit() + await session.refresh(m) + return m + + +async def remove_member(acting_user_id: int, group_id: int, target_user_id: int, is_site_admin: bool) -> bool: + """Group owner, site admin, or self-remove are allowed.""" + async with async_session() as session: + is_self = acting_user_id == target_user_id + if not is_site_admin and not is_self: + acting_m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not acting_m: + return False + m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == target_user_id + ) + )).scalar_one_or_none() + if not m: + return False + await session.delete(m) + await session.commit() + return True + + +async def get_user_groups(user_id: int) -> list[Group]: + async with async_session() as session: + rows = (await session.execute( + select(Group).join(GroupMembership, GroupMembership.group_id == Group.id) + .where(GroupMembership.user_id == user_id) + )).scalars().all() + return list(rows) +``` + +- [ ] **Step 2: Implement `routes/groups.py`** + +```python +from quart import Blueprint, g, jsonify, request +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services import groups as group_svc + +groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups") + + +@groups_bp.route("", methods=["GET"]) +@login_required +async def list_groups(): + uid = get_current_user_id() + is_admin = g.user.role == "admin" + result = await group_svc.list_groups(uid, is_admin) + return jsonify({"groups": result}) + + +@groups_bp.route("", methods=["POST"]) +@login_required +async def create_group(): + uid = get_current_user_id() + data = await request.get_json() + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "Name is required"}), 400 + try: + group = await group_svc.create_group(uid, name, data.get("description")) + except Exception: + return jsonify({"error": "Group name already taken"}), 409 + return jsonify(group.to_dict()), 201 + + +@groups_bp.route("/", methods=["GET"]) +@login_required +async def get_group(group_id: int): + group = await group_svc.get_group(group_id) + if not group: + return jsonify({"error": "Not found"}), 404 + members = await group_svc.list_members(group_id) + return jsonify({**group.to_dict(), "members": members}) + + +@groups_bp.route("/", methods=["PATCH"]) +@login_required +async def update_group(group_id: int): + uid = get_current_user_id() + data = await request.get_json() + group = await group_svc.update_group(uid, group_id, g.user.role == "admin", + **{k: v for k, v in data.items() if k in ("name", "description")}) + if not group: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(group.to_dict()) + + +@groups_bp.route("/", methods=["DELETE"]) +@login_required +async def delete_group(group_id: int): + uid = get_current_user_id() + deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin") + if not deleted: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) + + +@groups_bp.route("//members", methods=["GET"]) +@login_required +async def list_members(group_id: int): + members = await group_svc.list_members(group_id) + return jsonify({"members": members}) + + +@groups_bp.route("//members", methods=["POST"]) +@login_required +async def add_member(group_id: int): + uid = get_current_user_id() + data = await request.get_json() + target_uid = data.get("user_id") + role = data.get("role", "member") + if not target_uid: + return jsonify({"error": "user_id required"}), 400 + if role not in ("owner", "member"): + return jsonify({"error": "role must be owner or member"}), 400 + membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin") + if not membership: + return jsonify({"error": "Forbidden or group not found"}), 403 + return jsonify(membership.to_dict()), 201 + + +@groups_bp.route("//members/", methods=["PATCH"]) +@login_required +async def update_member(group_id: int, target_uid: int): + uid = get_current_user_id() + data = await request.get_json() + role = data.get("role") + if role not in ("owner", "member"): + return jsonify({"error": "role must be owner or member"}), 400 + m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin") + if not m: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(m.to_dict()) + + +@groups_bp.route("//members/", methods=["DELETE"]) +@login_required +async def remove_member(group_id: int, target_uid: int): + uid = get_current_user_id() + removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin") + if not removed: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) +``` + +- [ ] **Step 3: Register blueprint in `app.py`** + +```python +from fabledassistant.routes.groups import groups_bp +# in create_app(): +app.register_blueprint(groups_bp) +``` + +- [ ] **Step 4: Test manually** + +```bash +docker compose up --build -d +# Create group, add member, list, delete +curl -s -b cookies.txt http://localhost:5000/api/groups +``` +Expected: 200 with empty `groups` list + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/groups.py src/fabledassistant/routes/groups.py src/fabledassistant/app.py +git commit -m "feat: groups service and CRUD routes" +``` + +--- + +## Chunk 3: Sharing + +### Task 5: Sharing service + routes + user search + +**Files:** +- Create: `src/fabledassistant/services/sharing.py` +- Create: `src/fabledassistant/routes/shares.py` +- Create: `src/fabledassistant/routes/users.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Implement `services/sharing.py`** + +```python +import logging +from sqlalchemy import select +from fabledassistant.models import async_session +from fabledassistant.models.share import ProjectShare, NoteShare +from fabledassistant.models.group import GroupMembership +from fabledassistant.models.user import User +from fabledassistant.models.project import Project +from fabledassistant.models.note import Note + +logger = logging.getLogger(__name__) + +VALID_PERMISSIONS = {"viewer", "editor", "admin"} + + +async def share_project(owner_user_id: int, project_id: int, + target_user_id: int | None = None, + target_group_id: int | None = None, + permission: str = "viewer") -> ProjectShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + # Verify caller can admin the project + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(owner_user_id, project_id): + return None + share = ProjectShare(project_id=project_id, + shared_with_user_id=target_user_id, + shared_with_group_id=target_group_id, + permission=permission, invited_by=owner_user_id) + session.add(share) + await session.commit() + await session.refresh(share) + return share + + +async def update_project_share(acting_user_id: int, share_id: int, permission: str) -> ProjectShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + share = await session.get(ProjectShare, share_id) + if not share: + return None + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(acting_user_id, share.project_id): + return None + share.permission = permission + await session.commit() + await session.refresh(share) + return share + + +async def remove_project_share(acting_user_id: int, share_id: int) -> bool: + async with async_session() as session: + share = await session.get(ProjectShare, share_id) + if not share: + return False + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(acting_user_id, share.project_id): + return False + await session.delete(share) + await session.commit() + return True + + +async def list_project_shares(project_id: int) -> list[dict]: + async with async_session() as session: + shares = (await session.execute( + select(ProjectShare).where(ProjectShare.project_id == project_id) + )).scalars().all() + result = [] + for s in shares: + d = s.to_dict() + if s.shared_with_user_id: + user = await session.get(User, s.shared_with_user_id) + d["username"] = user.username if user else None + if s.shared_with_group_id: + from fabledassistant.models.group import Group + group = await session.get(Group, s.shared_with_group_id) + d["group_name"] = group.name if group else None + result.append(d) + return result + + +async def share_note(owner_user_id: int, note_id: int, + target_user_id: int | None = None, + target_group_id: int | None = None, + permission: str = "viewer") -> NoteShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + from fabledassistant.services.access import can_admin_project, get_note_permission + perm = await get_note_permission(owner_user_id, note_id) + if perm not in ("admin", "owner"): + return None + share = NoteShare(note_id=note_id, shared_with_user_id=target_user_id, + shared_with_group_id=target_group_id, + permission=permission, invited_by=owner_user_id) + session.add(share) + await session.commit() + await session.refresh(share) + return share + + +async def update_note_share(acting_user_id: int, share_id: int, permission: str) -> NoteShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + share = await session.get(NoteShare, share_id) + if not share: + return None + from fabledassistant.services.access import get_note_permission + perm = await get_note_permission(acting_user_id, share.note_id) + if perm not in ("admin", "owner"): + return None + share.permission = permission + await session.commit() + await session.refresh(share) + return share + + +async def remove_note_share(acting_user_id: int, share_id: int) -> bool: + async with async_session() as session: + share = await session.get(NoteShare, share_id) + if not share: + return False + from fabledassistant.services.access import get_note_permission + perm = await get_note_permission(acting_user_id, share.note_id) + if perm not in ("admin", "owner"): + return False + await session.delete(share) + await session.commit() + return True + + +async def list_note_shares(note_id: int) -> list[dict]: + async with async_session() as session: + shares = (await session.execute( + select(NoteShare).where(NoteShare.note_id == note_id) + )).scalars().all() + result = [] + for s in shares: + d = s.to_dict() + if s.shared_with_user_id: + user = await session.get(User, s.shared_with_user_id) + d["username"] = user.username if user else None + if s.shared_with_group_id: + from fabledassistant.models.group import Group + group = await session.get(Group, s.shared_with_group_id) + d["group_name"] = group.name if group else None + result.append(d) + return result + + +async def list_shared_with_me(user_id: int) -> dict: + """Returns all projects and notes shared with the user (directly or via group).""" + async with async_session() as session: + # User's groups + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + # Projects: direct shares + proj_shares_direct = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id) + )).scalars().all() + + # Projects: group shares + proj_shares_group = [] + if user_group_ids: + proj_shares_group = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_group_id.in_(user_group_ids)) + )).scalars().all() + + seen_project_ids: set[int] = set() + projects = [] + for share in list(proj_shares_direct) + list(proj_shares_group): + if share.project_id in seen_project_ids: + continue + seen_project_ids.add(share.project_id) + proj = await session.get(Project, share.project_id) + if proj: + owner = await session.get(User, proj.user_id) + projects.append({**proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}, + "owner_username": owner.username if owner else None, + "permission": share.permission}) + + # Notes: direct + group + note_shares_direct = (await session.execute( + select(NoteShare).where(NoteShare.shared_with_user_id == user_id) + )).scalars().all() + note_shares_group = [] + if user_group_ids: + note_shares_group = (await session.execute( + select(NoteShare).where(NoteShare.shared_with_group_id.in_(user_group_ids)) + )).scalars().all() + + seen_note_ids: set[int] = set() + notes = [] + for share in list(note_shares_direct) + list(note_shares_group): + if share.note_id in seen_note_ids: + continue + seen_note_ids.add(share.note_id) + note = await session.get(Note, share.note_id) + if note: + owner = await session.get(User, note.user_id) + notes.append({"id": note.id, "title": note.title, "is_task": note.is_task, + "project_id": note.project_id, "updated_at": note.updated_at.isoformat(), + "owner_username": owner.username if owner else None, + "permission": share.permission}) + + return {"projects": projects, "notes": notes} +``` + +- [ ] **Step 2: Implement `routes/shares.py`** + +```python +from quart import Blueprint, g, jsonify, request +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services import sharing as share_svc + +shares_bp = Blueprint("shares", __name__) + + +@shares_bp.route("/api/projects//shares", methods=["GET"]) +@login_required +async def list_project_shares(project_id: int): + uid = get_current_user_id() + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(uid, project_id): + return jsonify({"error": "Forbidden"}), 403 + shares = await share_svc.list_project_shares(project_id) + return jsonify({"shares": shares}) + + +@shares_bp.route("/api/projects//shares", methods=["POST"]) +@login_required +async def create_project_share(project_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.share_project( + uid, project_id, + target_user_id=data.get("user_id"), + target_group_id=data.get("group_id"), + permission=data.get("permission", "viewer") + ) + if not share: + return jsonify({"error": "Forbidden or invalid permission"}), 403 + # Fire notification (fire-and-forget) + import asyncio + from fabledassistant.services.notifications import notify_project_shared + asyncio.create_task(notify_project_shared(project_id, share.permission, uid, + data.get("user_id"), data.get("group_id"))) + return jsonify(share.to_dict()), 201 + + +@shares_bp.route("/api/projects//shares/", methods=["PATCH"]) +@login_required +async def update_project_share(project_id: int, share_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.update_project_share(uid, share_id, data.get("permission", "")) + if not share: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(share.to_dict()) + + +@shares_bp.route("/api/projects//shares/", methods=["DELETE"]) +@login_required +async def remove_project_share(project_id: int, share_id: int): + uid = get_current_user_id() + removed = await share_svc.remove_project_share(uid, share_id) + if not removed: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) + + +@shares_bp.route("/api/notes//shares", methods=["GET"]) +@login_required +async def list_note_shares(note_id: int): + uid = get_current_user_id() + from fabledassistant.services.access import can_write_note + if not await can_write_note(uid, note_id): + return jsonify({"error": "Forbidden"}), 403 + shares = await share_svc.list_note_shares(note_id) + return jsonify({"shares": shares}) + + +@shares_bp.route("/api/notes//shares", methods=["POST"]) +@login_required +async def create_note_share(note_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.share_note(uid, note_id, + target_user_id=data.get("user_id"), + target_group_id=data.get("group_id"), + permission=data.get("permission", "viewer")) + if not share: + return jsonify({"error": "Forbidden or invalid permission"}), 403 + import asyncio + from fabledassistant.services.notifications import notify_note_shared + asyncio.create_task(notify_note_shared(note_id, share.permission, uid, + data.get("user_id"), data.get("group_id"))) + return jsonify(share.to_dict()), 201 + + +@shares_bp.route("/api/notes//shares/", methods=["PATCH"]) +@login_required +async def update_note_share(note_id: int, share_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.update_note_share(uid, share_id, data.get("permission", "")) + if not share: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(share.to_dict()) + + +@shares_bp.route("/api/notes//shares/", methods=["DELETE"]) +@login_required +async def remove_note_share(note_id: int, share_id: int): + uid = get_current_user_id() + removed = await share_svc.remove_note_share(uid, share_id) + if not removed: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) + + +@shares_bp.route("/api/shared-with-me", methods=["GET"]) +@login_required +async def shared_with_me(): + uid = get_current_user_id() + data = await share_svc.list_shared_with_me(uid) + return jsonify(data) +``` + +- [ ] **Step 3: Implement `routes/users.py`** + +```python +from quart import Blueprint, jsonify, request +from sqlalchemy import select, or_ +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.models import async_session +from fabledassistant.models.user import User + +users_bp = Blueprint("users", __name__, url_prefix="/api/users") + + +@users_bp.route("/search", methods=["GET"]) +@login_required +async def search_users(): + uid = get_current_user_id() + q = (request.args.get("q") or "").strip() + if len(q) < 2: + return jsonify({"users": []}) + async with async_session() as session: + like = f"{q}%" + users = (await session.execute( + select(User).where( + User.id != uid, + or_(User.username.ilike(like), User.email.ilike(like)) + ).limit(10) + )).scalars().all() + return jsonify({"users": [{"id": u.id, "username": u.username, "email": u.email} for u in users]}) +``` + +- [ ] **Step 4: Register blueprints in `app.py`** + +```python +from fabledassistant.routes.shares import shares_bp +from fabledassistant.routes.users import users_bp +# in create_app(): +app.register_blueprint(shares_bp) +app.register_blueprint(users_bp) +``` + +- [ ] **Step 5: Typecheck and commit** + +```bash +docker compose exec app python -m py_compile \ + src/fabledassistant/services/sharing.py \ + src/fabledassistant/routes/shares.py \ + src/fabledassistant/routes/users.py +git add src/fabledassistant/services/sharing.py src/fabledassistant/routes/shares.py src/fabledassistant/routes/users.py src/fabledassistant/app.py +git commit -m "feat: sharing service, share routes, user search endpoint" +``` + +--- + +## Chunk 4: Notifications + +### Task 6: Notifications service + routes + +**Files:** +- Create: `src/fabledassistant/services/notifications.py` +- Create: `src/fabledassistant/routes/notifications.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Implement `services/notifications.py`** + +```python +import logging +import asyncio +from datetime import datetime, timezone +from sqlalchemy import select, func, update +from fabledassistant.models import async_session +from fabledassistant.models.notification import Notification +from fabledassistant.models.user import User +from fabledassistant.services.push import send_push_notification + +logger = logging.getLogger(__name__) + + +async def create_notification(user_id: int, notif_type: str, payload: dict) -> Notification: + async with async_session() as session: + n = Notification(user_id=user_id, type=notif_type, payload=payload) + session.add(n) + await session.commit() + await session.refresh(n) + return n + + +async def _fire_push(user_id: int, title: str, body: str, url: str) -> None: + try: + await send_push_notification(user_id, title, body, url=url) + except Exception: + logger.exception("Push notification failed for user %d", user_id) + + +async def _fire_email(user_id: int, subject: str, body: str) -> None: + try: + from fabledassistant.services.email import is_smtp_configured, send_notification_email + if not await is_smtp_configured(): + return + async with async_session() as session: + user = await session.get(User, user_id) + if user and user.email: + await send_notification_email(user.email, subject, body) + except Exception: + logger.exception("Email notification failed for user %d", user_id) + + +async def _resolve_group_members(group_id: int) -> list[int]: + from fabledassistant.models.group import GroupMembership + async with async_session() as session: + rows = (await session.execute( + select(GroupMembership.user_id).where(GroupMembership.group_id == group_id) + )).scalars().all() + return list(rows) + + +async def notify_project_shared(project_id: int, permission: str, invited_by_user_id: int, + target_user_id: int | None, target_group_id: int | None) -> None: + from fabledassistant.models.project import Project + async with async_session() as session: + project = await session.get(Project, project_id) + inviter = await session.get(User, invited_by_user_id) + if not project or not inviter: + return + project_title = project.title + inviter_name = inviter.username + + recipients: list[int] = [] + if target_user_id: + recipients = [target_user_id] + elif target_group_id: + recipients = await _resolve_group_members(target_group_id) + recipients = [r for r in recipients if r != invited_by_user_id] + + message = f"{inviter_name} shared \"{project_title}\" with you as {permission}" + url = f"/projects/{project_id}" + + for uid in recipients: + await create_notification(uid, "project_shared", { + "project_id": project_id, + "project_title": project_title, + "permission": permission, + "invited_by": inviter_name, + "url": url, + }) + asyncio.create_task(_fire_push(uid, "Project shared with you", message, url)) + asyncio.create_task(_fire_email(uid, + f"[Fabled] {inviter_name} shared a project with you", + f"{message}\n\nView it at: {{base_url}}{url}" + )) + + +async def notify_note_shared(note_id: int, permission: str, invited_by_user_id: int, + target_user_id: int | None, target_group_id: int | None) -> None: + from fabledassistant.models.note import Note + async with async_session() as session: + note = await session.get(Note, note_id) + inviter = await session.get(User, invited_by_user_id) + if not note or not inviter: + return + note_title = note.title + inviter_name = inviter.username + is_task = note.is_task + + recipients: list[int] = [] + if target_user_id: + recipients = [target_user_id] + elif target_group_id: + recipients = await _resolve_group_members(target_group_id) + recipients = [r for r in recipients if r != invited_by_user_id] + + route = "tasks" if is_task else "notes" + url = f"/{route}/{note_id}" + message = f"{inviter_name} shared \"{note_title}\" with you as {permission}" + + for uid in recipients: + await create_notification(uid, "note_shared", { + "note_id": note_id, + "note_title": note_title, + "permission": permission, + "invited_by": inviter_name, + "url": url, + }) + asyncio.create_task(_fire_push(uid, "Note shared with you", message, url)) + asyncio.create_task(_fire_email(uid, + f"[Fabled] {inviter_name} shared a note with you", + f"{message}\n\nView it at: {{base_url}}{url}" + )) + + +async def notify_group_added(group_id: int, role: str, invited_by_user_id: int, + target_user_id: int) -> None: + from fabledassistant.models.group import Group + async with async_session() as session: + group = await session.get(Group, group_id) + inviter = await session.get(User, invited_by_user_id) + if not group or not inviter: + return + group_name = group.name + inviter_name = inviter.username + + url = f"/groups/{group_id}" + message = f"{inviter_name} added you to group \"{group_name}\" as {role}" + await create_notification(target_user_id, "group_added", { + "group_id": group_id, + "group_name": group_name, + "role": role, + "invited_by": inviter_name, + "url": url, + }) + asyncio.create_task(_fire_push(target_user_id, "Added to group", message, url)) + asyncio.create_task(_fire_email(target_user_id, + f"[Fabled] You've been added to a group", + f"{message}" + )) + + +async def list_notifications(user_id: int, unread_only: bool = True) -> list[dict]: + async with async_session() as session: + q = select(Notification).where(Notification.user_id == user_id) + if unread_only: + q = q.where(Notification.read_at.is_(None)) + q = q.order_by(Notification.created_at.desc()).limit(50) + rows = (await session.execute(q)).scalars().all() + return [n.to_dict() for n in rows] + + +async def unread_count(user_id: int) -> int: + async with async_session() as session: + result = await session.execute( + select(func.count()).where( + Notification.user_id == user_id, + Notification.read_at.is_(None) + ) + ) + return result.scalar() or 0 + + +async def mark_read(user_id: int, notification_id: int) -> bool: + async with async_session() as session: + n = (await session.execute( + select(Notification).where(Notification.id == notification_id, Notification.user_id == user_id) + )).scalar_one_or_none() + if not n: + return False + n.read_at = datetime.now(timezone.utc) + await session.commit() + return True + + +async def mark_all_read(user_id: int) -> int: + async with async_session() as session: + result = await session.execute( + update(Notification) + .where(Notification.user_id == user_id, Notification.read_at.is_(None)) + .values(read_at=datetime.now(timezone.utc)) + .returning(Notification.id) + ) + await session.commit() + return len(result.fetchall()) +``` + +- [ ] **Step 2: Add `send_notification_email` stub in `services/email.py`** + +Check if `send_notification_email` exists. If not, add: +```python +async def send_notification_email(to_email: str, subject: str, body: str) -> None: + """Send a simple plain-text notification email.""" + # Reuse existing send_email infrastructure + await send_email(to_email, subject, body) +``` + +- [ ] **Step 3: Update `routes/groups.py` to fire `notify_group_added`** + +In `add_member` route, after successful membership creation: +```python +import asyncio +from fabledassistant.services.notifications import notify_group_added +asyncio.create_task(notify_group_added(group_id, role, uid, target_uid)) +``` + +- [ ] **Step 4: Implement `routes/notifications.py`** + +```python +from quart import Blueprint, jsonify, request +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services import notifications as notif_svc + +notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications") + + +@notifications_bp.route("", methods=["GET"]) +@login_required +async def list_notifications(): + uid = get_current_user_id() + all_flag = request.args.get("all", "false").lower() == "true" + items = await notif_svc.list_notifications(uid, unread_only=not all_flag) + return jsonify({"notifications": items}) + + +@notifications_bp.route("/count", methods=["GET"]) +@login_required +async def get_count(): + uid = get_current_user_id() + count = await notif_svc.unread_count(uid) + return jsonify({"count": count}) + + +@notifications_bp.route("//read", methods=["POST"]) +@login_required +async def mark_read(notif_id: int): + uid = get_current_user_id() + ok = await notif_svc.mark_read(uid, notif_id) + if not ok: + return jsonify({"error": "Not found"}), 404 + return jsonify({"status": "ok"}) + + +@notifications_bp.route("/read-all", methods=["POST"]) +@login_required +async def mark_all_read(): + uid = get_current_user_id() + count = await notif_svc.mark_all_read(uid) + return jsonify({"status": "ok", "marked": count}) +``` + +- [ ] **Step 5: Register in `app.py`** + +```python +from fabledassistant.routes.notifications import notifications_bp +app.register_blueprint(notifications_bp) +``` + +- [ ] **Step 6: Rebuild and verify** + +```bash +docker compose up --build -d +curl -s -b cookies.txt http://localhost:5000/api/notifications/count +``` +Expected: `{"count": 0}` + +- [ ] **Step 7: Commit** + +```bash +git add src/fabledassistant/services/notifications.py src/fabledassistant/routes/notifications.py src/fabledassistant/routes/groups.py src/fabledassistant/app.py +git commit -m "feat: notification service and routes; group-add notification hook" +``` + +--- + +## Chunk 5: Modified services for shared access + +### Task 7: Extend project + note services with `_for_user` variants + +**Files:** +- Modify: `src/fabledassistant/services/projects.py` +- Modify: `src/fabledassistant/services/notes.py` +- Modify: `src/fabledassistant/routes/projects.py` +- Modify: `src/fabledassistant/routes/notes.py` +- Modify: `src/fabledassistant/routes/tasks.py` + +- [ ] **Step 1: Add `get_project_for_user` in `services/projects.py`** + +```python +async def get_project_for_user(accessing_user_id: int, project_id: int) -> tuple[Project, str] | None: + """Returns (project, permission) if user has any access, else None.""" + from fabledassistant.services.access import get_project_permission + perm = await get_project_permission(accessing_user_id, project_id) + if perm is None: + return None + async with async_session() as session: + project = await session.get(Project, project_id) + return (project, perm) if project else None + + +async def list_projects_for_user(user_id: int) -> list[dict]: + """Returns owned projects + shared projects, each with a 'permission' field.""" + owned = await list_projects(user_id) + owned_dicts = [{**p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}, + "permission": "owner"} for p in owned] + + from fabledassistant.models.share import ProjectShare + from fabledassistant.models.group import GroupMembership + async with async_session() as session: + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + shared_shares = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id) + )).scalars().all() + + group_shares = [] + if user_group_ids: + group_shares = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_group_id.in_(user_group_ids)) + )).scalars().all() + + owned_ids = {p.id for p in owned} + seen: dict[int, str] = {} + for share in list(shared_shares) + list(group_shares): + if share.project_id in owned_ids: + continue + if share.project_id not in seen or PERMISSION_RANK[share.permission] > PERMISSION_RANK[seen[share.project_id]]: + seen[share.project_id] = share.permission + + shared_projects = [] + for pid, perm in seen.items(): + async with async_session() as session: + proj = await session.get(Project, pid) + if proj: + shared_projects.append({**{"id": proj.id, "title": proj.title, "description": proj.description, + "status": proj.status, "color": proj.color, + "updated_at": proj.updated_at.isoformat()}, + "permission": perm, "is_shared": True}) + + return owned_dicts + shared_projects +``` + +Note: Import `PERMISSION_RANK` from `services/access.py` to avoid duplication, or inline a local copy. + +- [ ] **Step 2: Add `get_note_for_user` in `services/notes.py`** + +```python +async def get_note_for_user(accessing_user_id: int, note_id: int) -> tuple[Note, str] | None: + """Returns (note, permission) or None if no access.""" + from fabledassistant.services.access import get_note_permission + perm = await get_note_permission(accessing_user_id, note_id) + if perm is None: + return None + async with async_session() as session: + note = await session.get(Note, note_id) + return (note, perm) if note else None +``` + +- [ ] **Step 3: Update `routes/projects.py` to use `_for_user` on read endpoints** + +`get_project_route` and `get_project_notes_route` should call `get_project_for_user` and pass permission to the response: +```python +@projects_bp.route("/", methods=["GET"]) +@login_required +async def get_project_route(project_id: int): + uid = get_current_user_id() + result = await get_project_for_user(uid, project_id) + if not result: + return jsonify({"error": "Not found"}), 404 + project, permission = result + # existing serialization logic + add "permission": permission to response + ... +``` + +`list_projects_route` → call `list_projects_for_user(uid)`. + +Mutation routes (PATCH, DELETE) continue to require owner or admin permission (checked via `can_admin_project`). + +- [ ] **Step 4: Update `routes/notes.py` and `routes/tasks.py`** + +Read-only routes (`GET /api/notes/:id`, `GET /api/tasks/:id`) call `get_note_for_user`. +Write routes verify `can_write_note`. Delete routes verify ownership (notes are deleted only by owner). + +- [ ] **Step 5: Typecheck** + +```bash +docker compose exec app python -m py_compile \ + src/fabledassistant/services/projects.py \ + src/fabledassistant/services/notes.py \ + src/fabledassistant/routes/projects.py \ + src/fabledassistant/routes/notes.py \ + src/fabledassistant/routes/tasks.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/services/projects.py src/fabledassistant/services/notes.py \ + src/fabledassistant/routes/projects.py src/fabledassistant/routes/notes.py src/fabledassistant/routes/tasks.py +git commit -m "feat: extend project/note services with shared-access variants" +``` + +--- + +## Chunk 6: Frontend + +### Task 8: ShareDialog component + API client methods + +**Files:** +- Modify: `frontend/src/api/client.ts` +- Create: `frontend/src/components/ShareDialog.vue` + +- [ ] **Step 1: Add API methods to `client.ts`** + +```typescript +// Sharing +export const listProjectShares = (projectId: number) => + api.get(`/projects/${projectId}/shares`).then(r => r.data.shares) +export const createProjectShare = (projectId: number, body: object) => + api.post(`/projects/${projectId}/shares`, body).then(r => r.data) +export const updateProjectShare = (projectId: number, shareId: number, permission: string) => + api.patch(`/projects/${projectId}/shares/${shareId}`, { permission }).then(r => r.data) +export const deleteProjectShare = (projectId: number, shareId: number) => + api.delete(`/projects/${projectId}/shares/${shareId}`) + +export const listNoteShares = (noteId: number) => + api.get(`/notes/${noteId}/shares`).then(r => r.data.shares) +export const createNoteShare = (noteId: number, body: object) => + api.post(`/notes/${noteId}/shares`, body).then(r => r.data) +export const updateNoteShare = (noteId: number, shareId: number, permission: string) => + api.patch(`/notes/${noteId}/shares/${shareId}`, { permission }).then(r => r.data) +export const deleteNoteShare = (noteId: number, shareId: number) => + api.delete(`/notes/${noteId}/shares/${shareId}`) + +export const searchUsers = (q: string) => + api.get('/users/search', { params: { q } }).then(r => r.data.users) +export const listGroups = () => + api.get('/groups').then(r => r.data.groups) +export const sharedWithMe = () => + api.get('/shared-with-me').then(r => r.data) + +// Notifications +export const getNotifications = (all = false) => + api.get('/notifications', { params: all ? { all: 'true' } : {} }).then(r => r.data.notifications) +export const getNotificationCount = () => + api.get('/notifications/count').then(r => r.data.count as number) +export const markNotificationRead = (id: number) => + api.post(`/notifications/${id}/read`) +export const markAllNotificationsRead = () => + api.post('/notifications/read-all') +``` + +- [ ] **Step 2: Build `ShareDialog.vue`** + +The dialog is a modal overlay. Key structure: + +```vue + +``` + +Props: `resourceType: 'project' | 'note'`, `resourceId: number`, `resourceTitle: string` +Emits: `close` + +Use `searchUsers` with a 300ms debounce for the user search input. +Load groups on mount via `listGroups()`. +Load current shares on mount. + +- [ ] **Step 3: TypeScript typecheck** + +```bash +cd frontend && npx vue-tsc --noEmit +``` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/api/client.ts frontend/src/components/ShareDialog.vue +git commit -m "feat: ShareDialog component and sharing API client methods" +``` + +--- + +### Task 9: Notification bell + panel + +**Files:** +- Create: `frontend/src/components/NotificationBell.vue` +- Create: `frontend/src/components/NotificationsPanel.vue` +- Create: `frontend/src/stores/notifications.ts` +- Modify: `frontend/src/App.vue` + +- [ ] **Step 1: Create notifications Pinia store** + +```typescript +// frontend/src/stores/notifications.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getNotificationCount, getNotifications, markAllNotificationsRead, markNotificationRead } from '@/api/client' + +export const useNotificationsStore = defineStore('notifications', () => { + const count = ref(0) + const items = ref([]) + + async function fetchCount() { + count.value = await getNotificationCount() + } + + async function fetchAll() { + items.value = await getNotifications(false) + count.value = items.value.length + } + + async function markRead(id: number) { + await markNotificationRead(id) + items.value = items.value.filter(n => n.id !== id) + count.value = Math.max(0, count.value - 1) + } + + async function markAll() { + await markAllNotificationsRead() + items.value = [] + count.value = 0 + } + + return { count, items, fetchCount, fetchAll, markRead, markAll } +}) +``` + +- [ ] **Step 2: Build `NotificationBell.vue`** + +Bell SVG icon with `.badge` absolutely positioned. Clicking opens/closes the panel. +Polls `fetchCount()` every 60s using `setInterval` in `onMounted`, cleared in `onUnmounted`. + +```vue + +``` + +- [ ] **Step 3: Build `NotificationsPanel.vue`** + +Positioned dropdown (absolute, right-aligned). Lists notifications with icon, message, relative time. "Mark all read" button. Click on notification navigates to `payload.url` and marks it read. + +- [ ] **Step 4: Add `NotificationBell` to `App.vue` nav** + +Place alongside existing nav controls (before the user menu or theme toggle). + +- [ ] **Step 5: TypeScript typecheck + commit** + +```bash +cd frontend && npx vue-tsc --noEmit +git add frontend/src/stores/notifications.ts frontend/src/components/NotificationBell.vue frontend/src/components/NotificationsPanel.vue frontend/src/App.vue +git commit -m "feat: notification bell, panel, and Pinia store" +``` + +--- + +### Task 10: Shared-with-me view + nav integration + +**Files:** +- Create: `frontend/src/views/SharedWithMeView.vue` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/App.vue` +- Modify: `frontend/src/views/HomeView.vue` + +- [ ] **Step 1: Build `SharedWithMeView.vue`** + +Route: `/shared` + +Two sections — Shared Projects, Shared Notes & Tasks: +- Project cards matching `ProjectCard` style; show owner username + permission badge +- Note rows with title, owner, project (if any), permission badge, link to note +- Empty state with friendly message when nothing is shared + +- [ ] **Step 2: Register route in `router/index.ts`** + +```typescript +{ path: '/shared', name: 'shared-with-me', + component: () => import('@/views/SharedWithMeView.vue') }, +``` + +- [ ] **Step 3: Add nav item to `App.vue`** + +People icon + "Shared" label, linking to `/shared`. + +- [ ] **Step 4: Add shared-with-me summary to `HomeView.vue`** + +Below active projects section: +``` +Shared with me +[ Project A - editor ] [ Note: Design doc - viewer ] → View all +``` +Max 3 items, "View all →" links to `/shared`. + +- [ ] **Step 5: Add Share button to `ProjectView.vue`, `NoteViewerView.vue`, `TaskViewerView.vue`** + +```vue + + +``` + +- [ ] **Step 6: TypeScript typecheck + commit** + +```bash +cd frontend && npx vue-tsc --noEmit +git add frontend/src/views/SharedWithMeView.vue frontend/src/router/index.ts frontend/src/App.vue frontend/src/views/HomeView.vue frontend/src/views/ProjectView.vue frontend/src/views/NoteViewerView.vue frontend/src/views/TaskViewerView.vue +git commit -m "feat: SharedWithMeView, nav integration, Share buttons on resource views" +``` + +--- + +### Task 11: Admin groups management in SettingsView + +**Files:** +- Modify: `frontend/src/views/SettingsView.vue` + +- [ ] **Step 1: Add API methods for admin group management to `client.ts`** + +```typescript +export const adminListGroups = () => api.get('/groups').then(r => r.data.groups) +export const adminCreateGroup = (name: string, description?: string) => + api.post('/groups', { name, description }).then(r => r.data) +export const adminDeleteGroup = (id: number) => api.delete(`/groups/${id}`) +export const getGroupMembers = (id: number) => + api.get(`/groups/${id}/members`).then(r => r.data.members) +export const addGroupMember = (groupId: number, userId: number, role: string) => + api.post(`/groups/${groupId}/members`, { user_id: userId, role }).then(r => r.data) +export const updateGroupMember = (groupId: number, userId: number, role: string) => + api.patch(`/groups/${groupId}/members/${userId}`, { role }).then(r => r.data) +export const removeGroupMember = (groupId: number, userId: number) => + api.delete(`/groups/${groupId}/members/${userId}`) +``` + +- [ ] **Step 2: Add "Groups" tab to Admin section in `SettingsView.vue`** + +In the Admin tab (already gated by `authStore.isAdmin`), add a "Groups" subsection: +- Group list with name, member count, delete button +- "Create group" form (name + optional description) +- Expand group → show member list with role, remove button; "Add member" form with user search + +The admin can manage all groups; group owners who are not admins manage via the normal `/groups` API but don't see this admin panel. + +- [ ] **Step 3: TypeScript typecheck + commit** + +```bash +cd frontend && npx vue-tsc --noEmit +git add frontend/src/views/SettingsView.vue frontend/src/api/client.ts +git commit -m "feat: admin groups management in SettingsView" +``` + +--- + +## Chunk 7: Verification + +### Task 12: End-to-end verification + +- [ ] **Step 1: Rebuild** + +```bash +docker compose up --build -d +``` + +- [ ] **Step 2: Multi-user flow** + +With two accounts (user A = owner, user B = collaborator): +1. User A creates a project → creates note inside it +2. User A opens ProjectView → "Share" button present → click +3. ShareDialog opens → search for user B → select "editor" → Add +4. User B receives notification (bell badge appears) +5. User B opens notification → navigates to project → can view project + notes +6. User B edits a note → save succeeds +7. User B tries to delete project → 403 (only viewer+ can't delete) +8. User A opens `/shared` as user B → project appears with "editor" badge + +- [ ] **Step 3: Group flow** + +1. Admin creates group via Settings → Groups +2. Admin adds user B to group +3. User B receives group notification +4. User A shares project with group +5. User B sees project in "Shared with me" + +- [ ] **Step 4: TypeScript final check** + +```bash +cd frontend && npx vue-tsc --noEmit +``` +Expected: No errors. + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "chore: sharing feature complete — all chunks verified" +```