From 20b7d28fdfc15bc4210daeddb993311ca1a1fb2f Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Wed, 25 Mar 2026 22:32:47 +0100 Subject: [PATCH] feat: add listDisplayItems query, group CRUD, and search by group name Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/telegram/queries.ts | 281 +++++++++++++++++++++++++++++++++++- 1 file changed, 280 insertions(+), 1 deletion(-) diff --git a/src/lib/telegram/queries.ts b/src/lib/telegram/queries.ts index 249d807..60d6530 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,100 @@ 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 }, + }); + 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 } }); +}