Files
FabledScribe/docs/plans/2026-03-11-sharing-collaboration.md
T
bvandeusen de4ab53363 Add implementation plans: sharing/collaboration and backup rewrite
- docs/plans/2026-03-11-sharing-collaboration.md
  7 chunks / 12 tasks covering DB migration, models, access control service,
  groups CRUD, sharing service, notifications, and all frontend components

- docs/plans/2026-03-11-backup-rewrite.md
  4 tasks covering v2 export (full + user), v2 restore with FK re-mapping,
  and round-trip verification; v1 restore preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:46:54 -04:00

75 KiB

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

"""Add groups, sharing, notifications tables

Revision ID: 0024_add_sharing_and_notifications
Revises: <previous_revision>
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 = '<find previous revision id>'
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
docker compose exec app alembic heads
  • Step 3: Run migration
docker compose exec app alembic upgrade head

Expected: No errors; 5 new tables present.

  • Step 4: Commit
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

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
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
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:

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
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
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

# 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
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
docker compose exec app python -m pytest tests/test_access.py -v

Expected: All 7 tests PASS.

  • Step 4: Commit
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

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
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("/<int:group_id>", 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("/<int:group_id>", 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("/<int:group_id>", 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("/<int:group_id>/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("/<int:group_id>/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("/<int:group_id>/members/<int:target_uid>", 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("/<int:group_id>/members/<int:target_uid>", 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
from fabledassistant.routes.groups import groups_bp
# in create_app():
app.register_blueprint(groups_bp)
  • Step 4: Test manually
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
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

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

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
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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/shares/<int:share_id>", 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/<int:project_id>/shares/<int:share_id>", 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/<int:note_id>/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/<int:note_id>/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/<int:note_id>/shares/<int:share_id>", 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/<int:note_id>/shares/<int:share_id>", 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
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
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
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

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:

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:

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
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("/<int:notif_id>/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
from fabledassistant.routes.notifications import notifications_bp
app.register_blueprint(notifications_bp)
  • Step 6: Rebuild and verify
docker compose up --build -d
curl -s -b cookies.txt http://localhost:5000/api/notifications/count

Expected: {"count": 0}

  • Step 7: Commit
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

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
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:

@projects_bp.route("/<int:project_id>", 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
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
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

// 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:

<template>
  <Teleport to="body">
    <div class="share-overlay" @click.self="$emit('close')">
      <div class="share-dialog">
        <header class="share-header">
          <h2>Share "{{ resourceTitle }}"</h2>
          <button class="btn-close" @click="$emit('close')"></button>
        </header>

        <!-- Add share form -->
        <div class="share-add">
          <div class="share-add-tabs">
            <button :class="{active: addMode==='user'}" @click="addMode='user'">User</button>
            <button :class="{active: addMode==='group'}" @click="addMode='group'">Group</button>
          </div>

          <!-- User search -->
          <div v-if="addMode==='user'">
            <input v-model="userQuery" placeholder="Search by username or email…" @input="searchDebounced" />
            <ul v-if="userResults.length" class="search-results">
              <li v-for="u in userResults" :key="u.id" @click="selectUser(u)">
                {{ u.username }} <span class="muted">{{ u.email }}</span>
              </li>
            </ul>
            <div v-if="selectedUser" class="selected-target">
              <span>{{ selectedUser.username }}</span>
              <select v-model="newPermission">
                <option value="viewer">Viewer</option>
                <option value="editor">Editor</option>
                <option value="admin">Admin</option>
              </select>
              <button @click="addShare">Add</button>
            </div>
          </div>

          <!-- Group picker -->
          <div v-if="addMode==='group'">
            <select v-model="selectedGroupId">
              <option disabled value="">Select a group</option>
              <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
            </select>
            <select v-model="newPermission">
              <option value="viewer">Viewer</option>
              <option value="editor">Editor</option>
              <option value="admin">Admin</option>
            </select>
            <button @click="addShare" :disabled="!selectedGroupId">Add</button>
          </div>
        </div>

        <!-- Current shares list -->
        <ul class="shares-list">
          <li v-for="share in shares" :key="share.id" class="share-row">
            <span class="share-target">
              <span v-if="share.shared_with_user_id">👤 {{ share.username }}</span>
              <span v-else>👥 {{ share.group_name }}</span>
            </span>
            <select :value="share.permission" @change="changePermission(share, ($event.target as HTMLSelectElement).value)">
              <option value="viewer">Viewer</option>
              <option value="editor">Editor</option>
              <option value="admin">Admin</option>
            </select>
            <button class="btn-remove" @click="removeShare(share)"></button>
          </li>
          <li v-if="!shares.length" class="share-empty">Not shared with anyone yet</li>
        </ul>
      </div>
    </div>
  </Teleport>
</template>

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
cd frontend && npx vue-tsc --noEmit

Expected: No errors.

  • Step 4: Commit
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

// 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<any[]>([])

  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.

<template>
  <div class="notif-bell-wrap" ref="wrapRef">
    <button class="notif-bell" @click="toggle" :aria-label="`Notifications${store.count ? ` (${store.count} unread)` : ''}`">
      <!-- bell SVG -->
      <span v-if="store.count" class="notif-badge">{{ store.count > 9 ? '9+' : store.count }}</span>
    </button>
    <NotificationsPanel v-if="open" @close="open = false" />
  </div>
</template>
  • 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
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

{ 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
<button class="btn-share" @click="shareDialogOpen = true">Share</button>
<ShareDialog v-if="shareDialogOpen"
  resource-type="project" :resource-id="project.id" :resource-title="project.title"
  @close="shareDialogOpen = false" />
  • Step 6: TypeScript typecheck + commit
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

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
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
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
cd frontend && npx vue-tsc --noEmit

Expected: No errors.

  • Step 5: Final commit
git add -A
git commit -m "chore: sharing feature complete — all chunks verified"