# Search Match Indicators, Size Limit Increase, Skipped/Failed Files Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add search match indicators to the STL files table, raise the ingestion size limit to 200 GB, and track skipped/failed archives with a retry UI. **Architecture:** Three independent features sharing one migration. Feature 1 (size limit) is a one-line config change. Feature 2 (search indicators) modifies `searchPackages()` to return per-package match counts and pipes that through to the table and file drawer. Feature 3 (skipped files) adds a new `SkippedPackage` model, worker-side recording, and a UI tab with retry capability. **Tech Stack:** Prisma 7.4, Next.js 16 (App Router), TanStack Table, shadcn/ui, TypeScript 5.9 **Spec:** `docs/superpowers/specs/2026-03-24-search-indicators-size-limit-skipped-files-design.md` --- ## File Structure ### Create - `src/app/(app)/stls/_components/skipped-packages-tab.tsx` — Skipped/failed packages table with retry buttons - `src/app/(app)/stls/_components/skipped-columns.tsx` — Column definitions for skipped packages table ### Modify - `worker/src/util/config.ts` — Raise default `maxZipSizeMB` from 4096 to 204800 - `prisma/schema.prisma` — Add `SkipReason` enum, `SkippedPackage` model, reverse relations - `worker/src/worker.ts` — Add `accountId` to `PipelineContext`, record skips/failures, clean up on success - `worker/src/db/queries.ts` — Add `upsertSkippedPackage()` and `deleteSkippedPackage()` functions - `src/lib/telegram/types.ts` — Add `matchedFileCount`/`matchedByContent` to `PackageListItem`, add `SkippedPackageItem` type - `src/lib/telegram/queries.ts` — Modify `searchPackages()` for grouped counts, add skipped package queries - `src/app/(app)/stls/page.tsx` — Pass search term, fetch skipped count - `src/app/(app)/stls/_components/stl-table.tsx` — Accept search prop, pass to columns/drawer, add tabs - `src/app/(app)/stls/_components/package-columns.tsx` — Add `matchedFileCount`/`matchedByContent` to `PackageRow`, render match badge - `src/app/(app)/stls/_components/package-files-drawer.tsx` — Accept `highlightTerm`, highlight matching files, auto-expand matched folders - `src/app/(app)/stls/actions.ts` — Add retry server actions --- ## Task 1: Raise Ingestion Size Limit **Files:** - Modify: `worker/src/util/config.ts:6` - [ ] **Step 1: Change the default max size** In `worker/src/util/config.ts`, change line 6: ```typescript // Before: maxZipSizeMB: parseInt(process.env.WORKER_MAX_ZIP_SIZE_MB ?? "4096", 10), // After: maxZipSizeMB: parseInt(process.env.WORKER_MAX_ZIP_SIZE_MB ?? "204800", 10), ``` - [ ] **Step 2: Verify worker builds** Run: `cd worker && npx tsc --noEmit` Expected: No errors - [ ] **Step 3: Commit** ```bash git add worker/src/util/config.ts git commit -m "feat: raise default ingestion size limit from 4GB to 200GB" ``` --- ## Task 2: Prisma Schema — SkippedPackage Model **Files:** - Modify: `prisma/schema.prisma` - [ ] **Step 1: Add SkipReason enum and SkippedPackage model** Add after the `ArchiveExtractRequest` model (end of file area) in `prisma/schema.prisma`: ```prisma enum SkipReason { SIZE_LIMIT DOWNLOAD_FAILED EXTRACT_FAILED UPLOAD_FAILED } model SkippedPackage { id String @id @default(cuid()) fileName String fileSize BigInt reason SkipReason errorMessage String? sourceChannelId String sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade) sourceMessageId BigInt sourceTopicId BigInt? isMultipart Boolean @default(false) partCount Int @default(1) accountId String account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@unique([sourceChannelId, sourceMessageId]) @@index([reason]) @@index([accountId]) @@map("skipped_packages") } ``` - [ ] **Step 2: Add reverse relations to existing models** In `TelegramAccount` model (line ~401-418), add inside the relations block (after `fetchRequests`): ```prisma skippedPackages SkippedPackage[] ``` In `TelegramChannel` model (line ~420-437), add inside the relations block (after `packages`): ```prisma skippedPackages SkippedPackage[] ``` - [ ] **Step 3: Generate Prisma client and verify** Run: `npx prisma generate` Expected: Success, no errors - [ ] **Step 4: Create migration** Run: `npx prisma migrate dev --name add-skipped-packages` Expected: Migration created successfully - [ ] **Step 5: Commit** ```bash git add prisma/ git commit -m "feat: add SkippedPackage model for tracking skipped/failed archives" ``` --- ## Task 3: Worker — Record Skipped/Failed Archives **Files:** - Modify: `worker/src/db/queries.ts` - Modify: `worker/src/worker.ts:279-298` (PipelineContext), `worker/src/worker.ts:436-448` (pipelineCtx creation), `worker/src/worker.ts:781-802` (size guard), `worker/src/worker.ts:726-732` (set failure catch) - [ ] **Step 1: Add worker DB functions for skipped packages** In `worker/src/db/queries.ts`, add these functions: ```typescript export async function upsertSkippedPackage(data: { fileName: string; fileSize: bigint; reason: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED"; errorMessage?: string; sourceChannelId: string; sourceMessageId: bigint; sourceTopicId?: bigint | null; isMultipart: boolean; partCount: number; accountId: string; }) { return db.skippedPackage.upsert({ where: { sourceChannelId_sourceMessageId: { sourceChannelId: data.sourceChannelId, sourceMessageId: data.sourceMessageId, }, }, update: { reason: data.reason, errorMessage: data.errorMessage ?? null, fileName: data.fileName, fileSize: data.fileSize, createdAt: new Date(), }, create: { fileName: data.fileName, fileSize: data.fileSize, reason: data.reason, errorMessage: data.errorMessage ?? null, sourceChannelId: data.sourceChannelId, sourceMessageId: data.sourceMessageId, sourceTopicId: data.sourceTopicId ?? null, isMultipart: data.isMultipart, partCount: data.partCount, accountId: data.accountId, }, }); } export async function deleteSkippedPackage( sourceChannelId: string, sourceMessageId: bigint ) { return db.skippedPackage.deleteMany({ where: { sourceChannelId, sourceMessageId }, }); } ``` - [ ] **Step 2: Add `accountId` to PipelineContext** In `worker/src/worker.ts`, add `accountId` to the `PipelineContext` interface (line ~279-298): ```typescript interface PipelineContext { client: Client; runId: string; accountId: string; // <-- ADD THIS channelTitle: string; channel: TelegramChannel; // ... rest unchanged } ``` And add it to the `pipelineCtx` creation (line ~436-448): ```typescript const pipelineCtx: PipelineContext = { client, runId: activeRunId, accountId: account.id, // <-- ADD THIS channelTitle: channel.title, // ... rest unchanged }; ``` - [ ] **Step 3: Record SIZE_LIMIT skips** In `worker/src/worker.ts` at the size guard (line ~784-802), after the `updateRunActivity` call and before the `return`, add: ```typescript await upsertSkippedPackage({ fileName: archiveName, fileSize: totalArchiveSize, reason: "SIZE_LIMIT", sourceChannelId: channel.id, sourceMessageId: archiveSet.parts[0].id, sourceTopicId: ctx.sourceTopicId, isMultipart: archiveSet.isMultipart, partCount: archiveSet.parts.length, accountId: ctx.accountId, }); ``` Add the import at top of worker.ts: ```typescript import { upsertSkippedPackage, deleteSkippedPackage } from "./db/queries.js"; ``` - [ ] **Step 4: Record processing failures in the catch block** In `worker/src/worker.ts` at the archive set failure catch (the `processArchiveSets` function, line ~726-732), enhance the catch block: ```typescript } catch (setErr) { // If a set fails, do NOT advance the watermark past it accountLog.warn( { err: setErr, baseName: archiveSets[setIdx].baseName }, "Archive set failed, watermark will not advance past this set" ); // Record the failure for visibility in the UI try { const archiveSet = archiveSets[setIdx]; const totalSize = archiveSet.parts.reduce((sum, p) => sum + p.fileSize, 0n); await upsertSkippedPackage({ fileName: archiveSet.parts[0].fileName, fileSize: totalSize, reason: "DOWNLOAD_FAILED", // Catch-all for any pipeline failure at this level errorMessage: setErr instanceof Error ? setErr.message : String(setErr), sourceChannelId: ctx.channel.id, sourceMessageId: archiveSet.parts[0].id, sourceTopicId: ctx.sourceTopicId, isMultipart: archiveSet.isMultipart, partCount: archiveSet.parts.length, accountId: ctx.accountId, }); } catch { // Best-effort — don't fail the run if skip recording fails } } ``` - [ ] **Step 5: Clean up skip records on successful ingestion** In `worker/src/worker.ts`, in `processOneArchiveSet`, after the `createPackageWithFiles` call succeeds (near the end of the function where `counters.zipsIngested++` is), add: ```typescript // Clean up any prior skip record for this archive await deleteSkippedPackage(channel.id, archiveSet.parts[0].id); ``` - [ ] **Step 6: Verify worker builds** Run: `cd worker && npx tsc --noEmit` Expected: No errors - [ ] **Step 7: Commit** ```bash git add worker/src/db/queries.ts worker/src/worker.ts git commit -m "feat: record skipped/failed archives in database for UI visibility" ``` --- ## Task 4: Search Match Indicators — Backend **Files:** - Modify: `src/lib/telegram/types.ts:1-17` - Modify: `src/lib/telegram/queries.ts:165-257` - [ ] **Step 1: Add match fields to PackageListItem** In `src/lib/telegram/types.ts`, add to the `PackageListItem` interface (after `sourceChannel`): ```typescript matchedFileCount: number; matchedByContent: boolean; ``` - [ ] **Step 2: Update listPackages to include default match fields** In `src/lib/telegram/queries.ts`, in the `listPackages` function's mapping (line ~47-60), add the two default fields: ```typescript const mapped: PackageListItem[] = items.map((pkg) => ({ // ... existing fields ... sourceChannel: pkg.sourceChannel, matchedFileCount: 0, matchedByContent: false, })); ``` - [ ] **Step 3: Rewrite searchPackages to return match counts** Replace the `searchPackages` function in `src/lib/telegram/queries.ts` (lines 165-257): ```typescript export async function searchPackages(options: { query: string; page: number; limit: number; searchIn: "packages" | "files" | "both"; }) { const q = options.query; if (options.searchIn === "files" || options.searchIn === "both") { // Get per-package file match counts const fileMatches = await prisma.packageFile.groupBy({ by: ["packageId"], where: { OR: [ { fileName: { contains: q, mode: "insensitive" } }, { path: { contains: q, mode: "insensitive" } }, ], }, _count: { _all: true }, }); const fileMatchMap = new Map( fileMatches.map((m) => [m.packageId, m._count._all]) ); const fileMatchedIds = fileMatches.map((f) => f.packageId); const packageNameIds = options.searchIn === "both" ? ( await prisma.package.findMany({ where: { fileName: { contains: q, mode: "insensitive" } }, select: { id: true }, }) ).map((p) => p.id) : []; const allIds = [...new Set([...fileMatchedIds, ...packageNameIds])]; const [items, total] = await Promise.all([ prisma.package.findMany({ where: { id: { in: allIds } }, orderBy: { indexedAt: "desc" }, skip: (options.page - 1) * options.limit, take: options.limit, select: { id: true, fileName: true, fileSize: true, contentHash: true, archiveType: true, fileCount: true, isMultipart: true, indexedAt: true, creator: true, tags: true, previewData: true, sourceChannel: { select: { id: true, title: true } }, }, }), Promise.resolve(allIds.length), ]); const mapped: PackageListItem[] = items.map((pkg) => ({ id: pkg.id, fileName: pkg.fileName, fileSize: pkg.fileSize.toString(), contentHash: pkg.contentHash, archiveType: pkg.archiveType, fileCount: pkg.fileCount, isMultipart: pkg.isMultipart, hasPreview: pkg.previewData !== null, creator: pkg.creator, tags: pkg.tags, indexedAt: pkg.indexedAt.toISOString(), sourceChannel: pkg.sourceChannel, matchedFileCount: fileMatchMap.get(pkg.id) ?? 0, matchedByContent: fileMatchMap.has(pkg.id), })); return { items: mapped, pagination: { page: options.page, limit: options.limit, total, totalPages: Math.ceil(total / options.limit), }, }; } // Search packages only return listPackages({ page: options.page, limit: options.limit, sortBy: "indexedAt", order: "desc", }); } ``` - [ ] **Step 4: Verify app builds** Run: `npx tsc --noEmit` (from project root) Expected: Errors about `PackageRow` missing the new fields — this is expected, we fix it in the next task. - [ ] **Step 5: Commit** ```bash git add src/lib/telegram/types.ts src/lib/telegram/queries.ts git commit -m "feat: return per-package file match counts from searchPackages" ``` --- ## Task 5: Search Match Indicators — Frontend (Table) **Files:** - Modify: `src/app/(app)/stls/page.tsx:25-53` - Modify: `src/app/(app)/stls/_components/stl-table.tsx:26-32, 34-40, 78-110, 116-168` - Modify: `src/app/(app)/stls/_components/package-columns.tsx:10-26, 28-32, 61-65, 75-88` - [ ] **Step 1: Add match fields to PackageRow** In `src/app/(app)/stls/_components/package-columns.tsx`, add to the `PackageRow` interface (after `sourceChannel`, line ~22-26): ```typescript matchedFileCount: number; matchedByContent: boolean; ``` - [ ] **Step 2: Add searchTerm to PackageColumnsProps and render match badge** In `src/app/(app)/stls/_components/package-columns.tsx`, add `searchTerm` to `PackageColumnsProps` (line ~28-32): ```typescript interface PackageColumnsProps { onViewFiles: (pkg: PackageRow) => void; onSetCreator: (pkg: PackageRow) => void; onSetTags: (pkg: PackageRow) => void; searchTerm: string; } ``` Update the `getPackageColumns` destructuring to include `searchTerm`: ```typescript export function getPackageColumns({ onViewFiles, onSetCreator, onSetTags, searchTerm, }: PackageColumnsProps): ColumnDef[] { ``` Update the `fileName` column cell (line ~78-88) to render the match badge: ```typescript { accessorKey: "fileName", header: ({ column }) => , cell: ({ row }) => (
{row.original.fileName} {row.original.isMultipart && ( Multi )}
{searchTerm && row.original.matchedByContent && ( )}
), enableHiding: false, }, ``` - [ ] **Step 3: Pass searchTerm from page to StlTable** In `src/app/(app)/stls/page.tsx`, pass `search` to `StlTable` (line ~45-53): ```typescript return ( ); ``` - [ ] **Step 4: Accept searchTerm in StlTable and pipe to columns/drawer** In `src/app/(app)/stls/_components/stl-table.tsx`: Add `searchTerm` to `StlTableProps` (line ~26-32): ```typescript interface StlTableProps { data: PackageRow[]; pageCount: number; totalCount: number; ingestionStatus: IngestionAccountStatus[]; availableTags: string[]; searchTerm: string; } ``` Add `searchTerm` to the destructured props (line ~34-40): ```typescript export function StlTable({ data, pageCount, totalCount, ingestionStatus, availableTags, searchTerm, }: StlTableProps) { ``` Pass `searchTerm` to `getPackageColumns` (line ~78): ```typescript const columns = getPackageColumns({ onViewFiles: (pkg) => setViewPkg(pkg), // ... existing handlers unchanged ... searchTerm, }); ``` **Note:** Do NOT pass `highlightTerm` to `PackageFilesDrawer` yet — that prop is added in Task 6. It will be wired in Task 6 Step 1 as part of updating the drawer. - [ ] **Step 5: Verify app builds** Run: `npm run build` Expected: Build succeeds - [ ] **Step 6: Commit** ```bash git add src/app/(app)/stls/page.tsx src/app/(app)/stls/_components/stl-table.tsx src/app/(app)/stls/_components/package-columns.tsx git commit -m "feat: show file match count badge in search results" ``` --- ## Task 6: Search Match Indicators — File Drawer Highlighting **Files:** - Modify: `src/app/(app)/stls/_components/package-files-drawer.tsx:51-55, 118-128, 186-210, 226, 477-504` - [ ] **Step 1: Add highlightTerm to PackageFilesDrawerProps** In `src/app/(app)/stls/_components/package-files-drawer.tsx`, update the props interface (line ~51-55): ```typescript interface PackageFilesDrawerProps { pkg: PackageRow | null; open: boolean; onOpenChange: (open: boolean) => void; highlightTerm?: string; } ``` Update the component signature (line ~226): ```typescript export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: PackageFilesDrawerProps) { ``` - [ ] **Step 2: Add a helper to check if a file matches the highlight term** Add a helper function near the top of the file (after the `getExtBadgeClass` function): ```typescript function fileMatchesHighlight(file: FileItem, term: string): boolean { if (!term) return false; const lower = term.toLowerCase(); return ( file.fileName.toLowerCase().includes(lower) || file.path.toLowerCase().includes(lower) ); } ``` - [ ] **Step 3: Add highlight term to TreeNodeView props and highlight matching files** Update the `TreeNodeView` props to accept `highlightTerm` (line ~118-128): ```typescript function TreeNodeView({ node, depth, search, defaultOpen, highlightTerm, }: { node: TreeNode; depth: number; search: string; defaultOpen: boolean; highlightTerm?: string; }) { ``` Add a helper inside `TreeNodeView` to check if a subtree contains highlighted files: ```typescript const hasHighlightedDescendant = useMemo(() => { if (!highlightTerm) return false; function check(n: TreeNode): boolean { if (n.file && fileMatchesHighlight(n.file, highlightTerm!)) return true; for (const child of n.children.values()) { if (check(child)) return true; } return false; } return check(node); }, [node, highlightTerm]); ``` Update the `useEffect` for auto-expanding to also expand when there are highlighted descendants (line ~141-143): ```typescript useEffect(() => { if (search || hasHighlightedDescendant) setOpen(true); }, [search, hasHighlightedDescendant]); ``` In the file node rendering (line ~186-210), add a highlight class when the file matches: ```typescript // File node if (node.file) { const isHighlighted = highlightTerm ? fileMatchesHighlight(node.file, highlightTerm) : false; return (
{node.name} {node.file.extension && ( .{node.file.extension} )} {formatBytes(node.file.uncompressedSize)}
); } ``` Pass `highlightTerm` through recursive `TreeNodeView` calls (line ~173-181): ```typescript {open && sortedChildren.map((child) => ( ))} ``` - [ ] **Step 4: Pass highlightTerm to TreeNodeView from the main render** In the `PackageFilesDrawer` component, where `TreeNodeView` is rendered for root children (line ~468-475): ```typescript ``` - [ ] **Step 5: Add highlighting to the flat list render path too** In the flat list render path (line ~477-504), add the same highlight logic: ```typescript {filtered.map((file) => { const isHighlighted = highlightTerm ? fileMatchesHighlight(file, highlightTerm) : false; return (

{file.fileName}

{file.extension && ( .{file.extension} )} {formatBytes(file.uncompressedSize)}
); })} ``` - [ ] **Step 6: Wire highlightTerm prop in StlTable** In `src/app/(app)/stls/_components/stl-table.tsx`, update the `PackageFilesDrawer` usage to pass the prop: ```typescript { if (!open) setViewPkg(null); }} highlightTerm={searchTerm} /> ``` - [ ] **Step 7: Verify app builds and lint passes** Run: `npm run build && npm run lint` Expected: Both pass - [ ] **Step 8: Commit** ```bash git add src/app/(app)/stls/_components/package-files-drawer.tsx src/app/(app)/stls/_components/stl-table.tsx git commit -m "feat: highlight matching files in package drawer when opened from search" ``` --- ## Task 7: Skipped/Failed Packages — App Queries & Types **Files:** - Modify: `src/lib/telegram/types.ts` - Modify: `src/lib/telegram/queries.ts` - [ ] **Step 1: Add SkippedPackageItem type** In `src/lib/telegram/types.ts`, add after the `PackageFileItem` interface: ```typescript export interface SkippedPackageItem { id: string; fileName: string; fileSize: string; reason: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED"; errorMessage: string | null; sourceChannel: { id: string; title: string; }; sourceMessageId: string; isMultipart: boolean; partCount: number; createdAt: string; } ``` - [ ] **Step 2: Add query functions for skipped packages** In `src/lib/telegram/queries.ts`, add these functions: ```typescript export async function listSkippedPackages(options: { page: number; limit: number; reason?: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED"; }) { const where: Record = {}; if (options.reason) where.reason = options.reason; const [items, total] = await Promise.all([ prisma.skippedPackage.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.limit, take: options.limit, include: { sourceChannel: { select: { id: true, title: true } }, }, }), prisma.skippedPackage.count({ where }), ]); const mapped: SkippedPackageItem[] = items.map((s) => ({ id: s.id, fileName: s.fileName, fileSize: s.fileSize.toString(), reason: s.reason, errorMessage: s.errorMessage, sourceChannel: s.sourceChannel, sourceMessageId: s.sourceMessageId.toString(), isMultipart: s.isMultipart, partCount: s.partCount, createdAt: s.createdAt.toISOString(), })); return { items: mapped, pagination: { page: options.page, limit: options.limit, total, totalPages: Math.ceil(total / options.limit), }, }; } export async function countSkippedPackages(): Promise { return prisma.skippedPackage.count(); } ``` Add `SkippedPackageItem` to the import in queries.ts: ```typescript import type { PackageListItem, PackageDetail, PackageFileItem, IngestionAccountStatus, SkippedPackageItem, } from "./types"; ``` - [ ] **Step 3: Verify app builds** Run: `npx tsc --noEmit` Expected: No errors - [ ] **Step 4: Commit** ```bash git add src/lib/telegram/types.ts src/lib/telegram/queries.ts git commit -m "feat: add query functions for listing skipped/failed packages" ``` --- ## Task 8: Skipped/Failed Packages — Retry Server Actions **Files:** - Modify: `src/app/(app)/stls/actions.ts` - [ ] **Step 1: Add retry server actions** In `src/app/(app)/stls/actions.ts`, add: ```typescript export async function retrySkippedPackageAction( id: string ): Promise { const session = await auth(); if (!session?.user?.id) return { success: false, error: "Unauthorized" }; try { const skipped = await prisma.skippedPackage.findUnique({ where: { id }, }); if (!skipped) return { success: false, error: "Skipped package not found" }; // Find the AccountChannelMap and reset watermark if needed const mapping = await prisma.accountChannelMap.findUnique({ where: { accountId_channelId: { accountId: skipped.accountId, channelId: skipped.sourceChannelId, }, }, }); if (mapping) { const targetId = skipped.sourceMessageId - 1n; // Only reset if the watermark is past this message if (mapping.lastProcessedMessageId && mapping.lastProcessedMessageId >= skipped.sourceMessageId) { await prisma.accountChannelMap.update({ where: { id: mapping.id }, data: { lastProcessedMessageId: targetId }, }); } // Also reset TopicProgress if this was a forum topic message if (skipped.sourceTopicId) { const topicProgress = await prisma.topicProgress.findFirst({ where: { accountChannelMapId: mapping.id, topicId: skipped.sourceTopicId, }, }); if (topicProgress && topicProgress.lastProcessedMessageId && topicProgress.lastProcessedMessageId >= skipped.sourceMessageId) { await prisma.topicProgress.update({ where: { id: topicProgress.id }, data: { lastProcessedMessageId: targetId }, }); } } } // Delete the skip record await prisma.skippedPackage.delete({ where: { id } }); revalidatePath("/stls"); return { success: true, data: undefined }; } catch { return { success: false, error: "Failed to retry skipped package" }; } } export async function retryAllSkippedPackagesAction( reason?: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED" ): Promise { const session = await auth(); if (!session?.user?.id) return { success: false, error: "Unauthorized" }; try { const where: Record = {}; if (reason) where.reason = reason; const skippedItems = await prisma.skippedPackage.findMany({ where }); if (skippedItems.length === 0) { return { success: true, data: undefined }; } // Group by (accountId, channelId) to find minimum messageId per channel const channelResets = new Map }>(); for (const item of skippedItems) { const key = `${item.accountId}:${item.sourceChannelId}`; const existing = channelResets.get(key); const targetId = item.sourceMessageId - 1n; if (!existing) { const topicResets = new Map(); if (item.sourceTopicId) { topicResets.set(item.sourceTopicId, targetId); } channelResets.set(key, { mappingKey: { accountId: item.accountId, channelId: item.sourceChannelId }, minMessageId: targetId, topicResets, }); } else { if (targetId < existing.minMessageId) { existing.minMessageId = targetId; } if (item.sourceTopicId) { const existingTopic = existing.topicResets.get(item.sourceTopicId); if (!existingTopic || targetId < existingTopic) { existing.topicResets.set(item.sourceTopicId, targetId); } } } } // Reset watermarks for (const reset of channelResets.values()) { const mapping = await prisma.accountChannelMap.findUnique({ where: { accountId_channelId: reset.mappingKey }, }); if (!mapping) continue; if (mapping.lastProcessedMessageId && mapping.lastProcessedMessageId > reset.minMessageId) { await prisma.accountChannelMap.update({ where: { id: mapping.id }, data: { lastProcessedMessageId: reset.minMessageId }, }); } // Reset topic progress for (const [topicId, targetId] of reset.topicResets) { const topicProgress = await prisma.topicProgress.findFirst({ where: { accountChannelMapId: mapping.id, topicId }, }); if (topicProgress && topicProgress.lastProcessedMessageId && topicProgress.lastProcessedMessageId > targetId) { await prisma.topicProgress.update({ where: { id: topicProgress.id }, data: { lastProcessedMessageId: targetId }, }); } } } // Delete all matching skip records await prisma.skippedPackage.deleteMany({ where }); revalidatePath("/stls"); return { success: true, data: undefined }; } catch { return { success: false, error: "Failed to retry skipped packages" }; } } ``` - [ ] **Step 2: Verify app builds** Run: `npx tsc --noEmit` Expected: No errors - [ ] **Step 3: Commit** ```bash git add src/app/(app)/stls/actions.ts git commit -m "feat: add retry server actions for skipped/failed packages" ``` --- ## Task 9: Skipped/Failed Packages — UI Components **Files:** - Create: `src/app/(app)/stls/_components/skipped-columns.tsx` - Create: `src/app/(app)/stls/_components/skipped-packages-tab.tsx` - [ ] **Step 1: Create skipped package column definitions** Create `src/app/(app)/stls/_components/skipped-columns.tsx`: ```typescript "use client"; import { type ColumnDef } from "@tanstack/react-table"; import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { RotateCw } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; export interface SkippedRow { id: string; fileName: string; fileSize: string; reason: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED"; errorMessage: string | null; sourceChannel: { id: string; title: string }; isMultipart: boolean; partCount: number; createdAt: string; } function formatBytes(bytesStr: string): string { const bytes = Number(bytesStr); if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } const REASON_LABELS: Record = { SIZE_LIMIT: { label: "Size Limit", variant: "secondary" }, DOWNLOAD_FAILED: { label: "Download Failed", variant: "destructive" }, EXTRACT_FAILED: { label: "Extract Failed", variant: "destructive" }, UPLOAD_FAILED: { label: "Upload Failed", variant: "destructive" }, }; export function getSkippedColumns({ onRetry, }: { onRetry: (row: SkippedRow) => void; }): ColumnDef[] { return [ { accessorKey: "fileName", header: ({ column }) => , cell: ({ row }) => (
{row.original.fileName} {row.original.isMultipart && ( {row.original.partCount} parts )}
), enableHiding: false, }, { accessorKey: "fileSize", header: ({ column }) => , cell: ({ row }) => ( {formatBytes(row.original.fileSize)} ), }, { accessorKey: "reason", header: ({ column }) => , cell: ({ row }) => { const { label, variant } = REASON_LABELS[row.original.reason]; return {label}; }, }, { accessorKey: "errorMessage", header: "Error", cell: ({ row }) => { const msg = row.original.errorMessage; if (!msg) return {"\u2014"}; return ( {msg}

{msg}

); }, }, { id: "channel", header: ({ column }) => , cell: ({ row }) => ( {row.original.sourceChannel.title} ), accessorFn: (row) => row.sourceChannel.title, }, { accessorKey: "createdAt", header: ({ column }) => , cell: ({ row }) => ( {new Date(row.original.createdAt).toLocaleDateString()} ), }, { id: "actions", cell: ({ row }) => ( ), enableHiding: false, }, ]; } ``` - [ ] **Step 2: Create skipped packages tab component** Create `src/app/(app)/stls/_components/skipped-packages-tab.tsx`: ```typescript "use client"; import { useTransition } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { RotateCw } from "lucide-react"; import { useDataTable } from "@/hooks/use-data-table"; import { getSkippedColumns, type SkippedRow } from "./skipped-columns"; import { DataTable } from "@/components/shared/data-table"; import { DataTablePagination } from "@/components/shared/data-table-pagination"; import { Button } from "@/components/ui/button"; import { retrySkippedPackageAction, retryAllSkippedPackagesAction } from "../actions"; interface SkippedPackagesTabProps { data: SkippedRow[]; pageCount: number; totalCount: number; } export function SkippedPackagesTab({ data, pageCount, totalCount, }: SkippedPackagesTabProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const columns = getSkippedColumns({ onRetry: (row) => { startTransition(async () => { const result = await retrySkippedPackageAction(row.id); if (result.success) { toast.success(`"${row.fileName}" queued for retry`); router.refresh(); } else { toast.error(result.error); } }); }, }); const { table } = useDataTable({ data, columns, pageCount }); return (
{totalCount > 0 && (
)}
); } ``` - [ ] **Step 3: Commit** ```bash git add src/app/(app)/stls/_components/skipped-columns.tsx src/app/(app)/stls/_components/skipped-packages-tab.tsx git commit -m "feat: add skipped/failed packages table UI components" ``` --- ## Task 10: Wire Up Tabs in STL Page **Files:** - Modify: `src/app/(app)/stls/page.tsx` - Modify: `src/app/(app)/stls/_components/stl-table.tsx` - [ ] **Step 1: Fetch skipped packages data in page.tsx** In `src/app/(app)/stls/page.tsx`, update imports and data fetching: ```typescript import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages } from "@/lib/telegram/queries"; import { StlTable } from "./_components/stl-table"; interface Props { searchParams: Promise>; } export default async function StlFilesPage({ searchParams }: Props) { const session = await auth(); if (!session?.user?.id) redirect("/login"); const params = await searchParams; const page = Number(params.page) || 1; const perPage = Number(params.perPage) || 20; const sort = (params.sort as string) ?? "indexedAt"; const order = (params.order as "asc" | "desc") ?? "desc"; const search = (params.search as string) ?? ""; const creator = (params.creator as string) || undefined; const tag = (params.tag as string) || undefined; const tab = (params.tab as string) ?? "packages"; // Fetch packages, ingestion status, tags, and skipped count in parallel const [result, ingestionStatus, availableTags, skippedCount] = await Promise.all([ search ? searchPackages({ query: search, page, limit: perPage, searchIn: "both", }) : listPackages({ page, limit: perPage, creator, tag, sortBy: sort as "indexedAt" | "fileName" | "fileSize", order, }), getIngestionStatus(), getAllPackageTags(), countSkippedPackages(), ]); // Fetch skipped packages only if on that tab const skippedResult = tab === "skipped" ? await listSkippedPackages({ page, limit: perPage }) : null; return ( ); } ``` - [ ] **Step 2: Add tabs to StlTable** In `src/app/(app)/stls/_components/stl-table.tsx`, add the tab UI. Update imports: ```typescript import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { SkippedPackagesTab } from "./skipped-packages-tab"; import type { SkippedRow } from "./skipped-columns"; ``` Update props: ```typescript interface StlTableProps { data: PackageRow[]; pageCount: number; totalCount: number; ingestionStatus: IngestionAccountStatus[]; availableTags: string[]; searchTerm: string; skippedData: SkippedRow[]; skippedPageCount: number; skippedTotalCount: number; } ``` Update the component to use tabs. The return JSX should become: ```typescript const activeTab = searchParams.get("tab") ?? "packages"; const updateTab = useCallback( (value: string) => { const params = new URLSearchParams(searchParams.toString()); if (value === "packages") { params.delete("tab"); } else { params.set("tab", value); } params.set("page", "1"); router.push(`${pathname}?${params.toString()}`, { scroll: false }); }, [router, pathname, searchParams] ); return (
Packages Skipped / Failed {skippedTotalCount > 0 && ( {skippedTotalCount} )}
updateSearch(e.target.value)} className="pl-9 h-9" />
{availableTags.length > 0 && ( )}
{ if (!open) setViewPkg(null); }} highlightTerm={searchTerm} />
); ``` Make sure to add the new props to the destructured params and add the `updateTab` callback. Remove the old JSX that is now inside `TabsContent`. - [ ] **Step 3: Verify the Tabs component exists** Check if `@/components/ui/tabs` exists. If not, install it: Run: `npx shadcn@latest add tabs` (if missing) - [ ] **Step 4: Verify app builds and lint passes** Run: `npm run build && npm run lint` Expected: Both pass - [ ] **Step 5: Commit** ```bash git add src/app/(app)/stls/page.tsx src/app/(app)/stls/_components/stl-table.tsx git commit -m "feat: add skipped/failed packages tab to STL files page" ``` --- ## Task 11: Final Build Verification - [ ] **Step 1: Full build check** Run: `npm run build` Expected: Build succeeds - [ ] **Step 2: Lint check** Run: `npm run lint` Expected: No errors - [ ] **Step 3: Worker build check** Run: `cd worker && npx tsc --noEmit` Expected: No errors - [ ] **Step 4: Prisma generate check** Run: `npx prisma generate` Expected: Success