From 7fdb2ee39d919c207e4bda126c8f3d45da737bcc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 12:16:23 -0400 Subject: [PATCH] =?UTF-8?q?chore:=20ignore=20docs/superpowers,=20docs/plan?= =?UTF-8?q?s,=20docs/specs=20=E2=80=94=20keep=20only=20root=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- docs/plans/2026-03-11-backup-rewrite.md | 538 ---- .../plans/2026-03-11-sharing-collaboration.md | 2023 --------------- .../plans/2026-03-25-briefing-improvements.md | 2172 ----------------- docs/plans/2026-03-25-internal-calendar.md | 1953 --------------- docs/plans/2026-03-25-rag-scoping.md | 1156 --------- ...2026-03-25-briefing-improvements-design.md | 285 --- .../2026-03-25-internal-calendar-design.md | 214 -- docs/specs/2026-03-25-rag-scoping-design.md | 236 -- .../2026-03-28-news-briefing-redesign.md | 1651 ------------- .../2026-03-27-news-briefing-redesign.md | 398 --- 11 files changed, 3 insertions(+), 10627 deletions(-) delete mode 100644 docs/plans/2026-03-11-backup-rewrite.md delete mode 100644 docs/plans/2026-03-11-sharing-collaboration.md delete mode 100644 docs/plans/2026-03-25-briefing-improvements.md delete mode 100644 docs/plans/2026-03-25-internal-calendar.md delete mode 100644 docs/plans/2026-03-25-rag-scoping.md delete mode 100644 docs/specs/2026-03-25-briefing-improvements-design.md delete mode 100644 docs/specs/2026-03-25-internal-calendar-design.md delete mode 100644 docs/specs/2026-03-25-rag-scoping-design.md delete mode 100644 docs/superpowers/plans/2026-03-28-news-briefing-redesign.md delete mode 100644 docs/superpowers/specs/2026-03-27-news-briefing-redesign.md diff --git a/.gitignore b/.gitignore index 54ef042..765ae6a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ settings.local.json # Claude Code .claude/ -docs/superpowers/specs/.brainstorm/ +docs/superpowers/ +docs/plans/ +docs/specs/ # Environment .env diff --git a/docs/plans/2026-03-11-backup-rewrite.md b/docs/plans/2026-03-11-backup-rewrite.md deleted file mode 100644 index 1c65dfb..0000000 --- a/docs/plans/2026-03-11-backup-rewrite.md +++ /dev/null @@ -1,538 +0,0 @@ -# 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 deleted file mode 100644 index c24cea7..0000000 --- a/docs/plans/2026-03-11-sharing-collaboration.md +++ /dev/null @@ -1,2023 +0,0 @@ -# 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" -``` diff --git a/docs/plans/2026-03-25-briefing-improvements.md b/docs/plans/2026-03-25-briefing-improvements.md deleted file mode 100644 index 533e814..0000000 --- a/docs/plans/2026-03-25-briefing-improvements.md +++ /dev/null @@ -1,2172 +0,0 @@ -# Briefing Service Improvements — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the briefing's repetitive, prose-only output with a pre-filtered, structured pipeline that tracks task changes, classifies RSS topics, gates stale weather, and surfaces news source links with per-story reactions. - -**Architecture:** Pre-processing stage runs before gather — task change detection diffs against a DB snapshot, RSS filtering applies topic preferences and reaction weights, weather is gated at 24h. Structured card metadata is stored on `Message.metadata` (JSONB) at write time; the frontend reads it on load to render `WeatherCard.vue` and reaction buttons without any SSE changes. - -**Tech Stack:** Python 3.12 + SQLAlchemy 2.0 async, Quart, httpx (Open-Meteo `current_weather=true` + `past_days=1`), Vue 3 + TypeScript, existing `TagInput.vue`, FastMCP (fable-mcp). - -**Spec:** `docs/specs/2026-03-25-briefing-improvements-design.md` - ---- - -## File Map - -### New backend files -- `alembic/versions/0028_add_briefing_improvements.py` -- `src/fabledassistant/services/rss_classifier.py` -- `src/fabledassistant/services/briefing_preferences.py` - -### Modified backend files -- `src/fabledassistant/models/conversation.py` — add `Message.metadata` JSONB column + `to_dict()` -- `src/fabledassistant/models/rss_feed.py` — add `RssItem.topics`, `RssItem.classified_at` -- `src/fabledassistant/services/briefing_conversations.py` — `post_message(... metadata=None)` -- `src/fabledassistant/services/weather.py` — add `current_weather=true`, `past_days=1`, `parse_weather_card_data()` -- `src/fabledassistant/services/rss.py` — trigger classification after storing new items -- `src/fabledassistant/services/briefing_pipeline.py` — pre-processing stage, task snapshot helpers, return `(text, metadata)` from `run_compilation` -- `src/fabledassistant/routes/briefing.py` — unpack tuple from `run_compilation`; add reaction endpoints - -### New frontend files -- `frontend/src/components/WeatherCard.vue` - -### Modified frontend files -- `frontend/src/api/client.ts` — add `postRssReaction()`, `deleteRssReaction()` -- `frontend/src/views/BriefingView.vue` — render `WeatherCard`, reaction buttons from metadata -- `frontend/src/views/SettingsView.vue` — add "News Preferences" subsection - -### New MCP files -- `fable-mcp/fable_mcp/tools/briefing.py` - -### Modified MCP files -- `fable-mcp/fable_mcp/server.py` — register briefing tools - -### Test files -- `tests/test_briefing_pipeline.py` — extend with new tests -- `tests/test_weather_service.py` — extend with card parser tests -- `tests/test_rss_service.py` — extend with classifier + preferences tests - ---- - -## Task 1: Migration 0028 - -**Files:** -- Create: `alembic/versions/0028_add_briefing_improvements.py` - -- [ ] **Step 1: Create the migration file** - -```python -"""Add briefing improvements: rss_items topics/classified_at, messages metadata, - rss_item_reactions, briefing_task_snapshot.""" - -from alembic import op - -revision = "0028" -down_revision = "0027" - - -def upgrade() -> None: - op.execute(""" - ALTER TABLE rss_items - ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ - """) - op.execute(""" - ALTER TABLE messages - ADD COLUMN IF NOT EXISTS metadata JSONB - """) - op.execute(""" - CREATE TABLE IF NOT EXISTS rss_item_reactions ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE, - reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')), - created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE (user_id, rss_item_id) - ) - """) - op.execute( - "CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id " - "ON rss_item_reactions(user_id)" - ) - op.execute(""" - CREATE TABLE IF NOT EXISTS briefing_task_snapshot ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, - snapshot_hash TEXT NOT NULL, - last_briefed TIMESTAMPTZ DEFAULT NOW(), - UNIQUE (user_id, task_id) - ) - """) - op.execute( - "CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id " - "ON briefing_task_snapshot(user_id)" - ) - - -def downgrade() -> None: - op.execute("DROP TABLE IF EXISTS briefing_task_snapshot") - op.execute("DROP TABLE IF EXISTS rss_item_reactions") - op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS metadata") - op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS classified_at") - op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS topics") -``` - -- [ ] **Step 2: Apply migration inside the running container** - -```bash -docker compose exec app alembic upgrade head -``` - -Expected: `Running upgrade 0027 -> 0028, Add briefing improvements` - -- [ ] **Step 3: Commit** - -```bash -git add alembic/versions/0028_add_briefing_improvements.py -git commit -m "feat(briefing): add migration 0028 — briefing improvements schema" -``` - ---- - -## Task 2: Update SQLAlchemy Models - -**Files:** -- Modify: `src/fabledassistant/models/conversation.py` -- Modify: `src/fabledassistant/models/rss_feed.py` - -- [ ] **Step 1: Add `metadata` column to `Message` in `models/conversation.py`** - -In the `Message` class, after the existing `tool_calls` column, add: - -```python -metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True) -``` - -In `Message.to_dict()`, add `"metadata": self.metadata` to the returned dict. - -- [ ] **Step 2: Add `topics` and `classified_at` columns to `RssItem` in `models/rss_feed.py`** - -Add imports at the top (if not already present): -```python -from sqlalchemy import ARRAY, DateTime, Text # ARRAY is new -from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY -``` - -Actually SQLAlchemy's native ARRAY works for PostgreSQL. Use: -```python -from sqlalchemy import ARRAY, Text -``` - -In the `RssItem` class, after `fetched_at`, add: - -```python -topics: Mapped[list[str]] = mapped_column( - ARRAY(Text), nullable=False, default=list, server_default="{}" -) -classified_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True -) -``` - -In `RssItem.to_dict()`, add: -```python -"topics": self.topics or [], -"classified_at": self.classified_at.isoformat() if self.classified_at else None, -``` - -- [ ] **Step 3: Rebuild the container to pick up model changes** - -```bash -docker compose up --build -d app -``` - -- [ ] **Step 4: Write a quick smoke test (no DB needed)** - -In `tests/test_briefing_models.py`, add: - -```python -def test_message_metadata_field_exists(): - from fabledassistant.models.conversation import Message - m = Message(conversation_id=1, role="assistant", content="hi", metadata={"foo": 1}) - assert m.metadata == {"foo": 1} - d = m.to_dict() - assert "metadata" in d - -def test_rss_item_topics_field_exists(): - from fabledassistant.models.rss_feed import RssItem - item = RssItem(feed_id=1, guid="x", title="Test", topics=["technology"]) - assert item.topics == ["technology"] - d = item.to_dict() - assert "topics" in d - assert "classified_at" in d -``` - -- [ ] **Step 5: Run tests** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_briefing_models.py -v -``` - -Expected: both new tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/fabledassistant/models/conversation.py src/fabledassistant/models/rss_feed.py tests/test_briefing_models.py -git commit -m "feat(briefing): add Message.metadata and RssItem.topics/classified_at columns" -``` - ---- - -## Task 3: Extend `post_message()` - -**Files:** -- Modify: `src/fabledassistant/services/briefing_conversations.py` - -- [ ] **Step 1: Write the failing test** - -In `tests/test_briefing_pipeline.py`, add: - -```python -@pytest.mark.asyncio -async def test_post_message_accepts_metadata(): - """post_message should accept an optional metadata dict and store it.""" - from unittest.mock import AsyncMock, patch, MagicMock - - mock_msg = MagicMock() - mock_msg.id = 1 - - with patch("fabledassistant.services.briefing_conversations.async_session") as mock_session_cls: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.add = MagicMock() - mock_session.get = AsyncMock(return_value=None) - mock_session.commit = AsyncMock() - mock_session.refresh = AsyncMock() - mock_session_cls.return_value = mock_session - - from fabledassistant.services.briefing_conversations import post_message - import importlib - import fabledassistant.services.briefing_conversations as bc - importlib.reload(bc) - - # Should not raise TypeError - await bc.post_message(1, "assistant", "text", metadata={"rss_item_ids": [1, 2]}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py::test_post_message_accepts_metadata -v -``` - -Expected: FAIL — `TypeError: post_message() got an unexpected keyword argument 'metadata'` - -- [ ] **Step 3: Update `post_message()` signature** - -In `services/briefing_conversations.py`, change: - -```python -async def post_message(conversation_id: int, role: str, content: str) -> Message: - """Append a message to a briefing conversation.""" - async with async_session() as session: - msg = Message( - conversation_id=conversation_id, - role=role, - content=content, - status="complete", - ) -``` - -To: - -```python -async def post_message( - conversation_id: int, - role: str, - content: str, - metadata: dict | None = None, -) -> Message: - """Append a message to a briefing conversation.""" - async with async_session() as session: - msg = Message( - conversation_id=conversation_id, - role=role, - content=content, - status="complete", - metadata=metadata, - ) -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py::test_post_message_accepts_metadata -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/fabledassistant/services/briefing_conversations.py tests/test_briefing_pipeline.py -git commit -m "feat(briefing): extend post_message() with optional metadata parameter" -``` - ---- - -## Task 4: Weather Service — Card Data Parser - -**Files:** -- Modify: `src/fabledassistant/services/weather.py` -- Test: `tests/test_weather_service.py` - -- [ ] **Step 1: Write failing tests** - -Open `tests/test_weather_service.py` and add: - -```python -def test_parse_weather_card_data_returns_none_when_stale(): - """Should return None when cache is older than 24 hours.""" - from datetime import datetime, timezone, timedelta - from unittest.mock import MagicMock - from fabledassistant.services.weather import parse_weather_card_data - - cache = MagicMock() - cache.fetched_at = datetime.now(timezone.utc) - timedelta(hours=25) - cache.forecast_json = {} - assert parse_weather_card_data(cache) is None - - -def test_parse_weather_card_data_returns_card_schema(): - """Should return the correct metadata.weather schema for fresh cache.""" - from datetime import datetime, timezone, timedelta, date - from unittest.mock import MagicMock - from fabledassistant.services.weather import parse_weather_card_data - - today = date.today().isoformat() - yesterday = (date.today() - timedelta(days=1)).isoformat() - tomorrow = (date.today() + timedelta(days=1)).isoformat() - - cache = MagicMock() - cache.fetched_at = datetime.now(timezone.utc) - cache.location_label = "Berlin, DE" - cache.forecast_json = { - "current_weather": {"temperature": 12.0, "weathercode": 2}, - "daily": { - "time": [yesterday, today, tomorrow], - "temperature_2m_max": [14.0, 16.0, 18.0], - "temperature_2m_min": [9.0, 8.0, 10.0], - "precipitation_sum": [0.0, 0.0, 1.5], - "weathercode": [1, 2, 61], - "windspeed_10m_max": [10.0, 12.0, 8.0], - } - } - - card = parse_weather_card_data(cache) - assert card is not None - assert card["current_temp"] == 12 - assert card["condition"] == "Partly cloudy" - assert card["today_high"] == 16 - assert card["today_low"] == 8 - assert card["yesterday_high"] == 14 - assert card["yesterday_low"] == 9 - assert len(card["forecast"]) >= 1 - assert card["location"] == "Berlin, DE" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_weather_service.py -v -k "parse_weather_card" -``` - -Expected: FAIL — `ImportError: cannot import name 'parse_weather_card_data'` - -- [ ] **Step 3: Update `_fetch_open_meteo` to request current weather + past day** - -In `services/weather.py`, update `_fetch_open_meteo`: - -```python -async def _fetch_open_meteo(lat: float, lon: float) -> dict: - """Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data.""" - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.get(OPEN_METEO_URL, params={ - "latitude": lat, - "longitude": lon, - "daily": OPEN_METEO_DAILY, - "current_weather": "true", - "past_days": 1, - "timezone": "auto", - "forecast_days": 7, - }) - resp.raise_for_status() - return resp.json() -``` - -- [ ] **Step 4: Add `parse_weather_card_data()` to `services/weather.py`** - -Add after `detect_changes()`: - -```python -def parse_weather_card_data( - cache_row, - temp_unit: str = "C", -) -> dict | None: - """ - Parse a WeatherCache row into the metadata.weather card schema. - Returns None if the cache is stale (older than 24 hours). - """ - from datetime import date, timedelta - - if cache_row is None or cache_row.fetched_at is None: - return None - - age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds() - if age_seconds > 86400: - return None - - raw = cache_row.forecast_json or {} - current_weather = raw.get("current_weather", {}) - days = parse_forecast(raw) - - today_str = date.today().isoformat() - yesterday_str = (date.today() - timedelta(days=1)).isoformat() - - today_day = next((d for d in days if d["date"] == today_str), None) - yesterday_day = next((d for d in days if d["date"] == yesterday_str), None) - future_days = [d for d in days if d["date"] > today_str][:5] - - def to_temp(c: float) -> int: - if temp_unit == "F": - return round(c * 9 / 5 + 32) - return round(c) - - def day_label(date_str: str) -> str: - from datetime import date as _date - try: - return _date.fromisoformat(date_str).strftime("%a") - except ValueError: - return date_str - - return { - "location": getattr(cache_row, "location_label", ""), - "fetched_at": cache_row.fetched_at.isoformat(), - "current_temp": to_temp(current_weather.get("temperature", 0)), - "condition": describe_weathercode(current_weather.get("weathercode", 0)), - "today_high": to_temp(today_day["temp_max"]) if today_day else None, - "today_low": to_temp(today_day["temp_min"]) if today_day else None, - "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None, - "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None, - "forecast": [ - { - "day": day_label(d["date"]), - "condition": d["description"], - "high": to_temp(d["temp_max"]), - "low": to_temp(d["temp_min"]), - } - for d in future_days - ], - } -``` - -- [ ] **Step 5: Run tests** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_weather_service.py -v -k "parse_weather_card" -``` - -Expected: both PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/fabledassistant/services/weather.py tests/test_weather_service.py -git commit -m "feat(briefing): add past_days/current_weather to Open-Meteo fetch; add parse_weather_card_data()" -``` - ---- - -## Task 5: RSS Classifier Service - -**Files:** -- Create: `src/fabledassistant/services/rss_classifier.py` -- Test: `tests/test_rss_service.py` - -- [ ] **Step 1: Write failing test** - -In `tests/test_rss_service.py`, add: - -```python -@pytest.mark.asyncio -async def test_classify_items_batch_returns_topic_map(): - """classify_items_batch should return a dict mapping item_id to topic list.""" - from unittest.mock import AsyncMock, patch - - fake_response = '{"1": ["technology", "ai"], "2": ["politics"]}' - - with patch( - "fabledassistant.services.rss_classifier._llm_classify", - new_callable=AsyncMock, - return_value=fake_response, - ): - from fabledassistant.services import rss_classifier - import importlib; importlib.reload(rss_classifier) - - items = [ - {"id": 1, "title": "OpenAI releases GPT-5", "content": "..."}, - {"id": 2, "title": "EU passes new law", "content": "..."}, - ] - result = await rss_classifier.classify_items_batch(items, user_include_topics=[]) - assert result[1] == ["technology", "ai"] - assert result[2] == ["politics"] - - -@pytest.mark.asyncio -async def test_classify_items_batch_handles_llm_failure(): - """classify_items_batch should return empty lists on LLM error.""" - from unittest.mock import AsyncMock, patch - - with patch( - "fabledassistant.services.rss_classifier._llm_classify", - new_callable=AsyncMock, - side_effect=Exception("LLM unavailable"), - ): - from fabledassistant.services import rss_classifier - import importlib; importlib.reload(rss_classifier) - - items = [{"id": 5, "title": "Some news", "content": ""}] - result = await rss_classifier.classify_items_batch(items, user_include_topics=[]) - assert result == {} # Empty on failure — items stay unclassified -``` - -- [ ] **Step 2: Run tests to confirm FAIL** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "classify" -``` - -Expected: FAIL — module not found. - -- [ ] **Step 3: Create `services/rss_classifier.py`** - -```python -""" -RSS item topic classifier. - -Classifies RSS items into topic tags using a fast non-streaming LLM call. -Called from rss.py after new items are stored — fire-and-forget. -""" - -import json -import logging -from datetime import datetime, timezone - -import httpx - -from fabledassistant.config import Config - -logger = logging.getLogger(__name__) - -STANDARD_TOPICS = [ - "technology", "science", "politics", "business", - "health", "environment", "local", "entertainment", "sports", "other", -] - -_CLASSIFY_PROMPT = """\ -Classify each news item into 1-3 topics. Use only topics from this list: {vocab}. -Return ONLY a JSON object mapping item_id (as string) to a list of topics. -Example: {{"1": ["technology", "ai"], "2": ["politics"]}} - -Items: -{items_block}""" - - -async def _llm_classify(prompt: str, model: str) -> str: - """Make a fast non-streaming LLM call and return the raw text response.""" - payload = { - "model": model, - "messages": [{"role": "user", "content": prompt}], - "stream": False, - "options": {"num_ctx": 2048, "temperature": 0.0}, - } - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload) - resp.raise_for_status() - return resp.json().get("message", {}).get("content", "") - - -async def classify_items_batch( - items: list[dict], - user_include_topics: list[str], - model: str | None = None, -) -> dict[int, list[str]]: - """ - Classify a batch of RSS items into topic tags. - - Args: - items: list of dicts with 'id', 'title', 'content' - user_include_topics: extra topics from user preferences to add to vocabulary - model: Ollama model name; defaults to Config.OLLAMA_MODEL - - Returns: - dict mapping item_id (int) -> list of topic strings. - Items not returned had classification fail; callers should leave classified_at=NULL. - """ - if not items: - return {} - - if model is None: - model = Config.OLLAMA_MODEL - - vocab = STANDARD_TOPICS + [t for t in user_include_topics if t not in STANDARD_TOPICS] - items_block = "\n".join( - f"[{item['id']}] {item['title']} — {item.get('content', '')[:300]}" - for item in items - ) - prompt = _CLASSIFY_PROMPT.format(vocab=", ".join(vocab), items_block=items_block) - - try: - raw = await _llm_classify(prompt, model) - # Extract JSON from response (LLM may wrap it in markdown) - raw = raw.strip() - if raw.startswith("```"): - raw = raw.split("```")[1] - if raw.startswith("json"): - raw = raw[4:] - parsed = json.loads(raw) - return {int(k): v for k, v in parsed.items() if isinstance(v, list)} - except Exception: - logger.warning("RSS classification failed", exc_info=True) - return {} - - -async def classify_and_store( - item_ids: list[int], - user_id: int, -) -> None: - """ - Classify unclassified RSS items and write results to DB. - Called as a fire-and-forget task from rss.py. - """ - from sqlalchemy import select - from fabledassistant.models import async_session - from fabledassistant.models.rss_feed import RssItem - from fabledassistant.services.settings import get_setting - - if not item_ids: - return - - # Load the items - async with async_session() as session: - result = await session.execute( - select(RssItem).where(RssItem.id.in_(item_ids)) - ) - items = list(result.scalars().all()) - - if not items: - return - - # Get user's include topics to extend vocabulary - raw_include = await get_setting(user_id, "briefing_include_topics", "[]") - try: - include_topics = json.loads(raw_include) if isinstance(raw_include, str) else [] - except Exception: - include_topics = [] - - model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) - - # Classify in batches of 10 - batch_size = 10 - all_results: dict[int, list[str]] = {} - for i in range(0, len(items), batch_size): - batch = items[i: i + batch_size] - batch_dicts = [{"id": it.id, "title": it.title, "content": it.content} for it in batch] - results = await classify_items_batch(batch_dicts, include_topics, model=model) - all_results.update(results) - - # Write back to DB - now = datetime.now(timezone.utc) - async with async_session() as session: - for item in items: - item_db = await session.get(RssItem, item.id) - if item_db is None: - continue - topics = all_results.get(item.id) - if topics is not None: - item_db.topics = topics - item_db.classified_at = now - await session.commit() -``` - -- [ ] **Step 4: Run tests** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "classify" -``` - -Expected: both PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/fabledassistant/services/rss_classifier.py tests/test_rss_service.py -git commit -m "feat(briefing): add rss_classifier service for LLM-based topic tagging" -``` - ---- - -## Task 6: Briefing Preferences Service - -> **Dependency:** Task 2 Step 2 must be complete before this task — `score_and_filter_items` expects items to have a `topics` key in their dicts, which comes from the `RssItem.topics` column added in Task 2. Run Task 2 first. - -**Files:** -- Create: `src/fabledassistant/services/briefing_preferences.py` -- Test: `tests/test_rss_service.py` - -- [ ] **Step 1: Write failing tests** - -In `tests/test_rss_service.py`, add: - -```python -def test_score_rss_items_excludes_blacklisted_topics(): - """Items with excluded topics should be removed.""" - from fabledassistant.services.briefing_preferences import score_and_filter_items - - items = [ - {"id": 1, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T08:00:00"}, - {"id": 2, "title": "Sports score", "topics": ["sports"], "published_at": "2026-03-25T08:00:00"}, - ] - result = score_and_filter_items( - items, - include_topics=["technology"], - exclude_topics=["sports"], - topic_scores={}, - max_items=10, - ) - ids = [r["id"] for r in result] - assert 1 in ids - assert 2 not in ids - - -def test_score_rss_items_boosts_included_topics(): - """Items matching include_topics should rank higher than neutral items.""" - from fabledassistant.services.briefing_preferences import score_and_filter_items - - items = [ - {"id": 1, "title": "Random news", "topics": ["other"], "published_at": "2026-03-25T07:00:00"}, - {"id": 2, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T06:00:00"}, - ] - result = score_and_filter_items( - items, - include_topics=["technology"], - exclude_topics=[], - topic_scores={}, - max_items=10, - ) - # Tech item should be ranked first despite being older - assert result[0]["id"] == 2 - - -def test_score_rss_items_no_preferences_returns_all(): - """With no preferences, all items should be returned sorted by recency.""" - from fabledassistant.services.briefing_preferences import score_and_filter_items - - items = [ - {"id": 1, "title": "A", "topics": [], "published_at": "2026-03-24T10:00:00"}, - {"id": 2, "title": "B", "topics": [], "published_at": "2026-03-25T10:00:00"}, - ] - result = score_and_filter_items(items, [], [], {}, max_items=10) - assert result[0]["id"] == 2 # Newer first -``` - -- [ ] **Step 2: Run tests to confirm FAIL** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "score_rss" -``` - -Expected: FAIL — module not found. - -- [ ] **Step 3: Create `services/briefing_preferences.py`** - -```python -""" -Briefing preferences: load topic settings, aggregate reaction scores, -filter and rank RSS items for briefing inclusion. -""" - -import json -import logging -from datetime import datetime, timezone - -from sqlalchemy import func, select - -from fabledassistant.models import async_session - -logger = logging.getLogger(__name__) - - -async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]: - """ - Return (include_topics, exclude_topics) from user settings. - """ - from fabledassistant.services.settings import get_setting - - raw_include = await get_setting(user_id, "briefing_include_topics", "[]") - raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]") - - def _parse(raw) -> list[str]: - try: - val = json.loads(raw) if isinstance(raw, str) else raw - return [str(t) for t in val] if isinstance(val, list) else [] - except Exception: - return [] - - return _parse(raw_include), _parse(raw_exclude) - - -async def load_topic_reaction_scores(user_id: int) -> dict[str, float]: - """ - Aggregate per-topic reaction scores from the last 30 days. - Returns a dict of topic -> net_score (positive = liked, negative = disliked). - Uses rss_item_reactions joined to rss_items.topics. - """ - from fabledassistant.models.rss_feed import RssItem - - try: - async with async_session() as session: - # Raw SQL is simpler here due to ARRAY unnest - result = await session.execute( - __import__("sqlalchemy", fromlist=["text"]).text(""" - SELECT unnest(i.topics) AS topic, - SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score - FROM rss_item_reactions r - JOIN rss_items i ON i.id = r.rss_item_id - WHERE r.user_id = :uid - AND r.created_at > NOW() - INTERVAL '30 days' - GROUP BY topic - """).bindparams(uid=user_id) - ) - return {row.topic: float(row.score) for row in result} - except Exception: - logger.warning("Failed to load topic reaction scores", exc_info=True) - return {} - - -def score_and_filter_items( - items: list[dict], - include_topics: list[str], - exclude_topics: list[str], - topic_scores: dict[str, float], - max_items: int = 10, -) -> list[dict]: - """ - Score, filter, and rank RSS items for briefing inclusion. - - Scoring: - - Hard-exclude: any item tagged with an excluded topic is removed. - - Base score: 0.0 - - +2.0 per topic that appears in include_topics - - +1.0 / -1.0 per topic based on reaction score (clamped per topic) - - Tiebreak: newer published_at wins - - Returns up to max_items items, highest score first. - Items with classified_at=None (unclassified) pass through with score=0. - """ - include_set = set(include_topics) - exclude_set = set(exclude_topics) - scored = [] - - for item in items: - item_topics = item.get("topics") or [] - - # Hard exclude - if exclude_set and any(t in exclude_set for t in item_topics): - continue - - score = 0.0 - for topic in item_topics: - if topic in include_set: - score += 2.0 - if topic in topic_scores: - score += max(-1.0, min(1.0, topic_scores[topic])) - - # Parse published_at for tiebreak - pub_str = item.get("published_at") or "" - try: - pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0 - except ValueError: - pub_ts = 0.0 - - scored.append((score, pub_ts, item)) - - # Sort: highest score first, then newest first - scored.sort(key=lambda x: (x[0], x[1]), reverse=True) - return [item for _, _, item in scored[:max_items]] -``` - -- [ ] **Step 4: Run tests** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "score_rss" -``` - -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/fabledassistant/services/briefing_preferences.py tests/test_rss_service.py -git commit -m "feat(briefing): add briefing_preferences service for RSS scoring and filtering" -``` - ---- - -## Task 7: Task Change Detection - -**Files:** -- Modify: `src/fabledassistant/services/briefing_pipeline.py` -- Test: `tests/test_briefing_pipeline.py` - -- [ ] **Step 1: Write failing tests** - -In `tests/test_briefing_pipeline.py`, add: - -```python -def test_compute_task_snapshot_hash(): - """compute_task_hash should return a stable SHA-256 hex string.""" - from fabledassistant.services.briefing_pipeline import compute_task_hash - task = {"status": "todo", "priority": "high", "due_date": "2026-03-25", "title": "Write spec"} - h = compute_task_hash(task) - assert len(h) == 64 # SHA-256 hex - # Same inputs produce same hash - assert h == compute_task_hash(task) - # Different status produces different hash - assert h != compute_task_hash({**task, "status": "done"}) - - -@pytest.mark.asyncio -async def test_split_changed_tasks_all_new(): - """split_changed_tasks should return all tasks as changed when no snapshot exists.""" - from unittest.mock import AsyncMock, patch, MagicMock - from fabledassistant.services.briefing_pipeline import split_changed_tasks - - tasks = [ - {"task_id": 1, "title": "A", "status": "todo", "priority": "none", "due_date": None}, - ] - - with patch( - "fabledassistant.services.briefing_pipeline.async_session" - ) as mock_cls: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.execute = AsyncMock(return_value=MagicMock( - scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))) - )) - mock_cls.return_value = mock_session - - changed, unchanged_count = await split_changed_tasks(user_id=1, tasks=tasks) - assert len(changed) == 1 - assert unchanged_count == 0 -``` - -- [ ] **Step 2: Run tests to verify FAIL** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py -v -k "task_snapshot or task_hash or split_changed" -``` - -Expected: FAIL. - -- [ ] **Step 3: Add helpers to `briefing_pipeline.py`** - -Add these imports at the top of `briefing_pipeline.py`: -```python -import hashlib -from datetime import datetime, timezone -``` - -Add these functions after `format_task()`: - -```python -def compute_task_hash(task: dict) -> str: - """Stable SHA-256 of the task's key change-detectable fields.""" - key = "|".join([ - str(task.get("status") or ""), - str(task.get("priority") or ""), - str(task.get("due_date") or ""), - str(task.get("title") or ""), - ]) - return hashlib.sha256(key.encode()).hexdigest() - - -async def split_changed_tasks( - user_id: int, - tasks: list[dict], -) -> tuple[list[dict], int]: - """ - Compare tasks against the briefing_task_snapshot table. - Returns (changed_tasks, unchanged_count). - changed_tasks includes new tasks (no snapshot row) and tasks whose hash differs. - """ - from sqlalchemy import select, text - from fabledassistant.models import async_session - - if not tasks: - return [], 0 - - task_ids = [t["task_id"] for t in tasks if t.get("task_id")] - - async with async_session() as session: - result = await session.execute( - text(""" - SELECT task_id, snapshot_hash - FROM briefing_task_snapshot - WHERE user_id = :uid AND task_id = ANY(:ids) - """).bindparams(uid=user_id, ids=task_ids) - ) - snapshots = {row.task_id: row.snapshot_hash for row in result} - - changed = [] - unchanged_count = 0 - for task in tasks: - current_hash = compute_task_hash(task) - stored_hash = snapshots.get(task.get("task_id")) - if stored_hash is None or stored_hash != current_hash: - changed.append(task) - else: - unchanged_count += 1 - - return changed, unchanged_count - - -async def upsert_task_snapshots(user_id: int, tasks: list[dict]) -> None: - """Upsert snapshot hashes for all tasks included in this briefing.""" - from sqlalchemy import text - from fabledassistant.models import async_session - - if not tasks: - return - - now = datetime.now(timezone.utc) - async with async_session() as session: - for task in tasks: - task_id = task.get("task_id") - if not task_id: - continue - await session.execute( - text(""" - INSERT INTO briefing_task_snapshot (user_id, task_id, snapshot_hash, last_briefed) - VALUES (:uid, :tid, :hash, :now) - ON CONFLICT (user_id, task_id) - DO UPDATE SET snapshot_hash = EXCLUDED.snapshot_hash, - last_briefed = EXCLUDED.last_briefed - """).bindparams( - uid=user_id, - tid=task_id, - hash=compute_task_hash(task), - now=now, - ) - ) - await session.commit() -``` - -- [ ] **Step 4: Update `_gather_internal` to include `task_id`** - -In `_gather_internal`, change the task serialisation from: - -```python -all_tasks = [ - { - "title": t.title, - "status": t.status, - "due_date": t.due_date.isoformat() if t.due_date else None, - "priority": t.priority, - } - for t in all_task_objs -] -``` - -To: - -```python -all_tasks = [ - { - "task_id": t.id, - "title": t.title, - "status": t.status, - "due_date": t.due_date.isoformat() if t.due_date else None, - "priority": t.priority, - } - for t in all_task_objs -] -``` - -Also add `"all_tasks_raw": all_tasks` to the dict that `_gather_internal` returns, alongside the existing keys (`overdue_tasks`, `due_today`, etc.). This is the key `run_compilation` will use for snapshot diffing. - -- [ ] **Step 5: Run tests** - -```bash -docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py -v -k "task_snapshot or task_hash or split_changed" -``` - -Expected: all PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/fabledassistant/services/briefing_pipeline.py tests/test_briefing_pipeline.py -git commit -m "feat(briefing): add task change detection helpers and task_id to _gather_internal" -``` - ---- - -## Task 8: Wire Pre-processing into `run_compilation` - -**Files:** -- Modify: `src/fabledassistant/services/briefing_pipeline.py` -- Modify: `src/fabledassistant/routes/briefing.py` -- Modify: `src/fabledassistant/services/briefing_scheduler.py` (read the file first to find all `run_compilation` calls) - -- [ ] **Step 1: Read the briefing scheduler to find callers** - -```bash -docker compose exec app grep -n "run_compilation\|post_message" src/fabledassistant/services/briefing_scheduler.py -``` - -Note every line number that calls `run_compilation` or `post_message` — you must update all of them in step 4. - -- [ ] **Step 2: Update `_external_system_prompt` and `_external_user_prompt` for news cards** - -In `_external_system_prompt()`, replace the current string with: - -```python -def _external_system_prompt() -> str: - return ( - "You are a briefing assistant for external information. Your job is to present " - "selected news items and summarise any remaining RSS content. " - "IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n" - "Format each news item EXACTLY as:\n" - "**[Headline text](source_url)**\n" - "*Outlet Name · Day Month*\n" - "One or two sentence summary.\n\n" - "Present news items in the EXACT ORDER they are provided. Do not reorder them. " - "After the news cards, add a brief paragraph for any remaining context." - ) -``` - -- [ ] **Step 3: Rewrite `run_compilation` to add pre-processing and return `(text, metadata)`** - -Replace the existing `run_compilation` function entirely: - -```python -async def run_compilation( - user_id: int, - slot: str, - model: str | None = None, -) -> tuple[str, dict]: - """ - Run the full two-lane briefing pipeline for a user and slot. - Returns (briefing_text, metadata_dict) where metadata contains - weather card data and rss_item_ids for frontend rendering. - """ - if model is None: - model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) - - from fabledassistant.services.briefing_profile import get_profile_body - from fabledassistant.services.briefing_preferences import ( - load_topic_preferences, - load_topic_reaction_scores, - score_and_filter_items, - ) - from fabledassistant.services.weather import parse_weather_card_data - - profile_body, temp_unit = await asyncio.gather( - get_profile_body(user_id), - _get_temp_unit(user_id), - ) - - # ── Pre-processing ────────────────────────────────────────────────────── - include_topics, exclude_topics = await load_topic_preferences(user_id) - topic_scores = await load_topic_reaction_scores(user_id) - - from fabledassistant.services.weather import get_cached_weather_rows - - # Parallel raw gather — include weather rows in the same gather to avoid a second DB round-trip - internal_data, external_data, weather_rows = await asyncio.gather( - _gather_internal(user_id), - _gather_external(user_id), - get_cached_weather_rows(user_id), - ) - - # Task change detection — uses the raw task dicts added in Task 7 - all_tasks = internal_data.get("all_tasks_raw", []) - changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks) - - # RSS filtering - raw_rss = external_data.get("rss_items") or [] - filtered_rss = score_and_filter_items( - raw_rss, - include_topics=include_topics, - exclude_topics=exclude_topics, - topic_scores=topic_scores, - max_items=10, - ) - rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")] - - # Weather staleness gate — parse_weather_card_data returns None if data is >24h old - weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None - - # ── LLM Synthesis ────────────────────────────────────────────────────── - # Rebuild internal_data with changed tasks only - internal_data_filtered = dict(internal_data) - internal_data_filtered["unchanged_task_count"] = unchanged_count - # Replace task lists with only changed tasks (formatted) - today = internal_data["date"] - changed_formatted = [format_task(t) for t in changed_tasks] - internal_data_filtered["overdue_tasks"] = [ - f for f in changed_formatted - if any(t.get("due_date") and t["due_date"] < today and t.get("status") != "done" - for t in changed_tasks if format_task(t) == f) - ] - # Simplified: pass all changed tasks, let the LLM sort by urgency - internal_data_filtered["changed_tasks"] = changed_formatted - - # Build filtered external data (no weather — card handles it) - external_data_filtered = { - "rss_items": filtered_rss, - "weather": [], # Suppressed — handled by WeatherCard - } - - internal_text, external_text = await asyncio.gather( - _llm_synthesise( - _internal_system_prompt(profile_body), - _internal_user_prompt(internal_data_filtered, slot), - model, - ), - _llm_synthesise( - _external_system_prompt(), - _external_user_prompt(external_data_filtered, slot, temp_unit), - model, - ), - ) - - # ── Post-processing ──────────────────────────────────────────────────── - # Upsert task snapshots so next run can diff - await upsert_task_snapshots(user_id, all_tasks) - - # Build metadata - metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card} - - # Build output text - if not internal_text and not external_text: - logger.warning( - "Briefing compilation produced no content for user %d slot %s", user_id, slot - ) - return "", metadata - - greeting = slot_greeting(slot) - parts = [f"**{greeting} — {today}**", ""] - if internal_text: - parts += ["## Your Day", "", internal_text, ""] - if external_text: - parts += ["## The World", "", external_text] - - return "\n".join(parts).strip(), metadata -``` - -> **Note:** `_gather_internal` needs to also return the raw task objects (with `task_id`) for snapshot diffing. See step below. - -- [ ] **Step 4: Add `get_cached_weather_rows()` to `services/weather.py`** - -The new pipeline needs the raw `WeatherCache` ORM rows (not the processed dicts) so it can call `parse_weather_card_data()`. Add this function to `weather.py`: - -```python -async def get_cached_weather_rows(user_id: int) -> list: - """Return raw WeatherCache ORM rows for a user (for card parsing).""" - async with async_session() as session: - result = await session.execute( - select(WeatherCache).where(WeatherCache.user_id == user_id) - ) - return list(result.scalars().all()) -``` - -- [ ] **Step 5: Update `_internal_user_prompt` to handle changed tasks** - -The prompt currently references `overdue_tasks`, `due_today`, `high_priority`. Add an `unchanged_task_count` line: - -```python -def _internal_user_prompt(data: dict, slot: str) -> str: - lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""] - if data.get("unchanged_task_count", 0) > 0: - lines.append( - f"({data['unchanged_task_count']} tasks are unchanged since the last briefing " - "— acknowledge briefly, do not list them.)" - ) - lines.append("") - # ... rest unchanged -``` - -- [ ] **Step 6: Update `routes/briefing.py` trigger endpoint** - -In `manual_trigger()`, unpack the tuple: - -```python -text, metadata = await run_compilation(g.user.id, slot, model) -msg = await post_message(conv.id, "assistant", text, metadata=metadata) -``` - -- [ ] **Step 7: Update `services/briefing_scheduler.py`** - -Find all calls to `run_compilation` (from step 1) and unpack the returned tuple, passing `metadata` to `post_message`. The pattern will be the same as step 6. - -- [ ] **Step 8: Run the full test suite** - -```bash -make test -``` - -Expected: all existing tests still PASS (no regressions). - -- [ ] **Step 9: Commit** - -```bash -git add src/fabledassistant/services/briefing_pipeline.py \ - src/fabledassistant/services/weather.py \ - src/fabledassistant/routes/briefing.py \ - src/fabledassistant/services/briefing_scheduler.py -git commit -m "feat(briefing): wire pre-processing pipeline; run_compilation now returns (text, metadata)" -``` - ---- - -## Task 9: Trigger RSS Classification from `rss.py` - -**Files:** -- Modify: `src/fabledassistant/services/rss.py` - -- [ ] **Step 1: Find the `fetch_and_cache_feed` function and add classification trigger** - -After `await _prune_old_items(feed_id)` (the last line of `fetch_and_cache_feed`), add: - -```python - # Queue classification for newly stored items if any were added - if new_count > 0 and new_item_ids: - import asyncio as _asyncio - from fabledassistant.services.rss_classifier import classify_and_store - # Fire-and-forget — don't await, don't block feed fetch - _asyncio.create_task(classify_and_store(new_item_ids, _feed_user_id)) - - return new_count -``` - -To make this work, you need to: -1. Collect `new_item_ids` during the loop (after `session.commit()`, refresh the items to get their DB-assigned IDs, or collect IDs inline) -2. Expose `_feed_user_id` by fetching it from the `RssFeed` row - -Update `fetch_and_cache_feed` to capture new item IDs and the feed's `user_id`: - -```python -async def fetch_and_cache_feed(feed_id: int, url: str) -> int: - # ... existing fetch/parse code ... - new_count = 0 - new_item_ids: list[int] = [] - feed_user_id: int | None = None - - async with async_session() as session: - for entry in parsed.entries: - # ... existing upsert code ... - item = RssItem(feed_id=feed_id, **item_data) - session.add(item) - new_count += 1 - - feed_row = await session.get(RssFeed, feed_id) - if feed_row: - feed_row.last_fetched_at = datetime.now(timezone.utc) - feed_user_id = feed_row.user_id - if not feed_row.title and parsed.feed.get("title"): - feed_row.title = parsed.feed.title[:200] - - await session.commit() - - # Collect IDs of newly inserted items after commit. - # We query classified_at IS NULL (not just the items inserted above) because - # classification is best-effort and may have failed on previous fetches. - # Re-queuing all unclassified items for this feed on each fetch is intentional: - # it provides automatic retry without a separate retry loop. The classifier - # only writes to items it successfully classifies, so already-classified items - # are not re-processed (they have classified_at set). - if new_count > 0: - result = await session.execute( - select(RssItem.id).where( - RssItem.feed_id == feed_id, - RssItem.classified_at.is_(None), - ) - ) - new_item_ids = list(result.scalars().all()) - - await _prune_old_items(feed_id) - - if new_count > 0 and new_item_ids and feed_user_id is not None: - import asyncio as _asyncio - from fabledassistant.services.rss_classifier import classify_and_store - _asyncio.create_task(classify_and_store(new_item_ids, feed_user_id)) - - return new_count -``` - -- [ ] **Step 2: Run the test suite** - -```bash -make test -``` - -Expected: all PASS (no regressions — classification is fire-and-forget so existing tests are unaffected). - -- [ ] **Step 3: Commit** - -```bash -git add src/fabledassistant/services/rss.py -git commit -m "feat(briefing): trigger RSS classification after new items are stored" -``` - ---- - -## Task 10: Reaction Endpoints - -**Files:** -- Modify: `src/fabledassistant/routes/briefing.py` - -- [ ] **Step 1: Add reaction endpoints at the bottom of `routes/briefing.py`** - -```python -# ── RSS Reactions ───────────────────────────────────────────────────────────── - -@briefing_bp.route("/rss-reactions", methods=["POST"]) -@_REQUIRE -async def upsert_rss_reaction(): - """Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips.""" - data = await request.get_json() - rss_item_id = data.get("rss_item_id") - reaction = data.get("reaction") - - if not rss_item_id or reaction not in ("up", "down"): - return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400 - - from sqlalchemy import text as _text - - async with async_session() as session: - # Ownership check: verify item belongs to a feed owned by this user - result = await session.execute( - _text(""" - SELECT i.id FROM rss_items i - JOIN rss_feeds f ON f.id = i.feed_id - WHERE i.id = :item_id AND f.user_id = :uid - """).bindparams(item_id=rss_item_id, uid=g.user.id) - ) - if result.first() is None: - return jsonify({"error": "Not found"}), 404 - - # Check existing reaction - existing = await session.execute( - _text(""" - SELECT id, reaction FROM rss_item_reactions - WHERE user_id = :uid AND rss_item_id = :item_id - """).bindparams(uid=g.user.id, item_id=rss_item_id) - ) - row = existing.first() - - if row is None: - # Insert - await session.execute( - _text(""" - INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction) - VALUES (:uid, :item_id, :reaction) - """).bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction) - ) - action = "created" - elif row.reaction == reaction: - # Toggle off - await session.execute( - _text(""" - DELETE FROM rss_item_reactions - WHERE user_id = :uid AND rss_item_id = :item_id - """).bindparams(uid=g.user.id, item_id=rss_item_id) - ) - action = "removed" - else: - # Flip - await session.execute( - _text(""" - UPDATE rss_item_reactions SET reaction = :reaction - WHERE user_id = :uid AND rss_item_id = :item_id - """).bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id) - ) - action = "updated" - - await session.commit() - - return jsonify({"ok": True, "action": action}) - - -@briefing_bp.route("/rss-reactions/", methods=["DELETE"]) -@_REQUIRE -async def delete_rss_reaction(item_id: int): - """Explicitly remove a reaction (useful for MCP/external API callers).""" - from sqlalchemy import text as _text - - async with async_session() as session: - await session.execute( - _text(""" - DELETE FROM rss_item_reactions - WHERE user_id = :uid AND rss_item_id = :item_id - """).bindparams(uid=g.user.id, item_id=item_id) - ) - await session.commit() - - return jsonify({"ok": True}) -``` - -- [ ] **Step 2: Run the test suite** - -```bash -make test -``` - -Expected: all PASS. - -- [ ] **Step 3: Commit** - -```bash -git add src/fabledassistant/routes/briefing.py -git commit -m "feat(briefing): add POST/DELETE /api/briefing/rss-reactions endpoints" -``` - ---- - -## Task 11: Frontend API Helpers - -**Files:** -- Modify: `frontend/src/api/client.ts` - -- [ ] **Step 1: Add the two new API helpers to `client.ts`** - -Find the section at the end of `client.ts` where other helpers are defined and add: - -```typescript -export async function postRssReaction( - rssItemId: number, - reaction: 'up' | 'down' -): Promise<{ ok: boolean; action: string }> { - return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction }); -} - -export async function deleteRssReaction(rssItemId: number): Promise<{ ok: boolean }> { - return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`); -} -``` - -- [ ] **Step 2: Run TypeScript check** - -```bash -make typecheck -``` - -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add frontend/src/api/client.ts -git commit -m "feat(briefing): add postRssReaction and deleteRssReaction API helpers" -``` - ---- - -## Task 12: WeatherCard.vue - -**Files:** -- Create: `frontend/src/components/WeatherCard.vue` - -- [ ] **Step 1: Create the component** - -```vue - - - - - -``` - -- [ ] **Step 2: TypeScript check** - -```bash -make typecheck -``` - -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add frontend/src/components/WeatherCard.vue -git commit -m "feat(briefing): add WeatherCard.vue component" -``` - ---- - -## Task 13: BriefingView.vue — Metadata Integration - -**Files:** -- Modify: `frontend/src/views/BriefingView.vue` - -- [ ] **Step 1: Read the current `BriefingView.vue` to understand its structure** - -```bash -docker compose exec app cat frontend/src/views/BriefingView.vue | head -100 -``` - -Or use the Read tool. Understand: how messages are loaded, how they are rendered, where to insert the WeatherCard. - -- [ ] **Step 2: Add WeatherCard import and type definitions** - -At the top of ` - - - - -``` - -- [ ] **Step 2: TypeScript check** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend -npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" -``` -Expected: no new errors - -- [ ] **Step 3: Commit** - -```bash -git add frontend/src/views/BriefingView.vue -git commit -m "feat: redesign briefing view as 3-column layout with weather left, news right" -``` - ---- - -### Task 6: News archive view (`/news`) - -**Files:** -- Create: `frontend/src/views/NewsView.vue` -- Modify: `frontend/src/router/index.ts` -- Modify: `frontend/src/components/AppHeader.vue` - -- [ ] **Step 1: Create `frontend/src/views/NewsView.vue`** - -```vue - - - - - -``` - -- [ ] **Step 2: Add `/news` route to `frontend/src/router/index.ts`** - -Add after the `/briefing` route (around line 122): - -```typescript - { - path: '/news', - name: 'news', - component: () => import('@/views/NewsView.vue'), - }, -``` - -- [ ] **Step 3: Add News nav link to `frontend/src/components/AppHeader.vue`** - -In the `.nav-center` div (around line 74), add after the Briefing link: - -```html -News -``` - -In the mobile menu div (around line 131), add after the Briefing link: - -```html -News -``` - -- [ ] **Step 4: TypeScript check** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend -npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" -``` -Expected: no new errors - -- [ ] **Step 5: Run full backend test suite** - -```bash -cd /home/bvandeusen/Nextcloud/Projects/fabledassistant -source .venv/bin/activate -pytest tests/ -v --tb=short 2>&1 | tail -20 -``` -Expected: all tests pass - -- [ ] **Step 6: Commit** - -```bash -git add frontend/src/views/NewsView.vue frontend/src/router/index.ts frontend/src/components/AppHeader.vue -git commit -m "feat: add /news archive view with feed filter, pagination, and reactions" -``` diff --git a/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md b/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md deleted file mode 100644 index be6efa5..0000000 --- a/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md +++ /dev/null @@ -1,398 +0,0 @@ -# News Feed & Briefing View Redesign - -> **For agentic workers:** Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this spec task-by-task. - -**Goal:** Extend RSS article retention to 90 days, introduce a unified news API, redesign the briefing view into a 3-column layout (weather · chat · news), add deep article Q&A in briefing chat, and add a `/news` archive view. - ---- - -## Architecture - -### Data layer -`rss_items` and `rss_item_reactions` already exist and contain everything needed. No new tables or migrations required. The only DB change is extending the prune window from 14 to 90 days. - -### Unified news API -A single `GET /api/briefing/news` endpoint serves both the briefing side panel (short window, no pagination) and the news archive view (full history, paginated). Both consumers share the same response shape and reaction logic. - -### Briefing view -`BriefingView.vue` restructures from a single-column `max-width: 760px` layout to a full-width CSS grid with three independently scrolling columns. Weather and news are fetched directly — no longer read from message metadata. - -### News archive view -A new `NewsView.vue` at `/news`, added to the sidebar nav, uses the same `/api/briefing/news` endpoint with `days=90` and offset-based pagination. - -### Deep article Q&A -`build_context()` in `llm.py` detects briefing conversations and injects the source article content from today's briefing into the system prompt, giving follow-up chat messages access to the full article text. - ---- - -## Section 1 — Data & API layer - -### 1a. Article retention (90 days) - -**File:** `src/fabledassistant/services/rss.py` - -Change the module-level constant: -```python -ITEM_MAX_AGE_DAYS = 90 -``` - -Update the SQL in `_prune_old_items`: -```python -AND published_at < NOW() - INTERVAL '90 days' -``` - -### 1b. Unified news endpoint - -**File:** `src/fabledassistant/routes/briefing.py` - -Add `GET /api/briefing/news` endpoint (registered on `briefing_bp` which has prefix `/api/briefing`): - -```python -@briefing_bp.route("/news", methods=["GET"]) -@_REQUIRE -async def list_news(): - days = min(int(request.args.get("days", 2)), 90) - limit = min(int(request.args.get("limit", 40)), 100) - offset = max(int(request.args.get("offset", 0)), 0) - feed_id = request.args.get("feed_id", type=int) - - from sqlalchemy import text as _text - async with async_session() as session: - result = await session.execute( - _text(""" - SELECT - i.id, i.title, i.url, i.content, i.published_at, - i.topics, f.title AS feed_title, - r.reaction - FROM rss_items i - JOIN rss_feeds f ON f.id = i.feed_id - LEFT JOIN rss_item_reactions r - ON r.rss_item_id = i.id AND r.user_id = :uid - WHERE f.user_id = :uid - AND (:feed_id IS NULL OR f.id = :feed_id) - AND i.published_at >= NOW() - make_interval(days => :days) - ORDER BY i.published_at DESC NULLS LAST - LIMIT :limit OFFSET :offset - """).bindparams(uid=g.user.id, days=days, limit=limit, - offset=offset, feed_id=feed_id) - ) - rows = result.mappings().all() - - items = [ - { - "id": r["id"], - "title": r["title"], - "url": r["url"], - "snippet": (r["content"] or "")[:300], - "published_at": r["published_at"].isoformat() if r["published_at"] else None, - "topics": r["topics"] or [], - "source": r["feed_title"], - "reaction": r["reaction"], - } - for r in rows - ] - return jsonify({"items": items, "offset": offset, "limit": limit}) -``` - ---- - -## Section 2 — Briefing view redesign - -### 2a. Layout restructure - -**File:** `frontend/src/views/BriefingView.vue` - -**Layout change:** Replace `.briefing-shell` (single column, max-width 760px) with a full-width 3-column grid: - -```css -.briefing-shell { - display: grid; - grid-template-rows: auto 1fr; - grid-template-columns: 1fr 2fr 1fr; - height: 100%; - min-height: 0; -} - -.briefing-header { - grid-column: 1 / -1; /* spans all three columns */ -} - -.briefing-left { - grid-column: 1; - border-right: 1px solid var(--color-border); - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.briefing-center { - grid-column: 2; - display: flex; - flex-direction: column; - min-height: 0; -} - -.briefing-right { - grid-column: 3; - border-left: 1px solid var(--color-border); - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -@media (max-width: 900px) { - .briefing-shell { - grid-template-columns: 1fr; - grid-template-rows: auto auto 1fr auto; - } - .briefing-header { grid-column: 1; } - .briefing-left, .briefing-right { - border: none; - border-bottom: 1px solid var(--color-border); - max-height: 200px; - } -} -``` - -### 2b. Weather panel (left column) - -Remove `WeatherCard` from the message flow. Load weather independently on mount: - -Extract the existing weather shape from `MessageMetadata` into a standalone `WeatherData` interface at the top of `BriefingView.vue` (same fields: `location`, `current_temp`, `condition`, `today_high`, `today_low`, `yesterday_high`, `yesterday_low`, `forecast[]`). The `MessageMetadata` interface can then be removed since `rss_items`/weather are no longer read from message metadata. - -```typescript -const weatherData = ref([]) - -async function loadWeather() { - try { - const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather') - weatherData.value = data.locations ?? [] - } catch { /* silent */ } -} -``` - -Render in left column: -```html -
-
Weather
- -
No weather configured
-
-``` - -### 2c. News panel (right column) - -Load on mount and on background refresh: - -```typescript -const newsItems = ref([]) - -async function loadNews() { - try { - const data = await apiGet<{ items: NewsItem[] }>('/api/briefing/news?days=2&limit=40') - newsItems.value = data.items - } catch { /* silent */ } -} -``` - -Render in right column using the existing `news-card` markup and `handleReaction` function. Remove the inline `news-cards` block from the message loop. - -### 2d. Auto-scroll to bottom on mount - -After messages load, scroll the center messages container to the bottom: - -```typescript -const messagesEl = ref(null) - -function scrollToBottom() { - nextTick(() => { - if (messagesEl.value) { - messagesEl.value.scrollTop = messagesEl.value.scrollHeight - } - }) -} -``` - -Call `scrollToBottom()` after `loadAll()` completes and after the streaming watcher fires. - -### 2e. Remove inline weather/news from message flow - -In the message loop template, remove: -- The `` block rendered above assistant messages -- The `
` block rendered below assistant messages - -The `msgMetadata()` helper and `MessageMetadata` interface can be removed entirely since they're no longer used in the template. - ---- - -## Section 3 — Deep article Q&A in briefing chat - -**File:** `src/fabledassistant/services/llm.py` (the `build_context()` function) - -After the existing RAG context is assembled, check if the conversation is a briefing and inject article content: - -```python -# Inject briefing article content for follow-up Q&A -if conversation and getattr(conversation, "conversation_type", None) == "briefing": - article_context = await _build_briefing_article_context(conversation.id) - if article_context: - system_parts.append(article_context) -``` - -New helper function in `llm.py`: - -```python -async def _build_briefing_article_context(conv_id: int) -> str: - """ - Fetch article content from today's briefing message and return - a formatted context block for injection into the system prompt. - Capped at 10 articles × 500 chars to keep token use reasonable. - """ - from sqlalchemy import select, text as _text - from fabledassistant.models import async_session - from fabledassistant.models.conversation import Message - import json - - async with async_session() as session: - # Get most recent assistant briefing message with rss_item_ids - result = await session.execute( - select(Message) - .where( - Message.conversation_id == conv_id, - Message.role == "assistant", - ) - .order_by(Message.created_at.desc()) - .limit(10) - ) - messages = result.scalars().all() - - rss_item_ids: list[int] = [] - for msg in messages: - meta = msg.metadata or {} - if isinstance(meta, str): - try: - meta = json.loads(meta) - except Exception: - continue - ids = meta.get("rss_item_ids") or [] - if ids: - rss_item_ids = ids - break - - if not rss_item_ids: - return "" - - async with async_session() as session: - result = await session.execute( - _text(""" - SELECT i.title, i.url, i.content, f.title AS feed_title - FROM rss_items i - JOIN rss_feeds f ON f.id = i.feed_id - WHERE i.id = ANY(:ids) - ORDER BY i.published_at DESC NULLS LAST - LIMIT 10 - """).bindparams(ids=rss_item_ids[:10]) - ) - rows = result.mappings().all() - - if not rows: - return "" - - lines = ["ARTICLE CONTEXT (source articles from today's briefing):"] - for row in rows: - lines.append(f"\n[{row['feed_title']}] {row['title']}") - if row["url"]: - lines.append(f"URL: {row['url']}") - if row["content"]: - lines.append(row["content"][:500]) - return "\n".join(lines) -``` - ---- - -## Section 4 — News archive view - -### 4a. Frontend type - -**File:** `frontend/src/types/news.ts` (new) - -```typescript -export interface NewsItem { - id: number - title: string - url: string - snippet: string - published_at: string | null - topics: string[] - source: string - reaction: 'up' | 'down' | null -} -``` - -### 4b. NewsView component - -**File:** `frontend/src/views/NewsView.vue` (new) - -- Loads feeds list from `GET /api/briefing/feeds` for the filter dropdown -- Loads items from `GET /api/briefing/news?days=90&limit=40&offset=0&feed_id=X` -- "Load more" button appends next page (increments offset by 40) -- Each card: source label, title as ``, snippet, relative date, 👍/👎 buttons -- Reactions call existing `POST /api/briefing/rss-reactions` / `DELETE /api/briefing/rss-reactions/:id` -- Local `reactions` map tracks optimistic state (same pattern as `BriefingView.vue`) - -```typescript -const items = ref([]) -const offset = ref(0) -const hasMore = ref(true) -const selectedFeedId = ref(null) -const loading = ref(false) - -async function loadMore() { - if (loading.value || !hasMore.value) return - loading.value = true - try { - const params = new URLSearchParams({ - days: '90', limit: '40', offset: String(offset.value) - }) - if (selectedFeedId.value) params.set('feed_id', String(selectedFeedId.value)) - const data = await apiGet<{ items: NewsItem[] }>(`/api/briefing/news?${params}`) - items.value = [...items.value, ...data.items] - offset.value += data.items.length - hasMore.value = data.items.length === 40 - } finally { - loading.value = false - } -} - -function onFeedChange() { - items.value = [] - offset.value = 0 - hasMore.value = true - loadMore() -} -``` - -### 4c. Router and nav - -**File:** `frontend/src/router/index.ts` -```typescript -{ path: '/news', component: () => import('@/views/NewsView.vue') } -``` - -**File:** `frontend/src/App.vue` (nav links section) -Add `News` alongside Notes/Tasks/Projects. - ---- - -## What is NOT in scope - -- Topic-based filtering in the news archive (reactions shape preferences; filtering can come later) -- Inline article display / full-text reader (links open to source) -- Changes to the RSS feed management UI in Settings -- Push notifications for new articles