From 22b2d6e400412296b4e3821bf02e0b3de4cb0367 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Jan 2026 20:34:58 +0530 Subject: [PATCH] feat: add archived column to notifications and implement archiving functionality - Introduced an archived boolean column in the notifications table to allow users to archive inbox items without deletion. - Updated Notification model to include the archived field with default value. - Added ArchiveRequest and ArchiveResponse models for handling archive/unarchive operations. - Implemented API endpoint to archive or unarchive notifications, ensuring real-time updates with Electric SQL. - Enhanced InboxSidebar to filter and display archived notifications appropriately. --- ...73_add_archived_column_to_notifications.py | 51 ++++++++++++++ surfsense_backend/app/db.py | 3 + .../app/routes/notifications_routes.py | 51 ++++++++++++++ .../ui/sidebar/AllPrivateChatsSidebar.tsx | 17 +---- .../ui/sidebar/AllSharedChatsSidebar.tsx | 17 +---- .../layout/ui/sidebar/InboxSidebar.tsx | 70 ++++++++++++++++--- surfsense_web/contracts/types/inbox.types.ts | 1 + surfsense_web/lib/electric/client.ts | 7 +- 8 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py diff --git a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py new file mode 100644 index 000000000..99962e501 --- /dev/null +++ b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py @@ -0,0 +1,51 @@ +"""Add archived column to notifications table + +Revision ID: 73 +Revises: 72 + +Adds an archived boolean column to the notifications table to allow users +to archive inbox items without deleting them. + +NOTE: Electric SQL automatically picks up schema changes when REPLICA IDENTITY FULL +is set (which was done in migration 66). We re-affirm it here to ensure replication +continues to work after adding the new column. +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "73" +down_revision: str | None = "72" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add archived column to notifications table.""" + # Add the archived column with a default value + op.execute( + """ + ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE; + """ + ) + + # Create index for archived column + op.execute( + "CREATE INDEX IF NOT EXISTS ix_notifications_archived ON notifications (archived);" + ) + + # Re-affirm REPLICA IDENTITY FULL for Electric SQL after schema change + # This ensures Electric SQL continues to replicate all columns including the new one + op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;") + + +def downgrade() -> None: + """Remove archived column from notifications table.""" + op.execute("DROP INDEX IF EXISTS ix_notifications_archived;") + op.execute("ALTER TABLE notifications DROP COLUMN IF EXISTS archived;") + # Re-affirm REPLICA IDENTITY FULL after removing the column + op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;") + diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 38e27ecf2..b969f9e55 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -784,6 +784,9 @@ class Notification(BaseModel, TimestampMixin): read = Column( Boolean, nullable=False, default=False, server_default=text("false"), index=True ) + archived = Column( + Boolean, nullable=False, default=False, server_default=text("false"), index=True + ) notification_metadata = Column("metadata", JSONB, nullable=True, default={}) updated_at = Column( TIMESTAMP(timezone=True), diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index deee748d8..3bf7a4880 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -30,6 +30,19 @@ class MarkAllReadResponse(BaseModel): updated_count: int +class ArchiveRequest(BaseModel): + """Request body for archive/unarchive operations.""" + + archived: bool + + +class ArchiveResponse(BaseModel): + """Response for archive operations.""" + + success: bool + message: str + + @router.patch("/{notification_id}/read", response_model=MarkReadResponse) async def mark_notification_as_read( notification_id: int, @@ -100,3 +113,41 @@ async def mark_all_notifications_as_read( message=f"Marked {updated_count} notification(s) as read", updated_count=updated_count, ) + + +@router.patch("/{notification_id}/archive", response_model=ArchiveResponse) +async def archive_notification( + notification_id: int, + request: ArchiveRequest, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> ArchiveResponse: + """ + Archive or unarchive a notification. + + Electric SQL will automatically sync this change to all connected clients. + """ + # Verify the notification belongs to the user + result = await session.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == user.id, + ) + ) + notification = result.scalar_one_or_none() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found", + ) + + # Update the notification + notification.archived = request.archived + await session.commit() + + action = "archived" if request.archived else "unarchived" + return ArchiveResponse( + success=True, + message=f"Notification {action}", + ) diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index fb1a6ed0d..6be4809cf 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -238,20 +238,9 @@ export function AllPrivateChatsSidebar({ aria-label={t("chats") || "Private Chats"} >
-
-
- -

