From c7ef709633205a72b8d1e74694c8f36b9a682edf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 11 Mar 2026 15:37:00 -0400 Subject: [PATCH] Multi-user sharing, groups, and in-app notifications Backend: - Alembic migration 0025: groups, group_memberships, project_shares, note_shares, notifications tables - models: Group, GroupMembership, ProjectShare, NoteShare, Notification - services/access.py: permission resolution (viewer/editor/admin/owner) - services/groups.py + routes/groups.py: full group CRUD + membership - services/sharing.py + routes/shares.py: project/note sharing API - services/notifications.py: in-app notification create/list/mark-read - routes/in_app_notifications.py: GET/POST notification endpoints - routes/users.py: user search endpoint - services/projects.py + services/notes.py: *_for_user variants - routes updated to use *_for_user on get/list; adds permission field - app.py: register all new blueprints Frontend: - api/client.ts: ShareEntry, GroupEntry, NotificationEntry types + helpers - stores/notifications.ts: Pinia store with polling - NotificationBell.vue: bell icon + badge + dropdown toggle + 60s poll - NotificationsPanel.vue: unread notification list with mark-all-read - ShareDialog.vue: teleport modal for sharing projects/notes with users/groups - SharedWithMeView.vue: /shared route listing shared projects and notes - AppHeader: NotificationBell, Shared nav link - ProjectView, NoteViewerView, TaskViewerView: Share button + ShareDialog - SettingsView: Groups admin tab with create/delete groups + member mgmt - router: /shared route Co-Authored-By: Claude Sonnet 4.6 --- .../0025_add_sharing_and_notifications.py | 209 +++++++++ frontend/src/api/client.ts | 105 +++++ frontend/src/components/AppHeader.vue | 5 + frontend/src/components/NotificationBell.vue | 100 +++++ .../src/components/NotificationsPanel.vue | 156 +++++++ frontend/src/components/ShareDialog.vue | 417 ++++++++++++++++++ frontend/src/router/index.ts | 5 + frontend/src/stores/notifications.ts | 45 ++ frontend/src/views/NoteViewerView.vue | 24 + frontend/src/views/ProjectView.vue | 24 + frontend/src/views/SettingsView.vue | 362 ++++++++++++++- frontend/src/views/SharedWithMeView.vue | 256 +++++++++++ frontend/src/views/TaskViewerView.vue | 24 + src/fabledassistant/app.py | 8 + src/fabledassistant/models/__init__.py | 3 + src/fabledassistant/models/group.py | 57 +++ src/fabledassistant/models/notification.py | 31 ++ src/fabledassistant/models/share.py | 79 ++++ src/fabledassistant/routes/groups.py | 112 +++++ .../routes/in_app_notifications.py | 44 ++ src/fabledassistant/routes/notes.py | 10 +- src/fabledassistant/routes/projects.py | 22 +- src/fabledassistant/routes/shares.py | 128 ++++++ src/fabledassistant/routes/tasks.py | 7 +- src/fabledassistant/routes/users.py | 29 ++ src/fabledassistant/services/access.py | 163 +++++++ src/fabledassistant/services/groups.py | 186 ++++++++ src/fabledassistant/services/notes.py | 17 + src/fabledassistant/services/notifications.py | 196 ++++++++ src/fabledassistant/services/projects.py | 75 ++++ src/fabledassistant/services/sharing.py | 264 +++++++++++ 31 files changed, 3147 insertions(+), 16 deletions(-) create mode 100644 alembic/versions/0025_add_sharing_and_notifications.py create mode 100644 frontend/src/components/NotificationBell.vue create mode 100644 frontend/src/components/NotificationsPanel.vue create mode 100644 frontend/src/components/ShareDialog.vue create mode 100644 frontend/src/stores/notifications.ts create mode 100644 frontend/src/views/SharedWithMeView.vue create mode 100644 src/fabledassistant/models/group.py create mode 100644 src/fabledassistant/models/notification.py create mode 100644 src/fabledassistant/models/share.py create mode 100644 src/fabledassistant/routes/groups.py create mode 100644 src/fabledassistant/routes/in_app_notifications.py create mode 100644 src/fabledassistant/routes/shares.py create mode 100644 src/fabledassistant/routes/users.py create mode 100644 src/fabledassistant/services/access.py create mode 100644 src/fabledassistant/services/groups.py create mode 100644 src/fabledassistant/services/sharing.py diff --git a/alembic/versions/0025_add_sharing_and_notifications.py b/alembic/versions/0025_add_sharing_and_notifications.py new file mode 100644 index 0000000..a9412d6 --- /dev/null +++ b/alembic/versions/0025_add_sharing_and_notifications.py @@ -0,0 +1,209 @@ +"""Add groups, group_memberships, project_shares, note_shares, notifications tables. + +Revision ID: 0025 +Revises: 0024 +Create Date: 2026-03-11 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0025" +down_revision = "0024" +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"]) + 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") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 68076c8..4952daa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -75,6 +75,111 @@ export async function apiDelete(path: string): Promise { return handleResponse(res, path); } +// --------------------------------------------------------------------------- +// Sharing, Groups, Notifications, User search +// --------------------------------------------------------------------------- + +export interface ShareEntry { + id: number + permission: string + shared_with_user_id: number | null + shared_with_group_id: number | null + username?: string + group_name?: string + invited_by: number + created_at: string +} + +export interface GroupEntry { + id: number + name: string + description: string | null + created_by: number | null + member_count: number + is_member: boolean + created_at: string + updated_at: string +} + +export interface GroupMember { + id: number + group_id: number + user_id: number + role: string + username: string + email: string | null + created_at: string +} + +export interface NotificationEntry { + id: number + user_id: number + type: string + payload: Record + read_at: string | null + created_at: string +} + +export interface UserSearchResult { + id: number + username: string + email: string | null +} + +// --- User search --- +export const searchUsers = (q: string) => + apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users) + +// --- Groups --- +export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups) +export const createGroup = (name: string, description?: string) => + apiPost('/api/groups', { name, description }) +export const updateGroup = (id: number, data: { name?: string; description?: string }) => + apiPatch(`/api/groups/${id}`, data) +export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`) +export const getGroupDetail = (id: number) => + apiGet(`/api/groups/${id}`) +export const listGroupMembers = (id: number) => + apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members) +export const addGroupMember = (groupId: number, userId: number, role: string) => + apiPost(`/api/groups/${groupId}/members`, { user_id: userId, role }) +export const updateGroupMember = (groupId: number, userId: number, role: string) => + apiPatch(`/api/groups/${groupId}/members/${userId}`, { role }) +export const removeGroupMember = (groupId: number, userId: number) => + apiDelete(`/api/groups/${groupId}/members/${userId}`) + +// --- Project shares --- +export const listProjectShares = (projectId: number) => + apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares) +export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) => + apiPost(`/api/projects/${projectId}/shares`, body) +export const updateProjectShare = (projectId: number, shareId: number, permission: string) => + apiPatch(`/api/projects/${projectId}/shares/${shareId}`, { permission }) +export const deleteProjectShare = (projectId: number, shareId: number) => + apiDelete(`/api/projects/${projectId}/shares/${shareId}`) + +// --- Note / task shares --- +export const listNoteShares = (noteId: number) => + apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares) +export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) => + apiPost(`/api/notes/${noteId}/shares`, body) +export const updateNoteShare = (noteId: number, shareId: number, permission: string) => + apiPatch(`/api/notes/${noteId}/shares/${shareId}`, { permission }) +export const deleteNoteShare = (noteId: number, shareId: number) => + apiDelete(`/api/notes/${noteId}/shares/${shareId}`) + +// --- Shared-with-me --- +export const getSharedWithMe = () => + apiGet<{ projects: Record[]; notes: Record[] }>('/api/shared-with-me') + +// --- In-app notifications --- +export const getNotifications = (all = false) => + apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications) +export const getNotificationCount = () => + apiGet<{ count: number }>('/api/notifications/count').then(r => r.count) +export const markNotificationRead = (id: number) => apiPost(`/api/notifications/${id}/read`, {}) +export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {}) + export interface SSEStreamHandle { close(): void; /** Resolves when the stream closes (normally or via error/abort). */ diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index e2fdf30..4a305ad 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -6,6 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts"; import { useAuthStore } from "@/stores/auth"; import { useChatStore } from "@/stores/chat"; import AppLogo from "@/components/AppLogo.vue"; +import NotificationBell from "@/components/NotificationBell.vue"; const { theme, toggleTheme } = useTheme(); const { toggleShortcuts } = useShortcuts(); @@ -76,6 +77,7 @@ router.afterEach(() => { Tasks Chat Graph + Shared @@ -85,6 +87,8 @@ router.afterEach(() => { {{ statusShortLabel }} + + + @@ -248,6 +251,14 @@ async function convertToTask() { class="toc-sidebar" /> + + diff --git a/frontend/src/views/SharedWithMeView.vue b/frontend/src/views/SharedWithMeView.vue new file mode 100644 index 0000000..f579a0a --- /dev/null +++ b/frontend/src/views/SharedWithMeView.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 4d20c24..3f7fc47 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -12,6 +12,7 @@ import StatusBadge from "@/components/StatusBadge.vue"; import PriorityBadge from "@/components/PriorityBadge.vue"; import TagPill from "@/components/TagPill.vue"; import TableOfContents from "@/components/TableOfContents.vue"; +import ShareDialog from "@/components/ShareDialog.vue"; const route = useRoute(); const router = useRouter(); @@ -19,6 +20,7 @@ const store = useTasksStore(); const notesStore = useNotesStore(); const backlinks = ref<{ type: string; id: number; title: string }[]>([]); const converting = ref(false); +const showShare = ref(false); // Context enrichment const projectTitle = ref(null); @@ -240,6 +242,7 @@ const subTaskProgress = computed(() => { > {{ converting ? "Converting..." : "Convert to Note" }} + @@ -364,6 +367,14 @@ const subTaskProgress = computed(() => { class="toc-sidebar" /> + +