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.
This commit is contained in:
Anish Sarkar 2026-01-21 20:34:58 +05:30
parent 93aa1dcf3c
commit 22b2d6e400
8 changed files with 178 additions and 39 deletions

View file

@ -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;")

View file

@ -784,6 +784,9 @@ class Notification(BaseModel, TimestampMixin):
read = Column( read = Column(
Boolean, nullable=False, default=False, server_default=text("false"), index=True 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={}) notification_metadata = Column("metadata", JSONB, nullable=True, default={})
updated_at = Column( updated_at = Column(
TIMESTAMP(timezone=True), TIMESTAMP(timezone=True),

View file

@ -30,6 +30,19 @@ class MarkAllReadResponse(BaseModel):
updated_count: int 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) @router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read( async def mark_notification_as_read(
notification_id: int, notification_id: int,
@ -100,3 +113,41 @@ async def mark_all_notifications_as_read(
message=f"Marked {updated_count} notification(s) as read", message=f"Marked {updated_count} notification(s) as read",
updated_count=updated_count, 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}",
)

View file

@ -238,21 +238,10 @@ export function AllPrivateChatsSidebar({
aria-label={t("chats") || "Private Chats"} aria-label={t("chats") || "Private Chats"}
> >
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" /> <User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2> <h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div> </div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />

View file

@ -238,21 +238,10 @@ export function AllSharedChatsSidebar({
aria-label={t("shared_chats") || "Shared Chats"} aria-label={t("shared_chats") || "Shared Chats"}
> >
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" /> <Users className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2> <h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
</div> </div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />

View file

@ -143,15 +143,16 @@ export function InboxSidebar({
let items = currentTabItems; let items = currentTabItems;
// Apply filter // Apply filter
// Note: Use `item.archived === true` to handle undefined/null as false
if (activeFilter === "all") { if (activeFilter === "all") {
// "Unread & read" shows all non-archived items // "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") { } else if (activeFilter === "unread") {
// "Unread" shows only unread non-archived items // "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") { } else if (activeFilter === "archived") {
// "Archived" shows only archived items // "Archived" shows only archived items (must be explicitly true)
items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived); items = items.filter((item) => item.archived === true);
} }
// Apply search query // Apply search query
@ -340,7 +341,7 @@ export function InboxSidebar({
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "-100%" }} exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }} 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" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t("inbox") || "Inbox"} aria-label={t("inbox") || "Inbox"}
@ -500,7 +501,7 @@ export function InboxSidebar({
const isMarkingAsRead = markingAsReadId === item.id; const isMarkingAsRead = markingAsReadId === item.id;
const isArchiving = archivingItemId === item.id; const isArchiving = archivingItemId === item.id;
const isBusy = isMarkingAsRead || isArchiving; const isBusy = isMarkingAsRead || isArchiving;
const isArchived = (item as InboxItem & { archived?: boolean }).archived; const isArchived = item.archived === true;
return ( return (
<div <div
@ -533,6 +534,47 @@ export function InboxSidebar({
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5"> <p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)} {convertRenderedToDisplay(item.message)}
</p> </p>
{/* Mobile action buttons - shown below description on mobile only */}
<div className="inline-flex items-center gap-0.5 mt-2 md:hidden bg-primary/20 rounded-md px-1 py-0.5">
{!item.read && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(item.id);
}}
disabled={isBusy}
>
{isMarkingAsRead ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<CheckCheck className="h-3.5 w-3.5" />
)}
<span className="sr-only">{t("mark_as_read") || "Mark as read"}</span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleToggleArchive(item.id, isArchived);
}}
disabled={isArchiving}
>
{isArchiving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : isArchived ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
)}
<span className="sr-only">{isArchived ? (t("unarchive") || "Restore") : (t("archive") || "Archive")}</span>
</Button>
</div>
</div> </div>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -544,8 +586,8 @@ export function InboxSidebar({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Time/dot and 3-dot button container - swap on hover */} {/* Time/dot and 3-dot button container - swap on hover (desktop only) */}
<div className="relative flex items-center shrink-0 w-12 justify-end"> <div className="relative hidden md:flex items-center shrink-0 w-12 justify-end">
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */} {/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
<div <div
className={cn( className={cn(
@ -605,7 +647,7 @@ export function InboxSidebar({
</> </>
)} )}
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleToggleArchive(item.id, !!isArchived)} onClick={() => handleToggleArchive(item.id, isArchived)}
disabled={isArchiving} disabled={isArchiving}
> >
{isArchived ? ( {isArchived ? (
@ -623,6 +665,16 @@ export function InboxSidebar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Mobile time and unread dot - always visible on mobile */}
<div className="flex md:hidden items-center gap-1.5 shrink-0">
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && (
<span className="h-2 w-2 rounded-full bg-blue-500" />
)}
</div>
</div> </div>
); );
})} })}

View file

@ -110,6 +110,7 @@ export const inboxItem = z.object({
title: z.string(), title: z.string(),
message: z.string(), message: z.string(),
read: z.boolean(), read: z.boolean(),
archived: z.boolean().default(false),
metadata: z.record(z.string(), z.unknown()), metadata: z.record(z.string(), z.unknown()),
created_at: z.string(), created_at: z.string(),
updated_at: z.string().nullable(), updated_at: z.string().nullable(),

View file

@ -53,8 +53,9 @@ const activeSyncHandles = new Map<string, SyncHandle>();
const pendingSyncs = new Map<string, Promise<SyncHandle>>(); const pendingSyncs = new Map<string, Promise<SyncHandle>>();
// Version for sync state - increment this to force fresh sync when Electric config changes // Version for sync state - increment this to force fresh sync when Electric config changes
// Set to v2 for user-specific database architecture // v2: user-specific database architecture
const SYNC_VERSION = 2; // v3: added archived column to notifications
const SYNC_VERSION = 3;
// Database name prefix for identifying SurfSense databases // Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-"; const DB_PREFIX = "surfsense-";
@ -181,6 +182,7 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
title TEXT NOT NULL, title TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT FALSE, read BOOLEAN NOT NULL DEFAULT FALSE,
archived BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}', metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ updated_at TIMESTAMPTZ
@ -188,6 +190,7 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); 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_read ON notifications(read);
CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived);
`); `);
// Create the search_source_connectors table schema in PGlite // Create the search_source_connectors table schema in PGlite