mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 08:46:22 +02:00
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:
parent
93aa1dcf3c
commit
22b2d6e400
8 changed files with 178 additions and 39 deletions
|
|
@ -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;")
|
||||||
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue