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" /> + +