From 2c46ab0843af13932a37580b5819aa9e24158bfe Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Mon, 30 Mar 2026 13:43:55 +0200 Subject: [PATCH] feat: pattern/creator grouping, notification UI, failure alerts Pattern grouping (Signal 3): - Extract YYYY-MM dates, month names, and project prefixes from filenames - Auto-group packages sharing the same pattern within a channel - Groups created with groupingSource=AUTO_PATTERN Creator grouping (Signal 4): - Auto-group 3+ ungrouped packages from the same creator within a channel - Runs after pattern grouping as lowest-priority automatic signal Notification UI: - Add NotificationBell component to header with unread badge - Popover panel shows recent notifications with severity icons - Mark individual or all notifications as read - Polls every 30 seconds for updates Failure notifications: - Upload/download failures now create SystemNotification records - Visible in the notification bell alongside hash mismatch alerts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/notifications/read/route.ts | 26 +++ src/app/api/notifications/route.ts | 27 +++ src/components/layout/header.tsx | 4 +- src/components/layout/notification-bell.tsx | 183 ++++++++++++++++++++ src/data/notification.queries.ts | 37 ++++ worker/src/db/queries.ts | 22 +++ worker/src/grouping.ts | 140 ++++++++++++++- worker/src/worker.ts | 24 ++- 8 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 src/app/api/notifications/read/route.ts create mode 100644 src/app/api/notifications/route.ts create mode 100644 src/components/layout/notification-bell.tsx create mode 100644 src/data/notification.queries.ts diff --git a/src/app/api/notifications/read/route.ts b/src/app/api/notifications/read/route.ts new file mode 100644 index 0000000..b44872e --- /dev/null +++ b/src/app/api/notifications/read/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { + markNotificationRead, + markAllNotificationsRead, +} from "@/data/notification.queries"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const id = body.id as string | undefined; + + if (id) { + await markNotificationRead(id); + } else { + await markAllNotificationsRead(); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..970bc74 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { + getRecentNotifications, + getUnreadNotificationCount, +} from "@/data/notification.queries"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [notifications, unreadCount] = await Promise.all([ + getRecentNotifications(30), + getUnreadNotificationCount(), + ]); + + const serialized = notifications.map((n) => ({ + ...n, + createdAt: n.createdAt.toISOString(), + })); + + return NextResponse.json({ notifications: serialized, unreadCount }); +} diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 873abd2..15f99bc 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { UserMenu } from "./user-menu"; import { MobileSidebar } from "./mobile-sidebar"; +import { NotificationBell } from "./notification-bell"; const routeTitles: Record = { "/dashboard": "Dashboard", @@ -38,7 +39,8 @@ export function Header() {

{title}

-
+
+
diff --git a/src/components/layout/notification-bell.tsx b/src/components/layout/notification-bell.tsx new file mode 100644 index 0000000..0a07111 --- /dev/null +++ b/src/components/layout/notification-bell.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Bell, AlertTriangle, AlertCircle, Info, CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface Notification { + id: string; + type: string; + severity: "INFO" | "WARNING" | "ERROR"; + title: string; + message: string; + isRead: boolean; + createdAt: string; +} + +const severityIcon = { + INFO: Info, + WARNING: AlertTriangle, + ERROR: AlertCircle, +}; + +const severityColor = { + INFO: "text-blue-400", + WARNING: "text-orange-400", + ERROR: "text-red-400", +}; + +export function NotificationBell() { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [open, setOpen] = useState(false); + + const fetchNotifications = useCallback(async () => { + try { + const res = await fetch("/api/notifications"); + if (res.ok) { + const data = await res.json(); + setNotifications(data.notifications ?? []); + setUnreadCount(data.unreadCount ?? 0); + } + } catch { + // Ignore fetch errors + } + }, []); + + // Poll every 30 seconds + on mount + useEffect(() => { + fetchNotifications(); + const interval = setInterval(fetchNotifications, 30_000); + return () => clearInterval(interval); + }, [fetchNotifications]); + + // Refresh when popover opens + useEffect(() => { + if (open) fetchNotifications(); + }, [open, fetchNotifications]); + + async function handleMarkAllRead() { + try { + await fetch("/api/notifications/read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + setUnreadCount(0); + } catch { + // Ignore + } + } + + async function handleMarkRead(id: string) { + try { + await fetch("/api/notifications/read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) + ); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { + // Ignore + } + } + + function formatTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; + } + + return ( + + + + + +
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +
+ +

All clear!

+
+ ) : ( +
+ {notifications.map((n) => { + const Icon = severityIcon[n.severity] ?? Info; + const color = severityColor[n.severity] ?? "text-muted-foreground"; + return ( + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/data/notification.queries.ts b/src/data/notification.queries.ts new file mode 100644 index 0000000..7772f0d --- /dev/null +++ b/src/data/notification.queries.ts @@ -0,0 +1,37 @@ +import { prisma } from "@/lib/prisma"; + +export async function getUnreadNotificationCount(): Promise { + return prisma.systemNotification.count({ + where: { isRead: false }, + }); +} + +export async function getRecentNotifications(limit = 20) { + return prisma.systemNotification.findMany({ + orderBy: { createdAt: "desc" }, + take: limit, + select: { + id: true, + type: true, + severity: true, + title: true, + message: true, + isRead: true, + createdAt: true, + }, + }); +} + +export async function markNotificationRead(id: string) { + return prisma.systemNotification.update({ + where: { id }, + data: { isRead: true }, + }); +} + +export async function markAllNotificationsRead() { + return prisma.systemNotification.updateMany({ + where: { isRead: false }, + data: { isRead: true }, + }); +} diff --git a/worker/src/db/queries.ts b/worker/src/db/queries.ts index 8b6e8aa..88179bd 100644 --- a/worker/src/db/queries.ts +++ b/worker/src/db/queries.ts @@ -608,3 +608,25 @@ export async function createTimeWindowGroup(input: { return group.id; } + +export async function createAutoGroup(input: { + sourceChannelId: string; + name: string; + packageIds: string[]; + groupingSource: "AUTO_TIME" | "AUTO_PATTERN" | "AUTO_ZIP" | "AUTO_CAPTION"; +}): Promise { + const group = await db.packageGroup.create({ + data: { + sourceChannelId: input.sourceChannelId, + name: input.name, + groupingSource: input.groupingSource, + }, + }); + + await db.package.updateMany({ + where: { id: { in: input.packageIds } }, + data: { packageGroupId: group.id }, + }); + + return group.id; +} diff --git a/worker/src/grouping.ts b/worker/src/grouping.ts index 28ed191..08767e6 100644 --- a/worker/src/grouping.ts +++ b/worker/src/grouping.ts @@ -1,7 +1,7 @@ import type { Client } from "tdl"; import type { TelegramPhoto } from "./preview/match.js"; import { downloadPhotoThumbnail } from "./tdlib/download.js"; -import { createOrFindPackageGroup, linkPackagesToGroup, createTimeWindowGroup } from "./db/queries.js"; +import { createOrFindPackageGroup, linkPackagesToGroup, createTimeWindowGroup, createAutoGroup } from "./db/queries.js"; import { config } from "./util/config.js"; import { childLogger } from "./util/logger.js"; import { db } from "./db/client.js"; @@ -150,6 +150,144 @@ export async function processTimeWindowGroups( } } +/** + * Group ungrouped packages that share a date pattern (YYYY-MM, YYYY_MM, etc.) + * or project slug extracted from their filenames. + */ +export async function processPatternGroups( + sourceChannelId: string, + indexedPackages: IndexedPackageRef[] +): Promise { + const ungrouped = await db.package.findMany({ + where: { + id: { in: indexedPackages.map((p) => p.packageId) }, + packageGroupId: null, + }, + select: { id: true, fileName: true }, + }); + + if (ungrouped.length < 2) return; + + // Group by extracted pattern + const patternMap = new Map(); + for (const pkg of ungrouped) { + const pattern = extractPattern(pkg.fileName); + if (!pattern) continue; + const group = patternMap.get(pattern) ?? []; + group.push(pkg); + patternMap.set(pattern, group); + } + + for (const [pattern, members] of patternMap) { + if (members.length < 2) continue; + + try { + const groupId = await createAutoGroup({ + sourceChannelId, + name: pattern, + packageIds: members.map((m) => m.id), + groupingSource: "AUTO_PATTERN", + }); + + log.info( + { groupId, pattern, memberCount: members.length }, + "Created pattern-based group" + ); + } catch (err) { + log.warn({ err, pattern }, "Failed to create pattern group"); + } + } +} + +/** + * Extract a grouping pattern from a filename. + * Matches: YYYY-MM, YYYY_MM, "Month Year", or a project prefix before common separators. + * Returns null if no usable pattern found. + */ +function extractPattern(fileName: string): string | null { + // Strip extension for matching + const name = fileName.replace(/\.(zip|rar|7z|pdf|stl)(\.\d+)?$/i, ""); + + // Match YYYY-MM or YYYY_MM patterns + const dateMatch = name.match(/(\d{4})[\-_](\d{2})/); + if (dateMatch) { + return `${dateMatch[1]}-${dateMatch[2]}`; + } + + // Match "Month Year" patterns (e.g., "January 2025", "Jan 2025") + const months = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)"; + const monthYearMatch = name.match(new RegExp(`(${months})\\s*(\\d{4})`, "i")); + if (monthYearMatch) { + const monthStr = monthYearMatch[1].toLowerCase().slice(0, 3); + const monthNum = ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"].indexOf(monthStr) + 1; + if (monthNum > 0) { + return `${monthYearMatch[2]}-${String(monthNum).padStart(2, "0")}`; + } + } + + // Match project prefix: text before " - ", " – ", or "(". Must be at least 5 chars. + const prefixMatch = name.match(/^(.{5,}?)(?:\s*[\-–]\s|\s*\()/); + if (prefixMatch) { + return prefixMatch[1].trim(); + } + + return null; +} + +/** + * Group ungrouped packages that share the same creator within a channel. + * Only groups if there are 3+ packages from the same creator (to avoid + * over-grouping when a creator only has a couple files). + */ +export async function processCreatorGroups( + sourceChannelId: string, + indexedPackages: IndexedPackageRef[] +): Promise { + const ungrouped = await db.package.findMany({ + where: { + id: { in: indexedPackages.map((p) => p.packageId) }, + packageGroupId: null, + creator: { not: null }, + }, + select: { id: true, fileName: true, creator: true }, + }); + + if (ungrouped.length < 3) return; + + // Group by creator + const creatorMap = new Map(); + for (const pkg of ungrouped) { + if (!pkg.creator) continue; + const key = pkg.creator.toLowerCase(); + const group = creatorMap.get(key) ?? []; + group.push(pkg); + creatorMap.set(key, group); + } + + for (const [, members] of creatorMap) { + if (members.length < 3) continue; + + const creatorName = members[0].creator!; + const name = findCommonPrefix(members.map((m) => m.fileName)) || creatorName; + + try { + const groupId = await createAutoGroup({ + sourceChannelId, + name, + packageIds: members.map((m) => m.id), + groupingSource: "AUTO_PATTERN", + }); + + log.info( + { groupId, creator: creatorName, memberCount: members.length }, + "Created creator-based group" + ); + } catch (err) { + log.warn({ err, creator: creatorName }, "Failed to create creator group"); + } + } +} + /** * Find the longest common prefix among a list of filenames, * trimming trailing separators and partial words. diff --git a/worker/src/worker.ts b/worker/src/worker.ts index ebd9852..73fd51e 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -47,7 +47,7 @@ import { readRarContents } from "./archive/rar-reader.js"; import { read7zContents } from "./archive/sevenz-reader.js"; import { byteLevelSplit, concatenateFiles } from "./archive/split.js"; import { uploadToChannel } from "./upload/channel.js"; -import { processAlbumGroups, processTimeWindowGroups, type IndexedPackageRef } from "./grouping.js"; +import { processAlbumGroups, processTimeWindowGroups, processPatternGroups, processCreatorGroups, type IndexedPackageRef } from "./grouping.js"; import { db } from "./db/client.js"; import type { TelegramAccount, TelegramChannel } from "@prisma/client"; import type { Client } from "tdl"; @@ -777,6 +777,22 @@ async function processArchiveSets( partCount: archiveSet.parts.length, accountId: ctx.accountId, }); + // Also create a persistent notification + await db.systemNotification.create({ + data: { + type: inferSkipReason(errMsg) === "UPLOAD_FAILED" ? "UPLOAD_FAILED" : "DOWNLOAD_FAILED", + severity: "WARNING", + title: `Failed to process ${archiveSet.parts[0].fileName}`, + message: errMsg, + context: { + fileName: archiveSet.parts[0].fileName, + sourceChannelId: ctx.channel.id, + sourceMessageId: Number(archiveSet.parts[0].id), + channelTitle: ctx.channelTitle, + reason: inferSkipReason(errMsg), + }, + }, + }); } catch { // Best-effort — don't fail the run if skip recording fails } @@ -794,6 +810,12 @@ async function processArchiveSets( // Time-window grouping for remaining ungrouped packages await processTimeWindowGroups(channel.id, indexedPackageRefs); + + // Pattern-based grouping (date patterns, project slugs) + await processPatternGroups(channel.id, indexedPackageRefs); + + // Creator-based grouping (3+ files from same creator) + await processCreatorGroups(channel.id, indexedPackageRefs); } return maxProcessedId;