42 KiB
Package Grouping 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: Group related packages that were posted together in Telegram so they appear as collapsible rows in the STL files table, with auto-detection via album IDs and manual grouping.
Architecture: New PackageGroup model links related Package records. The worker captures media_album_id during scanning and creates groups post-indexing. The app uses a two-step SQL query for paginated display items (groups + standalone packages). UI renders group rows with expand/collapse and supports manual group management.
Tech Stack: Prisma 7 (PostgreSQL), Next.js 16 (App Router, server components + server actions), TanStack Table, shadcn/ui, TDLib (tdl)
Spec: docs/superpowers/specs/2026-03-25-package-grouping-design.md
Testing: No test framework configured. Each task includes manual verification steps.
File Map
New Files
| File | Responsibility |
|---|---|
prisma/migrations/<timestamp>_add_package_groups/migration.sql |
Schema migration (auto-generated) |
src/app/api/groups/[id]/preview/route.ts |
Group preview image endpoint |
src/app/(app)/stls/_components/group-row.tsx |
Group row rendering (collapsed + expanded header) |
src/app/(app)/stls/_components/group-toolbar.tsx |
Toolbar for "Group Selected" action |
worker/src/grouping.ts |
Post-processing: album detection → PackageGroup creation |
Modified Files
| File | Changes |
|---|---|
prisma/schema.prisma |
Add PackageGroup model, add packageGroupId to Package, add back-relation to TelegramChannel |
worker/src/archive/multipart.ts |
Add mediaAlbumId? to TelegramMessage interface |
worker/src/preview/match.ts |
Add mediaAlbumId? to TelegramPhoto interface |
worker/src/tdlib/download.ts |
Capture media_album_id from TDLib messages in scan loop |
worker/src/tdlib/topics.ts |
Capture media_album_id from TDLib messages in forum topic scan loop |
worker/src/worker.ts |
Call grouping post-processing after package indexing loop |
worker/src/db/queries.ts |
Add createPackageGroup, linkPackagesToGroup functions |
src/lib/telegram/types.ts |
Add PackageGroupRow, DisplayItem union type |
src/lib/telegram/queries.ts |
Add listDisplayItems, getDisplayItemCount, group CRUD queries |
src/app/(app)/stls/actions.ts |
Add server actions for group rename, dissolve, create, remove member, update preview, send all |
src/app/(app)/stls/_components/package-columns.tsx |
Add chevron column, checkbox column, group-aware rendering |
src/app/(app)/stls/_components/stl-table.tsx |
Add expand/collapse state, selection state, group toolbar, integrate new display item data shape |
src/app/(app)/stls/page.tsx |
Switch from listPackages to listDisplayItems, pass group data to table |
Task 1: Prisma Schema Migration
Files:
-
Modify:
prisma/schema.prisma -
Step 1: Add PackageGroup model to schema
In prisma/schema.prisma, add the new model after the Package model (after line ~495):
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")
}
- Step 2: Add packageGroupId to Package model
In the Package model (around line 479, after previewMsgId), add:
packageGroupId String?
packageGroup PackageGroup? @relation(fields: [packageGroupId], references: [id], onDelete: SetNull)
And add this index alongside the existing indexes (after line ~493):
@@index([packageGroupId])
- Step 3: Add back-relation to TelegramChannel
In the TelegramChannel model (around line 435, after skippedPackages), add:
packageGroups PackageGroup[]
- Step 4: Generate and run the migration
cd /e/Projects/DragonsStash && npx prisma migrate dev --name add_package_groups
Expected: Migration creates package_groups table, adds packageGroupId column and index to packages, creates unique index on (mediaAlbumId, sourceChannelId).
- Step 5: Verify Prisma client generation
cd /e/Projects/DragonsStash && npm run db:generate
Expected: Prisma client generates without errors. prisma.packageGroup is available.
- Step 6: Commit
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat: add PackageGroup schema for album-based file grouping"
Task 2: Worker — Add mediaAlbumId to Interfaces
Files:
-
Modify:
worker/src/archive/multipart.ts -
Modify:
worker/src/preview/match.ts -
Step 1: Add mediaAlbumId to TelegramMessage
In worker/src/archive/multipart.ts, update the TelegramMessage interface (line 7-13):
export interface TelegramMessage {
id: bigint;
fileName: string;
fileId: string;
fileSize: bigint;
date: Date;
mediaAlbumId?: string;
}
- Step 2: Add mediaAlbumId to TelegramPhoto
In worker/src/preview/match.ts, update the TelegramPhoto interface (line 5-13):
export interface TelegramPhoto {
id: bigint;
date: Date;
/** Caption text on the photo message (if any). */
caption: string;
/** The smallest photo size available — used as thumbnail. */
fileId: string;
fileSize: number;
mediaAlbumId?: string;
}
- Step 3: Build worker to verify no type errors
cd /e/Projects/DragonsStash/worker && npm run build
Expected: Clean build. The new optional field doesn't break any existing call sites.
- Step 4: Commit
cd /e/Projects/DragonsStash && git add worker/src/archive/multipart.ts worker/src/preview/match.ts
git commit -m "feat: add mediaAlbumId to TelegramMessage and TelegramPhoto interfaces"
Task 3: Worker — Capture media_album_id During Scanning
Files:
-
Modify:
worker/src/tdlib/download.ts -
Step 1: Add media_album_id to TdMessage interface
In worker/src/tdlib/download.ts, update the TdMessage interface (lines 35-58) to add media_album_id:
interface TdMessage {
id: number;
date: number;
media_album_id?: string;
content: {
_: string;
document?: {
file_name?: string;
document?: {
id: number;
size: number;
local?: {
path?: string;
is_downloading_completed?: boolean;
};
};
};
photo?: {
sizes?: TdPhotoSize[];
};
caption?: {
text?: string;
};
};
}
- Step 2: Pass media_album_id through to TelegramMessage
In the getChannelMessages function, update the archive push block (around line 208-215). Change the archives.push call to include mediaAlbumId:
archives.push({
id: BigInt(msg.id),
fileName: doc.file_name,
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,
});
- Step 3: Pass media_album_id through to TelegramPhoto
In the same function, update the photo push block (around line 224-231). Change the photos.push call to include mediaAlbumId:
photos.push({
id: BigInt(msg.id),
date: new Date(msg.date * 1000),
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,
});
- Step 4: Add media_album_id to forum topic scanning
worker/src/tdlib/topics.ts has a parallel getTopicMessages function with its own inline message struct. Apply the same changes:
- Add
media_album_id?: stringto the inline TDLib message struct ingetTopicMessages - Update the
archives.pushblock to includemediaAlbumId - Update the
photos.pushblock to includemediaAlbumId
Use the exact same pattern as steps 2-3 above.
- Step 5: Build worker to verify
cd /e/Projects/DragonsStash/worker && npm run build
Expected: Clean build.
- Step 6: Commit
cd /e/Projects/DragonsStash && git add worker/src/tdlib/download.ts worker/src/tdlib/topics.ts
git commit -m "feat: capture media_album_id from TDLib messages during channel and topic scanning"
Task 4: Worker — Group DB Queries
Files:
-
Modify:
worker/src/db/queries.ts -
Step 1: Add createOrFindPackageGroup function
At the end of worker/src/db/queries.ts, add:
export async function createOrFindPackageGroup(input: {
mediaAlbumId: string;
sourceChannelId: string;
name: string;
previewData?: Buffer | null;
}): Promise<string> {
// 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;
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;
}
export async function linkPackagesToGroup(
packageIds: string[],
groupId: string
): Promise<void> {
await db.package.updateMany({
where: { id: { in: packageIds } },
data: { packageGroupId: groupId },
});
}
- Step 2: Build worker to verify
cd /e/Projects/DragonsStash/worker && npm run build
Expected: Clean build.
- Step 3: Commit
cd /e/Projects/DragonsStash && git add worker/src/db/queries.ts
git commit -m "feat: add createOrFindPackageGroup and linkPackagesToGroup worker queries"
Task 5: Worker — Grouping Post-Processing
Files:
-
Create:
worker/src/grouping.ts -
Modify:
worker/src/worker.ts -
Step 1: Create grouping.ts module
Create worker/src/grouping.ts:
import type { Client } from "tdl";
import type { TelegramMessage } from "./archive/multipart.js";
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");
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.
*
* - Collects indexed packages that share a non-zero mediaAlbumId
* - Creates (or finds existing) PackageGroup per album
* - Links all member packages via packageGroupId
* - Downloads album photo as group preview if available
*/
export async function processAlbumGroups(
client: Client,
sourceChannelId: string,
indexedPackages: IndexedPackageRef[],
photos: TelegramPhoto[]
): Promise<void> {
// Group indexed packages by mediaAlbumId
const albumMap = new Map<string, IndexedPackageRef[]>();
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; // Single-file albums aren't groups
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");
}
}
}
- Step 2: Integrate grouping into worker pipeline
In worker/src/worker.ts, find the processArchiveSets function. The function processes archive sets in a loop (around lines 726-772) and tracks maxProcessedId. After the processing loop ends, add the grouping step.
First, add the import at the top of worker.ts:
import { processAlbumGroups } from "./grouping.js";
Then, in the processArchiveSets function, add tracking for indexed packages. Near line 726 (before the archive set loop), add:
const indexedPackageRefs: { packageId: string; sourceMessageId: bigint; mediaAlbumId?: string }[] = [];
Inside the per-set processing (in processOneArchiveSet), after the createPackageWithFiles call (around line 1149), the function needs to return the created package ID. Since processOneArchiveSet is a void function called from processArchiveSets, modify processArchiveSets to capture the result.
The cleanest integration point: in the processArchiveSets loop body (around line 740), after a successful processOneArchiveSet call, query for the created package by contentHash or source message and push to indexedPackageRefs. But simpler: have processOneArchiveSet return the package ID.
Find the processOneArchiveSet function signature. Change its return type from Promise<void> to Promise<string | null> (returning the created package ID, or null if it reused an existing upload).
After the createPackageWithFiles call (around line 1149), capture the return value:
const pkg = await createPackageWithFiles({ ... });
// ... existing code after creation ...
return pkg.id;
Add return null; to the early-return paths (size guard, dedup skip).
Back in processArchiveSets, in the success branch of the try/catch (around line 740), capture the return:
const packageId = await processOneArchiveSet(/* ... */);
if (packageId) {
const firstPart = archiveSet.parts[0];
indexedPackageRefs.push({
packageId,
sourceMessageId: firstPart.id,
mediaAlbumId: firstPart.mediaAlbumId,
});
}
After the loop (around line 773, before return maxProcessedId), add:
// Post-processing: group packages by Telegram album ID
if (indexedPackageRefs.length > 0) {
await processAlbumGroups(
ctx.client,
channel.id,
indexedPackageRefs,
scanResult.photos
);
}
- Step 3: Build worker to verify
cd /e/Projects/DragonsStash/worker && npm run build
Expected: Clean build.
- Step 4: Commit
cd /e/Projects/DragonsStash && git add worker/src/grouping.ts worker/src/worker.ts
git commit -m "feat: add album grouping post-processing to worker pipeline"
Task 6: App — Types
Files:
-
Modify:
src/lib/telegram/types.ts -
Step 1: Add PackageGroupRow and DisplayItem types
At the end of src/lib/telegram/types.ts, add:
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 };
- Step 2: Verify app build
cd /e/Projects/DragonsStash && npm run build
Expected: Clean build. Types are exported but not yet consumed.
- Step 3: Commit
cd /e/Projects/DragonsStash && git add src/lib/telegram/types.ts
git commit -m "feat: add PackageGroupRow and DisplayItem types"
Task 7: App — Queries
Files:
-
Modify:
src/lib/telegram/queries.ts -
Step 1: Add listDisplayItems query
Add the following function to src/lib/telegram/queries.ts:
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: PaginatedResponse<never>["pagination"] }> {
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 ")}` : "";
// Sort column mapping
const sortCol = sortBy === "fileName" ? `"fileName"` : sortBy === "fileSize" ? `"fileSize"` : `"indexedAt"`;
const sortDir = order === "asc" ? "ASC" : "DESC";
// Step 1: Count display items
const countSql = `
SELECT COUNT(*) AS count FROM (
SELECT DISTINCT COALESCE(p."packageGroupId", p."id") AS display_id
FROM packages p
${whereClause}
) AS display_items
`;
const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>(countSql, ...params);
const total = Number(countResult[0].count);
// Step 2: Get display item IDs for this page
const itemsSql = `
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 $${paramIdx++} OFFSET $${paramIdx++}
`;
params.push(limit, (page - 1) * limit);
const displayRows = await prisma.$queryRawUnsafe<
{ display_id: string; display_type: "group" | "package" }[]
>(itemsSql, ...params);
// Step 3: Fetch full data for each display item
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);
// Fetch standalone packages
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 } },
},
})
: [];
// Fetch groups with their member packages
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))];
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, 0n).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) },
};
}
- Step 2: Add group CRUD queries
Add these functions to src/lib/telegram/queries.ts:
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 (cross-channel groups are not supported)
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,
},
});
// Move packages to new group (removes from any existing group)
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 },
});
// Clean up empty groups left behind
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 },
});
// Clean up empty group
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 } });
}
- Step 3: Add import for DisplayItem type
At the top of src/lib/telegram/queries.ts, ensure DisplayItem and PackageGroupRow are imported from ./types:
import type { PackageListItem, PackageDetail, PaginatedResponse, DisplayItem, PackageGroupRow } from "./types";
- Step 4: Update searchPackages to include group names
In the searchPackages function, add a LEFT JOIN to package_groups when building the query. When searchIn is "packages" or "both", add PackageGroup.name as an additional search target:
After the existing where: { fileName: { contains: query, mode: "insensitive" } } block for package name matching, also find packages whose group name matches:
// Also match by group name
const groupNameMatches = await prisma.package.findMany({
where: {
packageGroup: { name: { contains: query, mode: "insensitive" } },
},
select: { id: true },
});
const groupMatchIds = groupNameMatches.map((p) => p.id);
Merge groupMatchIds into the existing allIds set before the final query.
- Step 5: Verify app build
cd /e/Projects/DragonsStash && npm run build
Expected: Clean build.
- Step 6: Commit
cd /e/Projects/DragonsStash && git add src/lib/telegram/queries.ts src/lib/telegram/types.ts
git commit -m "feat: add listDisplayItems query, search by group name, and group CRUD operations"
Task 8: App — Group Preview API Route
Files:
-
Create:
src/app/api/groups/[id]/preview/route.ts -
Step 1: Create group preview endpoint
Create src/app/api/groups/[id]/preview/route.ts:
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
/**
* GET /api/groups/:id/preview
* Returns the group's preview thumbnail image as JPEG binary.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
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",
},
});
}
- Step 2: Verify app build
cd /e/Projects/DragonsStash && npm run build
- Step 3: Commit
cd /e/Projects/DragonsStash && git add src/app/api/groups/
git commit -m "feat: add group preview image API endpoint"
Task 9: App — Server Actions for Groups
Files:
-
Modify:
src/app/(app)/stls/actions.ts -
Step 1: Add group server actions
Import the new query functions at the top of src/app/(app)/stls/actions.ts:
import {
updatePackageGroupName,
updatePackageGroupPreview,
createManualGroup,
removePackageFromGroup,
dissolveGroup,
addPackagesToGroup,
} from "@/lib/telegram/queries";
Then add these server actions after the existing ones:
export async function renameGroupAction(
groupId: string,
name: string
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
if (!name.trim()) return { success: false, error: "Group name is required" };
await updatePackageGroupName(groupId, name);
revalidatePath("/stls");
return { success: true };
}
export async function dissolveGroupAction(groupId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
await dissolveGroup(groupId);
revalidatePath("/stls");
return { success: true };
}
export async function createGroupAction(
name: string,
packageIds: string[]
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
if (!name.trim()) return { success: false, error: "Group name is required" };
if (packageIds.length < 2) return { success: false, error: "Need at least 2 packages" };
await createManualGroup(name, packageIds);
revalidatePath("/stls");
return { success: true };
}
export async function removeFromGroupAction(packageId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
await removePackageFromGroup(packageId);
revalidatePath("/stls");
return { success: true };
}
export async function updateGroupPreviewAction(
groupId: string,
formData: FormData
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const file = formData.get("preview") as File | null;
if (!file) return { success: false, error: "No file provided" };
const buffer = Buffer.from(await file.arrayBuffer());
await updatePackageGroupPreview(groupId, buffer);
revalidatePath("/stls");
return { success: true };
}
export async function sendAllInGroupAction(groupId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
// Resolve the user's linked Telegram account (same pattern as /api/telegram/bot/send)
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 → Telegram." };
}
const group = await prisma.packageGroup.findUnique({
where: { id: groupId },
select: {
packages: {
where: { destChannelId: { not: null }, destMessageId: { not: null } },
select: { id: true },
},
},
});
if (!group) return { success: false, error: "Group not found" };
if (group.packages.length === 0) return { success: false, error: "No uploadable packages in group" };
// Queue send requests for each package, skipping those with existing pending/sending requests
for (const pkg of group.packages) {
const existingPending = await prisma.botSendRequest.findFirst({
where: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
status: { in: ["PENDING", "SENDING"] },
},
});
if (!existingPending) {
await prisma.botSendRequest.create({
data: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
}
}
revalidatePath("/stls");
return { success: true };
}
- Step 2: Add prisma import if not present
Make sure prisma is imported:
import { prisma } from "@/lib/prisma";
- Step 3: Verify app build
cd /e/Projects/DragonsStash && npm run build
- Step 4: Commit
cd /e/Projects/DragonsStash && git add src/app/(app)/stls/actions.ts
git commit -m "feat: add server actions for group rename, dissolve, create, remove, preview, and send all"
Task 10: App — Update Page to Use Display Items
Files:
-
Modify:
src/app/(app)/stls/page.tsx -
Step 1: Switch to listDisplayItems
In src/app/(app)/stls/page.tsx, update the imports to include listDisplayItems:
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, countSkippedPackages, listSkippedPackages } from "@/lib/telegram/queries";
Update the data fetch in the parallel Promise.all. Replace the listPackages call:
const [result, ingestionStatus, availableTags, skippedCount] = await Promise.all([
search
? searchPackages({ query: search, page, limit: perPage, searchIn: "both" })
: listDisplayItems({ page, limit: perPage, creator, tag, sortBy: sort as "indexedAt" | "fileName" | "fileSize", order }),
getIngestionStatus(),
getAllPackageTags(),
countSkippedPackages(),
]);
Update the props passed to StlTable — result.items is now DisplayItem[] when not searching, and PackageListItem[] when searching. The StlTable component will need to handle both, so wrap search results as DisplayItem[]:
const displayItems = search
? result.items.map((item: PackageListItem) => ({ type: "package" as const, data: item }))
: result.items;
Pass displayItems instead of result.items to StlTable.
- Step 2: Update StlTable props type
This will be done in Task 11 when we modify the table component. For now, just ensure the page passes the right data shape.
- Step 3: Commit
cd /e/Projects/DragonsStash && git add src/app/(app)/stls/page.tsx
git commit -m "feat: switch STL page from listPackages to listDisplayItems"
Task 11: App — UI Table with Group Support
Files:
- Modify:
src/app/(app)/stls/_components/stl-table.tsx - Modify:
src/app/(app)/stls/_components/package-columns.tsx - Create:
src/app/(app)/stls/_components/group-row.tsx - Create:
src/app/(app)/stls/_components/group-toolbar.tsx
This is the largest task. It modifies the table to render both group rows and package rows, with expand/collapse and selection for manual grouping.
- Step 1: Create group-row.tsx component
Create src/app/(app)/stls/_components/group-row.tsx. This component renders a single group as a collapsible row. When collapsed it shows aggregates; when expanded it shows a header row + indented member packages.
Key elements:
- Chevron toggle button (ChevronRight rotated when expanded)
- Preview thumbnail (from
/api/groups/${groupId}/previewor fallback icon) - Editable group name (click to edit inline, calls
renameGroupAction) - "Mixed" type badge or most common type
- Combined size, file count, tag badges
- Actions: Send All, Dissolve Group (with confirmation dialog)
- Expanded state renders member
PackageRowentries with indent and "Remove from group" action
Use the existing UI patterns from package-columns.tsx for consistency (same badge styles, size formatting, etc.).
- Step 2: Create group-toolbar.tsx component
Create src/app/(app)/stls/_components/group-toolbar.tsx. Shows when 2+ packages are selected:
-
"Group N Selected" button
-
Clicking it opens a dialog prompting for group name
-
On submit, calls
createGroupAction(name, selectedPackageIds) -
Clears selection after success
-
Step 3: Update package-columns.tsx
In src/app/(app)/stls/_components/package-columns.tsx:
Add a checkbox column as the first column for row selection:
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="h-4 w-4"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="h-4 w-4"
/>
),
enableSorting: false,
enableHiding: false,
size: 32,
}
Add a "Remove from group" option in the actions dropdown for packages that have a packageGroupId. This means PackageRow needs to carry packageGroupId: string | null so the cell can conditionally show the action.
- Step 4: Update stl-table.tsx
In src/app/(app)/stls/_components/stl-table.tsx:
Update StlTableProps to accept DisplayItem[]:
interface StlTableProps {
data: DisplayItem[];
pageCount: number;
totalCount: number;
// ... rest stays the same
}
Add state for:
expandedGroups: Set<string>— which group IDs are expanded- Row selection via TanStack Table's built-in selection
Render logic:
- Iterate over
dataitems - If
item.type === "group":- Render
<GroupRow>component - If expanded, render member packages as indented
<TableRow>entries using the existing column definitions
- Render
- If
item.type === "package":- Render normal
<TableRow>as today
- Render normal
Show <GroupToolbar> when selected count >= 2.
The DataTable component (src/components/shared/data-table.tsx) renders rows generically from TanStack Table. Since we need custom group rows interspersed, the cleanest approach is to not use the generic DataTable for the packages tab and instead render the table body directly in stl-table.tsx, similar to how DataTable does it but with group-awareness.
- Step 5: Verify app build
cd /e/Projects/DragonsStash && npm run build
- Step 6: Manual testing
- Start the dev environment:
npm run dev - Navigate to
/stls - Verify standalone packages render as before
- Create a manual group: select 2+ packages via checkboxes, click "Group Selected", enter a name
- Verify the group appears as a collapsed row with aggregated data
- Click the chevron to expand — member packages appear indented
- Click group name to edit it inline
- Test "Remove from group" on a member package
- Test "Dissolve Group" on the group row
- Test "Send All" on the group row
- Step 7: Commit
cd /e/Projects/DragonsStash && git add src/app/(app)/stls/_components/
git commit -m "feat: add group row rendering, expand/collapse, selection, and manual grouping UI"
Task 12: App — Group Preview Upload in UI
Files:
-
Modify:
src/app/(app)/stls/_components/group-row.tsx -
Step 1: Add preview upload to group row
In the group row's preview cell, make the thumbnail clickable. On click, open a file input dialog. On file selection, call updateGroupPreviewAction(groupId, formData).
Reuse the pattern from the existing package preview upload in package-files-drawer.tsx — it uses a hidden <input type="file"> triggered by a button click, then submits via FormData.
- Step 2: Verify and commit
cd /e/Projects/DragonsStash && npm run build
git add src/app/(app)/stls/_components/group-row.tsx
git commit -m "feat: add preview image upload to group rows"
Verification Checklist
After all tasks are complete, verify end-to-end:
- Worker builds cleanly:
cd worker && npm run build - App builds cleanly:
npm run build - Migration applied:
npm run db:migrate - Worker scans a channel with an album of files → PackageGroup created automatically
- STL table shows album groups as collapsed rows
- Expand/collapse works
- Manual grouping (select + group) works
- Group rename works
- Group dissolve works
- Remove from group works
- Send All works (queues requests for all members)
- Group preview upload works
- Search finds groups by name
- Filtering by tag/creator correctly shows groups when any member matches
- Pagination is correct (groups take one slot)