{t("chats") || "Private Chats"}

-
- +
+ +

{t("chats") || "Private Chats"}

diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index f400e6fc8..ea80cc920 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -238,20 +238,9 @@ export function AllSharedChatsSidebar({ aria-label={t("shared_chats") || "Shared Chats"} >
-
-
- -

{t("shared_chats") || "Shared Chats"}

-
- +
+ +

{t("shared_chats") || "Shared Chats"}

diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 4171ac267..9f5a50bc4 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -143,15 +143,16 @@ export function InboxSidebar({ let items = currentTabItems; // Apply filter + // Note: Use `item.archived === true` to handle undefined/null as false if (activeFilter === "all") { // "Unread & read" shows all non-archived items - items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived); + items = items.filter((item) => item.archived !== true); } else if (activeFilter === "unread") { // "Unread" shows only unread non-archived items - items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived); + items = items.filter((item) => !item.read && item.archived !== true); } else if (activeFilter === "archived") { - // "Archived" shows only archived items - items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived); + // "Archived" shows only archived items (must be explicitly true) + items = items.filter((item) => item.archived === true); } // Apply search query @@ -340,7 +341,7 @@ export function InboxSidebar({ animate={{ x: 0 }} exit={{ x: "-100%" }} transition={{ type: "spring", damping: 25, stiffness: 300 }} - className="fixed inset-y-0 left-0 z-70 w-96 bg-background shadow-xl flex flex-col pointer-events-auto isolate" + className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate" role="dialog" aria-modal="true" aria-label={t("inbox") || "Inbox"} @@ -500,7 +501,7 @@ export function InboxSidebar({ const isMarkingAsRead = markingAsReadId === item.id; const isArchiving = archivingItemId === item.id; const isBusy = isMarkingAsRead || isArchiving; - const isArchived = (item as InboxItem & { archived?: boolean }).archived; + const isArchived = item.archived === true; return (
{convertRenderedToDisplay(item.message)}

+ {/* Mobile action buttons - shown below description on mobile only */} +
+ {!item.read && ( + + )} + +
@@ -544,8 +586,8 @@ export function InboxSidebar({ - {/* Time/dot and 3-dot button container - swap on hover */} -
+ {/* Time/dot and 3-dot button container - swap on hover (desktop only) */} +
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
)} handleToggleArchive(item.id, !!isArchived)} + onClick={() => handleToggleArchive(item.id, isArchived)} disabled={isArchiving} > {isArchived ? ( @@ -623,6 +665,16 @@ export function InboxSidebar({
+ + {/* Mobile time and unread dot - always visible on mobile */} +
+ + {formatTime(item.created_at)} + + {!item.read && ( + + )} +
); })} diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 515ba5864..2e80a9909 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -110,6 +110,7 @@ export const inboxItem = z.object({ title: z.string(), message: z.string(), read: z.boolean(), + archived: z.boolean().default(false), metadata: z.record(z.string(), z.unknown()), created_at: z.string(), updated_at: z.string().nullable(), diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 514185d23..222553f32 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -53,8 +53,9 @@ const activeSyncHandles = new Map(); const pendingSyncs = new Map>(); // Version for sync state - increment this to force fresh sync when Electric config changes -// Set to v2 for user-specific database architecture -const SYNC_VERSION = 2; +// v2: user-specific database architecture +// v3: added archived column to notifications +const SYNC_VERSION = 3; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-"; @@ -181,6 +182,7 @@ export async function initElectric(userId: string): Promise { title TEXT NOT NULL, message TEXT NOT NULL, read BOOLEAN NOT NULL DEFAULT FALSE, + archived BOOLEAN NOT NULL DEFAULT FALSE, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ @@ -188,6 +190,7 @@ export async function initElectric(userId: string): Promise { CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read); + CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived); `); // Create the search_source_connectors table schema in PGlite