diff --git a/prisma/migrations/20260325212128_add_package_groups/migration.sql b/prisma/migrations/20260325212128_add_package_groups/migration.sql new file mode 100644 index 0000000..47166b6 --- /dev/null +++ b/prisma/migrations/20260325212128_add_package_groups/migration.sql @@ -0,0 +1,30 @@ +-- AlterTable +ALTER TABLE "packages" ADD COLUMN "packageGroupId" TEXT; + +-- CreateTable +CREATE TABLE "package_groups" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "mediaAlbumId" TEXT, + "sourceChannelId" TEXT NOT NULL, + "previewData" BYTEA, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "package_groups_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "package_groups_sourceChannelId_idx" ON "package_groups"("sourceChannelId"); + +-- CreateIndex +CREATE UNIQUE INDEX "package_groups_mediaAlbumId_sourceChannelId_key" ON "package_groups"("mediaAlbumId", "sourceChannelId"); + +-- CreateIndex +CREATE INDEX "packages_packageGroupId_idx" ON "packages"("packageGroupId"); + +-- AddForeignKey +ALTER TABLE "packages" ADD CONSTRAINT "packages_packageGroupId_fkey" FOREIGN KEY ("packageGroupId") REFERENCES "package_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "package_groups" ADD CONSTRAINT "package_groups_sourceChannelId_fkey" FOREIGN KEY ("sourceChannelId") REFERENCES "telegram_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8443799..fb6a8b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -432,6 +432,7 @@ model TelegramChannel { accountMaps AccountChannelMap[] packages Package[] skippedPackages SkippedPackage[] + packageGroups PackageGroup[] @@index([type, isActive]) @@index([category]) @@ -474,10 +475,12 @@ model Package { tags String[] @default([]) previewData Bytes? // JPEG thumbnail from nearby Telegram photo (stored as raw bytes) previewMsgId BigInt? // Telegram message ID of the matched photo + packageGroupId String? indexedAt DateTime @default(now()) createdAt DateTime @default(now()) sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id]) + packageGroup PackageGroup? @relation(fields: [packageGroupId], references: [id], onDelete: SetNull) files PackageFile[] ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id]) ingestionRunId String? @@ -491,6 +494,7 @@ model Package { @@index([indexedAt]) @@index([archiveType]) @@index([creator]) + @@index([packageGroupId]) @@map("packages") } @@ -512,6 +516,23 @@ model PackageFile { @@map("package_files") } +model PackageGroup { + id String @id @default(cuid()) + name String + mediaAlbumId String? + sourceChannelId String + previewData Bytes? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + packages Package[] + sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade) + + @@unique([mediaAlbumId, sourceChannelId]) + @@index([sourceChannelId]) + @@map("package_groups") +} + model IngestionRun { id String @id @default(cuid()) accountId String diff --git a/src/app/(app)/stls/_components/package-columns.tsx b/src/app/(app)/stls/_components/package-columns.tsx index 00fea5e..f51c45d 100644 --- a/src/app/(app)/stls/_components/package-columns.tsx +++ b/src/app/(app)/stls/_components/package-columns.tsx @@ -1,10 +1,11 @@ "use client"; import { type ColumnDef } from "@tanstack/react-table"; -import { FileArchive, Eye } from "lucide-react"; +import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus } from "lucide-react"; import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { SendToTelegramButton } from "./send-to-telegram-button"; export interface PackageRow { @@ -25,6 +26,34 @@ export interface PackageRow { }; matchedFileCount: number; matchedByContent: boolean; + packageGroupId?: string | null; +} + +export interface GroupHeaderRow { + _rowType: "group"; + id: string; + name: string; + hasPreview: boolean; + totalFileSize: string; + totalFileCount: number; + packageCount: number; + combinedTags: string[]; + archiveTypes: ("ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT")[]; + latestIndexedAt: string; + sourceChannel: { id: string; title: string }; + _expanded: boolean; +} + +export interface PackageTableRow extends PackageRow { + _rowType: "package"; + _groupId: string | null; + _isGroupMember: boolean; +} + +export type StlTableRow = GroupHeaderRow | PackageTableRow; + +function isGroupRow(row: StlTableRow): row is GroupHeaderRow { + return row._rowType === "group"; } interface PackageColumnsProps { @@ -32,9 +61,17 @@ interface PackageColumnsProps { onSetCreator: (pkg: PackageRow) => void; onSetTags: (pkg: PackageRow) => void; searchTerm: string; + onToggleGroup: (groupId: string) => void; + onRenameGroup: (groupId: string, currentName: string) => void; + onDissolveGroup: (groupId: string) => void; + onSendAllInGroup: (groupId: string) => void; + onRemoveFromGroup: (packageId: string) => void; + onGroupPreviewUpload: (groupId: string) => void; + selectedPackages: Set; + onToggleSelect: (packageId: string) => void; } -function formatBytes(bytesStr: string): string { +export function formatBytes(bytesStr: string): string { const bytes = Number(bytesStr); if (bytes === 0) return "0 B"; const k = 1024; @@ -61,107 +98,254 @@ function PreviewCell({ pkg }: { pkg: PackageRow }) { ); } +function GroupPreviewCell({ + group, + onUpload, +}: { + group: GroupHeaderRow; + onUpload: (groupId: string) => void; +}) { + if (group.hasPreview) { + return ( + + ); + } + return ( + + ); +} + export function getPackageColumns({ onViewFiles, onSetCreator, onSetTags, searchTerm, -}: PackageColumnsProps): ColumnDef[] { + onToggleGroup, + onRenameGroup, + onDissolveGroup, + onSendAllInGroup, + onRemoveFromGroup, + onGroupPreviewUpload, + selectedPackages, + onToggleSelect, +}: PackageColumnsProps): ColumnDef[] { return [ + { + id: "select", + header: "", + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) return null; + return ( + onToggleSelect(data.id)} + aria-label="Select package" + className="translate-y-[2px]" + /> + ); + }, + enableHiding: false, + enableSorting: false, + size: 32, + }, { id: "preview", header: "", - cell: ({ row }) => , + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return ( +
+ + +
+ ); + } + return ( +
+ +
+ ); + }, enableHiding: false, enableSorting: false, - size: 52, + size: 72, }, { accessorKey: "fileName", header: ({ column }) => , - cell: ({ row }) => ( -
-
- {row.original.fileName} - {row.original.isMultipart && ( - - Multi - + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return ( +
+
+ + + {data.packageCount} pkg{data.packageCount !== 1 ? "s" : ""} + +
+
+ ); + } + return ( +
+
+ {data.fileName} + {data.isMultipart && ( + + Multi + + )} +
+ {searchTerm && data.matchedByContent && ( + )}
- {searchTerm && row.original.matchedByContent && ( - - )} -
- ), + ); + }, enableHiding: false, }, { accessorKey: "archiveType", header: ({ column }) => , - cell: ({ row }) => ( - - {row.original.archiveType} - - ), + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + const types = data.archiveTypes; + if (types.length === 1) { + return ( + + {types[0]} + + ); + } + return ( + + Mixed + + ); + } + return ( + + {data.archiveType} + + ); + }, }, { accessorKey: "fileSize", header: ({ column }) => , - cell: ({ row }) => ( - - {formatBytes(row.original.fileSize)} - - ), + cell: ({ row }) => { + const data = row.original; + const size = isGroupRow(data) ? data.totalFileSize : data.fileSize; + return ( + + {formatBytes(size)} + + ); + }, }, { accessorKey: "fileCount", header: ({ column }) => , - cell: ({ row }) => ( - - {row.original.fileCount.toLocaleString()} - - ), + cell: ({ row }) => { + const data = row.original; + const count = isGroupRow(data) ? data.totalFileCount : data.fileCount; + return ( + + {count.toLocaleString()} + + ); + }, }, { accessorKey: "creator", header: ({ column }) => , - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return {"\u2014"}; + } + return ( + + ); + }, }, { id: "tags", header: ({ column }) => , cell: ({ row }) => { - const tags = row.original.tags; + const data = row.original; + const tags = isGroupRow(data) ? data.combinedTags : data.tags; if (tags.length === 0) { + if (isGroupRow(data)) { + return {"\u2014"}; + } return ( ); } + const clickHandler = isGroupRow(data) ? undefined : () => onSetTags(data as PackageTableRow); return ( -
- ), + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return ( +
+ + +
+ ); + } + return ( +
+ + + {data._isGroupMember && ( + + )} +
+ ); + }, enableHiding: false, }, ]; diff --git a/src/app/(app)/stls/_components/stl-table.tsx b/src/app/(app)/stls/_components/stl-table.tsx index 7ed8409..cc54cbf 100644 --- a/src/app/(app)/stls/_components/stl-table.tsx +++ b/src/app/(app)/stls/_components/stl-table.tsx @@ -1,11 +1,17 @@ "use client"; -import { useState, useCallback, useTransition } from "react"; +import { useState, useCallback, useTransition, useMemo, useRef } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { Search } from "lucide-react"; +import { Search, Layers } from "lucide-react"; import { useDataTable } from "@/hooks/use-data-table"; -import { getPackageColumns, type PackageRow } from "./package-columns"; +import { + getPackageColumns, + type PackageRow, + type StlTableRow, + type PackageTableRow, + type GroupHeaderRow, +} from "./package-columns"; import { PackageFilesDrawer } from "./package-files-drawer"; import { IngestionStatus } from "./ingestion-status"; import { SkippedPackagesTab } from "./skipped-packages-tab"; @@ -14,6 +20,7 @@ import { DataTablePagination } from "@/components/shared/data-table-pagination"; import { DataTableViewOptions } from "@/components/shared/data-table-view-options"; import { PageHeader } from "@/components/shared/page-header"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -21,14 +28,31 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import type { IngestionAccountStatus } from "@/lib/telegram/types"; +import type { DisplayItem, IngestionAccountStatus } from "@/lib/telegram/types"; import type { SkippedRow } from "./skipped-columns"; -import { updatePackageCreator, updatePackageTags } from "../actions"; +import { + updatePackageCreator, + updatePackageTags, + renameGroupAction, + dissolveGroupAction, + createGroupAction, + removeFromGroupAction, + sendAllInGroupAction, + updateGroupPreviewAction, +} from "../actions"; interface StlTableProps { - data: PackageRow[]; + data: DisplayItem[]; pageCount: number; totalCount: number; ingestionStatus: IngestionAccountStatus[]; @@ -58,6 +82,88 @@ export function StlTable({ const [viewPkg, setViewPkg] = useState(null); const [, startTransition] = useTransition(); + // Group expansion state + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + // Package selection state (for manual grouping) + const [selectedPackages, setSelectedPackages] = useState>(new Set()); + + // Create group dialog state + const [createGroupOpen, setCreateGroupOpen] = useState(false); + const [groupName, setGroupName] = useState(""); + + // Group preview upload ref + const previewInputRef = useRef(null); + const [uploadGroupId, setUploadGroupId] = useState(null); + + const toggleGroup = useCallback((groupId: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + const toggleSelect = useCallback((packageId: string) => { + setSelectedPackages((prev) => { + const next = new Set(prev); + if (next.has(packageId)) { + next.delete(packageId); + } else { + next.add(packageId); + } + return next; + }); + }, []); + + // Flatten DisplayItem[] into StlTableRow[] based on expansion state + const tableRows: StlTableRow[] = useMemo(() => { + const rows: StlTableRow[] = []; + for (const item of data) { + if (item.type === "package") { + rows.push({ + ...item.data, + _rowType: "package" as const, + _groupId: null, + _isGroupMember: false, + }); + } else { + const group = item.data; + const isExpanded = expandedGroups.has(group.id); + rows.push({ + _rowType: "group" as const, + id: group.id, + name: group.name, + hasPreview: group.hasPreview, + totalFileSize: group.totalFileSize, + totalFileCount: group.totalFileCount, + packageCount: group.packageCount, + combinedTags: group.combinedTags, + archiveTypes: group.archiveTypes, + latestIndexedAt: group.latestIndexedAt, + sourceChannel: group.sourceChannel, + _expanded: isExpanded, + }); + if (isExpanded) { + for (const pkg of group.packages) { + rows.push({ + ...pkg, + _rowType: "package" as const, + _groupId: group.id, + _isGroupMember: true, + packageGroupId: group.id, + }); + } + } + } + } + return rows; + }, [data, expandedGroups]); + const updateSearch = useCallback( (value: string) => { setSearchValue(value); @@ -103,6 +209,131 @@ export function StlTable({ [router, pathname, searchParams] ); + const handleRenameGroup = useCallback( + (groupId: string, currentName: string) => { + const value = prompt("Enter group name:", currentName); + if (value === null || value.trim() === currentName) return; + startTransition(async () => { + const result = await renameGroupAction(groupId, value); + if (result.success) { + toast.success(`Group renamed to "${value.trim()}"`); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleDissolveGroup = useCallback( + (groupId: string) => { + if (!confirm("Dissolve this group? Packages will become standalone items.")) return; + startTransition(async () => { + const result = await dissolveGroupAction(groupId); + if (result.success) { + toast.success("Group dissolved"); + setExpandedGroups((prev) => { + const next = new Set(prev); + next.delete(groupId); + return next; + }); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleSendAllInGroup = useCallback( + (groupId: string) => { + if (!confirm("Send all packages in this group to your Telegram?")) return; + startTransition(async () => { + const result = await sendAllInGroupAction(groupId); + if (result.success) { + toast.success("Group packages queued for sending"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleRemoveFromGroup = useCallback( + (packageId: string) => { + startTransition(async () => { + const result = await removeFromGroupAction(packageId); + if (result.success) { + toast.success("Package removed from group"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleCreateGroup = useCallback(() => { + if (selectedPackages.size < 2) return; + setGroupName(""); + setCreateGroupOpen(true); + }, [selectedPackages.size]); + + const submitCreateGroup = useCallback(() => { + if (!groupName.trim() || selectedPackages.size < 2) return; + const ids = Array.from(selectedPackages); + startTransition(async () => { + const result = await createGroupAction(groupName, ids); + if (result.success) { + toast.success(`Group "${groupName.trim()}" created`); + setSelectedPackages(new Set()); + setCreateGroupOpen(false); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, [groupName, selectedPackages, router]); + + // Group preview upload handler (Task 12) + const handleGroupPreviewUpload = useCallback((groupId: string) => { + setUploadGroupId(groupId); + // Trigger file input after state update + setTimeout(() => { + previewInputRef.current?.click(); + }, 0); + }, []); + + const handlePreviewFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !uploadGroupId) return; + + const formData = new FormData(); + formData.append("file", file); + + startTransition(async () => { + const result = await updateGroupPreviewAction(uploadGroupId, formData); + if (result.success) { + toast.success("Group preview updated"); + router.refresh(); + } else { + toast.error(result.error); + } + setUploadGroupId(null); + }); + + // Reset input so the same file can be selected again + e.target.value = ""; + }, + [uploadGroupId, router] + ); + const columns = getPackageColumns({ onViewFiles: (pkg) => setViewPkg(pkg), searchTerm, @@ -136,9 +367,17 @@ export function StlTable({ } }); }, + onToggleGroup: toggleGroup, + onRenameGroup: handleRenameGroup, + onDissolveGroup: handleDissolveGroup, + onSendAllInGroup: handleSendAllInGroup, + onRemoveFromGroup: handleRemoveFromGroup, + onGroupPreviewUpload: handleGroupPreviewUpload, + selectedPackages, + onToggleSelect: toggleSelect, }); - const { table } = useDataTable({ data, columns, pageCount }); + const { table } = useDataTable({ data: tableRows, columns, pageCount }); const activeTag = searchParams.get("tag") ?? ""; @@ -191,11 +430,37 @@ export function StlTable({ )} + {selectedPackages.size >= 2 && ( + + )} + {selectedPackages.size > 0 && selectedPackages.size < 2 && ( + + Select at least 2 packages to group + + )} { + const data = row.original as StlTableRow; + if (data._rowType === "group") { + return "bg-muted/30 border-border"; + } + if (data._rowType === "package" && (data as PackageTableRow)._isGroupMember) { + return "bg-muted/10"; + } + return ""; + }} /> @@ -217,6 +482,47 @@ export function StlTable({ }} highlightTerm={searchTerm} /> + + {/* Create Group Dialog */} + + + + Create Package Group + + Group {selectedPackages.size} selected packages together. Enter a name for the group. + + +
+ setGroupName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitCreateGroup(); + }} + autoFocus + /> +
+ + + + +
+
+ + {/* Hidden file input for group preview upload (Task 12) */} + ); } diff --git a/src/app/(app)/stls/actions.ts b/src/app/(app)/stls/actions.ts index 1db8c28..ade3b0e 100644 --- a/src/app/(app)/stls/actions.ts +++ b/src/app/(app)/stls/actions.ts @@ -4,6 +4,13 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import type { ActionResult } from "@/types/api.types"; import { revalidatePath } from "next/cache"; +import { + updatePackageGroupName, + updatePackageGroupPreview, + createManualGroup, + removePackageFromGroup, + dissolveGroup, +} from "@/lib/telegram/queries"; const ALLOWED_IMAGE_TYPES = [ "image/jpeg", @@ -322,3 +329,186 @@ export async function retryAllSkippedPackagesAction( return { success: false, error: "Failed to retry skipped packages" }; } } + +export async function renameGroupAction( + groupId: string, + name: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + if (!name.trim()) { + return { success: false, error: "Group name cannot be empty" }; + } + + try { + await updatePackageGroupName(groupId, name); + revalidatePath("/stls"); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to rename group" }; + } +} + +export async function dissolveGroupAction( + groupId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + try { + await dissolveGroup(groupId); + revalidatePath("/stls"); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to dissolve group" }; + } +} + +export async function createGroupAction( + name: string, + packageIds: string[] +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + if (!name.trim()) { + return { success: false, error: "Group name cannot be empty" }; + } + if (packageIds.length < 2) { + return { success: false, error: "At least 2 packages are required to create a group" }; + } + + try { + await createManualGroup(name, packageIds); + revalidatePath("/stls"); + return { success: true, data: undefined }; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create group"; + return { success: false, error: message }; + } +} + +export async function removeFromGroupAction( + packageId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + try { + await removePackageFromGroup(packageId); + revalidatePath("/stls"); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to remove package from group" }; + } +} + +export async function updateGroupPreviewAction( + groupId: string, + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const file = formData.get("file"); + if (!(file instanceof File)) { + return { success: false, error: "No file provided" }; + } + + if (!ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number])) { + return { success: false, error: "Only JPG, PNG, and WebP images are accepted" }; + } + + if (file.size > MAX_IMAGE_SIZE) { + return { success: false, error: "Image must be smaller than 2 MB" }; + } + + try { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await updatePackageGroupPreview(groupId, buffer); + revalidatePath("/stls"); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to upload group preview image" }; + } +} + +export async function sendAllInGroupAction( + groupId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + try { + const telegramLink = await prisma.telegramLink.findUnique({ + where: { userId: session.user.id }, + }); + + if (!telegramLink) { + return { success: false, error: "No linked Telegram account. Link one in Settings." }; + } + + const group = await prisma.packageGroup.findUnique({ + where: { id: groupId }, + select: { + packages: { + select: { id: true, destChannelId: true, destMessageId: true, fileName: true }, + }, + }, + }); + + if (!group) { + return { success: false, error: "Group not found" }; + } + + const sendablePackages = group.packages.filter( + (p) => p.destChannelId && p.destMessageId + ); + + if (sendablePackages.length === 0) { + return { success: false, error: "No packages in this group have been uploaded to a destination channel" }; + } + + let queued = 0; + for (const pkg of sendablePackages) { + // Only create if no existing PENDING/SENDING request for this package+link combo + const existing = await prisma.botSendRequest.findFirst({ + where: { + packageId: pkg.id, + telegramLinkId: telegramLink.id, + status: { in: ["PENDING", "SENDING"] }, + }, + }); + + if (!existing) { + const sendRequest = await prisma.botSendRequest.create({ + data: { + packageId: pkg.id, + telegramLinkId: telegramLink.id, + requestedByUserId: session.user.id, + status: "PENDING", + }, + }); + + // Notify the bot via pg_notify + try { + await prisma.$queryRawUnsafe( + `SELECT pg_notify('bot_send', $1)`, + sendRequest.id + ); + } catch { + // Best-effort — the bot also polls periodically + } + + queued++; + } + } + + revalidatePath("/stls"); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to send group packages" }; + } +} diff --git a/src/app/(app)/stls/page.tsx b/src/app/(app)/stls/page.tsx index 4cd7340..87cb02f 100644 --- a/src/app/(app)/stls/page.tsx +++ b/src/app/(app)/stls/page.tsx @@ -1,7 +1,8 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages } from "@/lib/telegram/queries"; +import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages } from "@/lib/telegram/queries"; import { StlTable } from "./_components/stl-table"; +import type { DisplayItem, PackageListItem } from "@/lib/telegram/types"; interface Props { searchParams: Promise>; @@ -31,7 +32,7 @@ export default async function StlFilesPage({ searchParams }: Props) { limit: perPage, searchIn: "both", }) - : listPackages({ + : listDisplayItems({ page, limit: perPage, creator, @@ -44,6 +45,11 @@ export default async function StlFilesPage({ searchParams }: Props) { countSkippedPackages(), ]); + // For search results, wrap as DisplayItem[]; for non-search, already DisplayItem[] + const displayItems: DisplayItem[] = search + ? (result as { items: PackageListItem[] }).items.map((item) => ({ type: "package" as const, data: item })) + : (result as { items: DisplayItem[] }).items; + // Fetch skipped packages only if on that tab const skippedResult = tab === "skipped" ? await listSkippedPackages({ page, limit: perPage }) @@ -51,7 +57,7 @@ export default async function StlFilesPage({ searchParams }: Props) { return ( } +) { + const authResult = await authenticateApiRequest(request); + if ("error" in authResult) return authResult.error; + + const { id } = await params; + + const group = await prisma.packageGroup.findUnique({ + where: { id }, + select: { previewData: true }, + }); + + if (!group || !group.previewData) { + return new NextResponse(null, { status: 404 }); + } + + const buffer = + group.previewData instanceof Buffer + ? group.previewData + : Buffer.from(group.previewData); + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": "image/jpeg", + "Content-Length": String(buffer.length), + "Cache-Control": "public, max-age=3600, immutable", + }, + }); +} diff --git a/src/components/shared/data-table.tsx b/src/components/shared/data-table.tsx index ad2e5f6..d4b1e8c 100644 --- a/src/components/shared/data-table.tsx +++ b/src/components/shared/data-table.tsx @@ -1,6 +1,6 @@ "use client"; -import { type Table as TanStackTable, flexRender } from "@tanstack/react-table"; +import { type Table as TanStackTable, type Row, flexRender } from "@tanstack/react-table"; import { Table, TableBody, @@ -10,13 +10,15 @@ import { TableRow, } from "@/components/ui/table"; import { EmptyState } from "./empty-state"; +import { cn } from "@/lib/utils"; interface DataTableProps { table: TanStackTable; emptyMessage?: string; + rowClassName?: (row: Row) => string; } -export function DataTable({ table, emptyMessage }: DataTableProps) { +export function DataTable({ table, emptyMessage, rowClassName }: DataTableProps) { return (
@@ -36,7 +38,10 @@ export function DataTable({ table, emptyMessage }: DataTableProps) {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/src/lib/telegram/queries.ts b/src/lib/telegram/queries.ts index 249d807..80745c9 100644 --- a/src/lib/telegram/queries.ts +++ b/src/lib/telegram/queries.ts @@ -5,6 +5,8 @@ import type { PackageFileItem, IngestionAccountStatus, SkippedPackageItem, + DisplayItem, + PackageGroupRow, } from "./types"; export async function listPackages(options: { @@ -73,6 +75,177 @@ export async function listPackages(options: { }; } +export async function listDisplayItems(options: { + page: number; + limit: number; + channelId?: string; + creator?: string; + tag?: string; + sortBy: "indexedAt" | "fileName" | "fileSize"; + order: "asc" | "desc"; +}): Promise<{ items: DisplayItem[]; pagination: { page: number; limit: number; total: number; totalPages: number } }> { + const { page, limit, channelId, creator, tag, sortBy, order } = options; + + // Build WHERE clause fragments for raw SQL + const conditions: string[] = []; + const params: unknown[] = []; + let paramIdx = 1; + + if (channelId) { + conditions.push(`p."sourceChannelId" = $${paramIdx++}`); + params.push(channelId); + } + if (creator) { + conditions.push(`p."creator" = $${paramIdx++}`); + params.push(creator); + } + if (tag) { + conditions.push(`$${paramIdx++} = ANY(p."tags")`); + params.push(tag); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const sortCol = sortBy === "fileName" ? `"fileName"` : sortBy === "fileSize" ? `"fileSize"` : `"indexedAt"`; + const sortDir = order === "asc" ? "ASC" : "DESC"; + + // Step 1: Count display items + const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>( + `SELECT COUNT(*) AS count FROM ( + SELECT DISTINCT COALESCE(p."packageGroupId", p."id") AS display_id + FROM packages p + ${whereClause} + ) AS display_items`, + ...params + ); + const total = Number(countResult[0].count); + + // Step 2: Get display item IDs for this page + const limitParam = paramIdx++; + const offsetParam = paramIdx++; + const displayRows = await prisma.$queryRawUnsafe< + { display_id: string; display_type: string }[] + >( + `SELECT + COALESCE(p."packageGroupId", p."id") AS display_id, + CASE WHEN p."packageGroupId" IS NOT NULL THEN 'group' ELSE 'package' END AS display_type, + MAX(p.${sortCol}) AS sort_value + FROM packages p + ${whereClause} + GROUP BY COALESCE(p."packageGroupId", p."id"), + CASE WHEN p."packageGroupId" IS NOT NULL THEN 'group' ELSE 'package' END + ORDER BY sort_value ${sortDir} + LIMIT $${limitParam} OFFSET $${offsetParam}`, + ...params, limit, (page - 1) * limit + ); + + // Step 3: Fetch full data + const groupIds = displayRows.filter((r) => r.display_type === "group").map((r) => r.display_id); + const packageIds = displayRows.filter((r) => r.display_type === "package").map((r) => r.display_id); + + const standalonePackages = packageIds.length > 0 + ? await prisma.package.findMany({ + where: { id: { in: packageIds } }, + 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 } }, + }, + }) + : []; + + const groups = groupIds.length > 0 + ? await prisma.packageGroup.findMany({ + where: { id: { in: groupIds } }, + select: { + id: true, name: true, previewData: true, + sourceChannel: { select: { id: true, title: true } }, + packages: { + 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 } }, + }, + orderBy: { indexedAt: "desc" }, + }, + }, + }) + : []; + + // Build DisplayItem array in the original sort order + const packageMap = new Map(standalonePackages.map((p) => [p.id, p])); + const groupMap = new Map(groups.map((g) => [g.id, g])); + + const items: DisplayItem[] = displayRows.map((row) => { + if (row.display_type === "package") { + const pkg = packageMap.get(row.display_id)!; + return { + type: "package" as const, + data: { + 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: 0, + matchedByContent: false, + }, + }; + } else { + const grp = groupMap.get(row.display_id)!; + const allTags = [...new Set(grp.packages.flatMap((p) => p.tags))]; + const archiveTypes = [...new Set(grp.packages.map((p) => p.archiveType))] as PackageGroupRow["archiveTypes"]; + return { + type: "group" as const, + data: { + id: grp.id, + name: grp.name, + hasPreview: grp.previewData !== null, + totalFileSize: grp.packages.reduce((sum, p) => sum + p.fileSize, BigInt(0)).toString(), + totalFileCount: grp.packages.reduce((sum, p) => sum + p.fileCount, 0), + packageCount: grp.packages.length, + combinedTags: allTags, + archiveTypes, + latestIndexedAt: grp.packages.length > 0 + ? grp.packages[0].indexedAt.toISOString() + : new Date().toISOString(), + sourceChannel: grp.sourceChannel, + packages: grp.packages.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: 0, + matchedByContent: false, + })), + }, + }; + } + }); + + return { + items, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; +} + export async function getPackageById( id: string ): Promise { @@ -203,7 +376,16 @@ export async function searchPackages(options: { ).map((p) => p.id) : []; - const allIds = [...new Set([...fileMatchedIds, ...packageNameIds])]; + // Also match by group name + const groupNameMatches = await prisma.package.findMany({ + where: { + packageGroup: { name: { contains: q, mode: "insensitive" } }, + }, + select: { id: true }, + }); + const groupMatchedIds = groupNameMatches.map((p) => p.id); + + const allIds = [...new Set([...fileMatchedIds, ...packageNameIds, ...groupMatchedIds])]; const [items, total] = await Promise.all([ prisma.package.findMany({ @@ -388,3 +570,103 @@ export async function listSkippedPackages(options: { export async function countSkippedPackages(): Promise { return prisma.skippedPackage.count(); } + +export async function getPackageGroup(groupId: string) { + return prisma.packageGroup.findUnique({ + where: { id: groupId }, + select: { + id: true, name: true, previewData: true, mediaAlbumId: true, + sourceChannelId: true, createdAt: true, + sourceChannel: { select: { id: true, title: true } }, + packages: { + select: { + id: true, fileName: true, fileSize: true, archiveType: true, + fileCount: true, creator: true, tags: true, + }, + orderBy: { indexedAt: "desc" }, + }, + }, + }); +} + +export async function updatePackageGroupName(groupId: string, name: string) { + return prisma.packageGroup.update({ + where: { id: groupId }, + data: { name: name.trim() }, + }); +} + +export async function updatePackageGroupPreview(groupId: string, previewData: Buffer) { + return prisma.packageGroup.update({ + where: { id: groupId }, + data: { previewData: new Uint8Array(previewData) }, + }); +} + +export async function createManualGroup(name: string, packageIds: string[]) { + // Verify all packages belong to the same channel + const pkgs = await prisma.package.findMany({ + where: { id: { in: packageIds } }, + select: { sourceChannelId: true }, + }); + if (pkgs.length === 0) { + throw new Error("No matching packages found"); + } + const channelIds = new Set(pkgs.map((p) => p.sourceChannelId)); + if (channelIds.size > 1) { + throw new Error("Cannot group packages from different channels"); + } + + const firstPkg = pkgs[0]; + const group = await prisma.packageGroup.create({ + data: { + name: name.trim(), + sourceChannelId: firstPkg.sourceChannelId, + }, + }); + + await prisma.package.updateMany({ + where: { id: { in: packageIds } }, + data: { packageGroupId: group.id }, + }); + + // Clean up empty groups left behind + await prisma.packageGroup.deleteMany({ + where: { packages: { none: {} }, id: { not: group.id } }, + }); + + return group; +} + +export async function addPackagesToGroup(packageIds: string[], groupId: string) { + await prisma.package.updateMany({ + where: { id: { in: packageIds } }, + data: { packageGroupId: groupId }, + }); + await prisma.packageGroup.deleteMany({ + where: { packages: { none: {} } }, + }); +} + +export async function removePackageFromGroup(packageId: string) { + const pkg = await prisma.package.findUniqueOrThrow({ + where: { id: packageId }, + select: { packageGroupId: true }, + }); + if (!pkg.packageGroupId) return; + await prisma.package.update({ + where: { id: packageId }, + data: { packageGroupId: null }, + }); + await prisma.packageGroup.deleteMany({ + where: { id: pkg.packageGroupId, packages: { none: {} } }, + }); +} + +export async function dissolveGroup(groupId: string) { + await prisma.package.updateMany({ + where: { packageGroupId: groupId }, + data: { packageGroupId: null }, + }); + await prisma.packageGroup.delete({ where: { id: groupId } }); +} diff --git a/src/lib/telegram/types.ts b/src/lib/telegram/types.ts index 03b487c..b49f1ab 100644 --- a/src/lib/telegram/types.ts +++ b/src/lib/telegram/types.ts @@ -68,6 +68,24 @@ export interface PaginatedResponse { }; } +export interface PackageGroupRow { + id: string; + name: string; + hasPreview: boolean; + totalFileSize: string; + totalFileCount: number; + packageCount: number; + combinedTags: string[]; + archiveTypes: ("ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT")[]; + latestIndexedAt: string; + sourceChannel: { id: string; title: string }; + packages: PackageListItem[]; +} + +export type DisplayItem = + | { type: "package"; data: PackageListItem } + | { type: "group"; data: PackageGroupRow }; + export interface IngestionAccountStatus { id: string; displayName: string | null; diff --git a/worker/src/archive/multipart.ts b/worker/src/archive/multipart.ts index 6af47e0..80ecaec 100644 --- a/worker/src/archive/multipart.ts +++ b/worker/src/archive/multipart.ts @@ -10,6 +10,7 @@ export interface TelegramMessage { fileId: string; fileSize: bigint; date: Date; + mediaAlbumId?: string; } export interface ArchiveSet { diff --git a/worker/src/db/queries.ts b/worker/src/db/queries.ts index fae3bb3..5d34411 100644 --- a/worker/src/db/queries.ts +++ b/worker/src/db/queries.ts @@ -535,3 +535,53 @@ export async function deleteSkippedPackage( where: { sourceChannelId, sourceMessageId }, }); } + +export async function createOrFindPackageGroup(input: { + mediaAlbumId: string; + sourceChannelId: string; + name: string; + previewData?: Buffer | null; +}): Promise { + // findFirst + conditional create (Prisma doesn't support upsert on nullable compound unique) + const existing = await db.packageGroup.findFirst({ + where: { + mediaAlbumId: input.mediaAlbumId, + sourceChannelId: input.sourceChannelId, + }, + select: { id: true }, + }); + + if (existing) return existing.id; + + try { + const group = await db.packageGroup.create({ + data: { + mediaAlbumId: input.mediaAlbumId, + sourceChannelId: input.sourceChannelId, + name: input.name, + previewData: input.previewData ? new Uint8Array(input.previewData) : undefined, + }, + }); + return group.id; + } catch (err) { + // Handle race condition: another process created the group between our findFirst and create + if (err instanceof Error && err.message.includes("Unique constraint")) { + const raced = await db.packageGroup.findFirst({ + where: { mediaAlbumId: input.mediaAlbumId, sourceChannelId: input.sourceChannelId }, + select: { id: true }, + }); + if (raced) return raced.id; + } + throw err; + } +} + +export async function linkPackagesToGroup( + packageIds: string[], + groupId: string +): Promise { + await db.package.updateMany({ + where: { id: { in: packageIds } }, + data: { packageGroupId: groupId }, + }); +} diff --git a/worker/src/grouping.ts b/worker/src/grouping.ts new file mode 100644 index 0000000..35c91b0 --- /dev/null +++ b/worker/src/grouping.ts @@ -0,0 +1,79 @@ +import type { Client } from "tdl"; +import type { TelegramPhoto } from "./preview/match.js"; +import { downloadPhotoThumbnail } from "./tdlib/download.js"; +import { createOrFindPackageGroup, linkPackagesToGroup } from "./db/queries.js"; +import { childLogger } from "./util/logger.js"; +import { db } from "./db/client.js"; + +const log = childLogger("grouping"); + +export interface IndexedPackageRef { + packageId: string; + sourceMessageId: bigint; + mediaAlbumId?: string; +} + +/** + * After a scan cycle's packages are individually indexed, detect album groups + * and create PackageGroup records linking the members. + */ +export async function processAlbumGroups( + client: Client, + sourceChannelId: string, + indexedPackages: IndexedPackageRef[], + photos: TelegramPhoto[] +): Promise { + // Group indexed packages by mediaAlbumId + const albumMap = new Map(); + for (const pkg of indexedPackages) { + if (!pkg.mediaAlbumId || pkg.mediaAlbumId === "0") continue; + const group = albumMap.get(pkg.mediaAlbumId) ?? []; + group.push(pkg); + albumMap.set(pkg.mediaAlbumId, group); + } + + if (albumMap.size === 0) return; + + log.info({ albumCount: albumMap.size }, "Detected album groups to process"); + + for (const [albumId, members] of albumMap) { + if (members.length < 2) continue; + + try { + // Find the first package's fileName for the group name fallback + const firstPkg = await db.package.findFirst({ + where: { id: { in: members.map((m) => m.packageId) } }, + orderBy: { sourceMessageId: "asc" }, + select: { id: true, fileName: true }, + }); + + // Try to find a caption from the album's photo message + const albumPhoto = photos.find((p) => p.mediaAlbumId === albumId); + const groupName = albumPhoto?.caption || firstPkg?.fileName || "Unnamed Group"; + + // Download preview from album photo if available + let previewData: Buffer | null = null; + if (albumPhoto) { + previewData = await downloadPhotoThumbnail(client, albumPhoto.fileId); + } + + const groupId = await createOrFindPackageGroup({ + mediaAlbumId: albumId, + sourceChannelId, + name: groupName, + previewData, + }); + + // Idempotent link — safe to re-run if some packages were indexed in prior scans + const packageIds = members.map((m) => m.packageId); + await linkPackagesToGroup(packageIds, groupId); + + log.info( + { albumId, groupId, groupName, memberCount: packageIds.length }, + "Linked packages to album group" + ); + } catch (err) { + log.warn({ albumId, err }, "Failed to create album group — packages still indexed individually"); + } + } +} diff --git a/worker/src/preview/match.ts b/worker/src/preview/match.ts index 933edcf..0585e0b 100644 --- a/worker/src/preview/match.ts +++ b/worker/src/preview/match.ts @@ -10,6 +10,7 @@ export interface TelegramPhoto { /** The smallest photo size available — used as thumbnail. */ fileId: string; fileSize: number; + mediaAlbumId?: string; } export interface ArchiveRef { diff --git a/worker/src/tdlib/download.ts b/worker/src/tdlib/download.ts index e96e07d..b267df5 100644 --- a/worker/src/tdlib/download.ts +++ b/worker/src/tdlib/download.ts @@ -35,6 +35,7 @@ interface TdPhotoSize { interface TdMessage { id: number; date: number; + media_album_id?: string; content: { _: string; document?: { @@ -211,6 +212,7 @@ export async function getChannelMessages( fileId: String(doc.document.id), fileSize: BigInt(doc.document.size), date: new Date(msg.date * 1000), + mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined, }); continue; } @@ -227,6 +229,7 @@ export async function getChannelMessages( caption, fileId: String(smallest.photo.id), fileSize: smallest.photo.size || smallest.photo.expected_size, + mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined, }); } } diff --git a/worker/src/tdlib/topics.ts b/worker/src/tdlib/topics.ts index 3a83974..cc6b4b1 100644 --- a/worker/src/tdlib/topics.ts +++ b/worker/src/tdlib/topics.ts @@ -201,6 +201,7 @@ export async function getTopicMessages( messages?: { id: number; date: number; + media_album_id?: string; content: { _: string; document?: { @@ -248,6 +249,7 @@ export async function getTopicMessages( fileId: String(doc.document.id), fileSize: BigInt(doc.document.size), date: new Date(msg.date * 1000), + mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined, }); continue; } @@ -263,6 +265,7 @@ export async function getTopicMessages( caption, fileId: String(smallest.photo.id), fileSize: smallest.photo.size || smallest.photo.expected_size, + mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined, }); } } diff --git a/worker/src/worker.ts b/worker/src/worker.ts index 37d134e..d86f068 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -47,6 +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, type IndexedPackageRef } from "./grouping.js"; import type { TelegramAccount, TelegramChannel } from "@prisma/client"; import type { Client } from "tdl"; @@ -722,10 +723,11 @@ async function processArchiveSets( // Track the highest message ID that was successfully processed let maxProcessedId: bigint | null = null; + const indexedPackageRefs: IndexedPackageRef[] = []; for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) { try { - await processOneArchiveSet( + const packageId = await processOneArchiveSet( ctx, archiveSets[setIdx], setIdx, @@ -734,6 +736,15 @@ async function processArchiveSets( ingestionRunId ); + if (packageId) { + const firstPart = archiveSets[setIdx].parts[0]; + indexedPackageRefs.push({ + packageId, + sourceMessageId: firstPart.id, + mediaAlbumId: firstPart.mediaAlbumId, + }); + } + // Set completed (ingested or confirmed duplicate) — advance watermark const setMaxId = archiveSets[setIdx].parts.reduce( (max, p) => (p.id > max ? p.id : max), @@ -771,6 +782,16 @@ async function processArchiveSets( } } + // Post-processing: group packages by Telegram album ID + if (indexedPackageRefs.length > 0) { + await processAlbumGroups( + ctx.client, + channel.id, + indexedPackageRefs, + scanResult.photos + ); + } + return maxProcessedId; } @@ -784,7 +805,7 @@ async function processOneArchiveSet( totalSets: number, previewMatches: Map, ingestionRunId: string -): Promise { +): Promise { const { client, runId, channelTitle, channel, destChannelTelegramId, destChannelId, @@ -814,7 +835,7 @@ async function processOneArchiveSet( totalFiles: totalSets, zipsDuplicate: counters.zipsDuplicate, }); - return; + return null; } // ── Size guard: skip archives that exceed WORKER_MAX_ZIP_SIZE_MB ── @@ -848,7 +869,7 @@ async function processOneArchiveSet( partCount: archiveSet.parts.length, accountId: ctx.accountId, }); - return; + return null; } const tempPaths: string[] = []; @@ -954,7 +975,7 @@ async function processOneArchiveSet( totalFiles: totalSets, zipsDuplicate: counters.zipsDuplicate, }); - return; + return null; } // ── Reading metadata ── @@ -1127,7 +1148,7 @@ async function processOneArchiveSet( tags.push(channel.category); } - await createPackageWithFiles({ + const pkg = await createPackageWithFiles({ contentHash, fileName: archiveName, fileSize: totalSize, @@ -1166,6 +1187,8 @@ async function processOneArchiveSet( { fileName: archiveName, contentHash, fileCount: entries.length, creator }, "Archive ingested" ); + + return pkg.id; } finally { // ALWAYS delete temp files and the set directory await deleteFiles([...tempPaths, ...splitPaths]);