From 0cf5fcd3a78e2cf990eddd96f2379ef9cad95f45 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Mon, 22 Jun 2026 19:05:34 +0200 Subject: [PATCH 1/7] fix(stls): stop loading preview blobs in list queries (OOM fix) The STLs list queries selected the full previewData JPEG bytes (avg ~700KB, up to 2MB, and every group member's preview) only to compute a hasPreview boolean. Under concurrent page loads this piled blobs into the Node heap, exceeded the 512MB container limit, crashed the process, and surfaced as repeated connection errors while browsing. Replace the byte-loading select with a fetchPreviewFlags() helper that checks `previewData IS NOT NULL` in SQL (IDs only, no bytes). Applied to listPackages, listDisplayItems, searchPackages, and listUngroupedPackages. Verified: same concurrent load that drove memory to 508/512MB and forced a restart now peaks at 156MB (30%) with zero failed requests and no restart. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/telegram/queries.ts | 68 ++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/src/lib/telegram/queries.ts b/src/lib/telegram/queries.ts index fbf6c3a..1c4a23b 100644 --- a/src/lib/telegram/queries.ts +++ b/src/lib/telegram/queries.ts @@ -9,6 +9,28 @@ import type { PackageGroupRow, } from "./types"; +/** + * Returns the subset of the given IDs whose `previewData` column is non-null, + * WITHOUT transferring the (large) image bytes. + * + * List views only need a `hasPreview` boolean per row. Selecting `previewData` + * directly pulls every JPEG blob (avg ~700 KB, up to 2 MB) into the Node heap + * just to compare it against null — under concurrent requests this exhausts the + * container memory limit and crashes the process. This keeps the check in SQL. + */ +async function fetchPreviewFlags( + table: "packages" | "package_groups", + ids: string[] +): Promise> { + if (ids.length === 0) return new Set(); + const placeholders = ids.map((_, i) => `$${i + 1}`).join(", "); + const rows = await prisma.$queryRawUnsafe<{ id: string }[]>( + `SELECT id FROM ${table} WHERE "previewData" IS NOT NULL AND id IN (${placeholders})`, + ...ids + ); + return new Set(rows.map((r) => r.id)); +} + export async function listPackages(options: { page: number; limit: number; @@ -40,13 +62,17 @@ export async function listPackages(options: { indexedAt: true, creator: true, tags: true, - previewData: true, // check actual image data, not previewMsgId proxy sourceChannel: { select: { id: true, title: true } }, }, }), prisma.package.count({ where }), ]); + const previewIds = await fetchPreviewFlags( + "packages", + items.map((p) => p.id) + ); + const mapped: PackageListItem[] = items.map((pkg) => ({ id: pkg.id, fileName: pkg.fileName, @@ -55,7 +81,7 @@ export async function listPackages(options: { archiveType: pkg.archiveType, fileCount: pkg.fileCount, isMultipart: pkg.isMultipart, - hasPreview: pkg.previewData !== null, + hasPreview: previewIds.has(pkg.id), creator: pkg.creator, tags: pkg.tags, indexedAt: pkg.indexedAt.toISOString(), @@ -149,7 +175,7 @@ export async function listDisplayItems(options: { select: { id: true, fileName: true, fileSize: true, contentHash: true, archiveType: true, fileCount: true, isMultipart: true, - indexedAt: true, creator: true, tags: true, previewData: true, + indexedAt: true, creator: true, tags: true, sourceChannel: { select: { id: true, title: true } }, }, }) @@ -159,13 +185,13 @@ export async function listDisplayItems(options: { ? await prisma.packageGroup.findMany({ where: { id: { in: groupIds } }, select: { - id: true, name: true, previewData: true, + id: true, name: 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, + indexedAt: true, creator: true, tags: true, sourceChannel: { select: { id: true, title: true } }, }, orderBy: { indexedAt: "desc" }, @@ -174,6 +200,16 @@ export async function listDisplayItems(options: { }) : []; + // Compute hasPreview flags without transferring image bytes (see fetchPreviewFlags) + const allPackageIds = [ + ...standalonePackages.map((p) => p.id), + ...groups.flatMap((g) => g.packages.map((p) => p.id)), + ]; + const [packagePreviewIds, groupPreviewIds] = await Promise.all([ + fetchPreviewFlags("packages", allPackageIds), + fetchPreviewFlags("package_groups", groups.map((g) => g.id)), + ]); + // 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])); @@ -191,7 +227,7 @@ export async function listDisplayItems(options: { archiveType: pkg.archiveType, fileCount: pkg.fileCount, isMultipart: pkg.isMultipart, - hasPreview: pkg.previewData !== null, + hasPreview: packagePreviewIds.has(pkg.id), creator: pkg.creator, tags: pkg.tags, indexedAt: pkg.indexedAt.toISOString(), @@ -209,7 +245,7 @@ export async function listDisplayItems(options: { data: { id: grp.id, name: grp.name, - hasPreview: grp.previewData !== null, + hasPreview: groupPreviewIds.has(grp.id), 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, @@ -227,7 +263,7 @@ export async function listDisplayItems(options: { archiveType: pkg.archiveType, fileCount: pkg.fileCount, isMultipart: pkg.isMultipart, - hasPreview: pkg.previewData !== null, + hasPreview: packagePreviewIds.has(pkg.id), creator: pkg.creator, tags: pkg.tags, indexedAt: pkg.indexedAt.toISOString(), @@ -440,13 +476,17 @@ export async function searchPackages(options: { indexedAt: true, creator: true, tags: true, - previewData: true, sourceChannel: { select: { id: true, title: true } }, }, }), Promise.resolve(allIds.length), ]); + const previewIds = await fetchPreviewFlags( + "packages", + items.map((p) => p.id) + ); + const mapped: PackageListItem[] = items.map((pkg) => ({ id: pkg.id, fileName: pkg.fileName, @@ -455,7 +495,7 @@ export async function searchPackages(options: { archiveType: pkg.archiveType, fileCount: pkg.fileCount, isMultipart: pkg.isMultipart, - hasPreview: pkg.previewData !== null, + hasPreview: previewIds.has(pkg.id), creator: pkg.creator, tags: pkg.tags, indexedAt: pkg.indexedAt.toISOString(), @@ -636,13 +676,17 @@ export async function listUngroupedPackages(options: { partCount: true, tags: true, indexedAt: true, - previewData: true, sourceChannel: { select: { id: true, title: true } }, }, }), prisma.package.count({ where }), ]); + const previewIds = await fetchPreviewFlags( + "packages", + items.map((p) => p.id) + ); + return { items: items.map((p) => ({ id: p.id, @@ -656,7 +700,7 @@ export async function listUngroupedPackages(options: { partCount: p.partCount, tags: p.tags, indexedAt: p.indexedAt.toISOString(), - hasPreview: !!p.previewData, + hasPreview: previewIds.has(p.id), sourceChannel: p.sourceChannel, matchedFileCount: 0, matchedByContent: false, From b0baf72f0a59db9d4ced29e7ec3ec558d1334c02 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Sat, 4 Jul 2026 09:14:33 +0200 Subject: [PATCH 2/7] docs: spec for send-all-from-creator button on STL page Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-07-04-send-all-from-creator-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-send-all-from-creator-design.md diff --git a/docs/superpowers/specs/2026-07-04-send-all-from-creator-design.md b/docs/superpowers/specs/2026-07-04-send-all-from-creator-design.md new file mode 100644 index 0000000..3b7535c --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-send-all-from-creator-design.md @@ -0,0 +1,170 @@ +# Send All From Creator — Design + +**Date:** 2026-07-04 +**Status:** Approved for planning + +## Summary + +Add a "Send all from [Creator]" button to the STL packages page that queues every +sendable package from a given creator for delivery to the user's Telegram. The +button appears in the packages-tab toolbar only when the list is filtered by a +single creator (`?creator=`). + +This reuses the existing single-send infrastructure (`BotSendRequest` + +`pg_notify('bot_send', id)` + the bot's `send-listener`) and closely mirrors the +existing `sendAllInGroupAction`. No bot, DB schema, or send-listener changes are +required. + +## Context + +Relevant existing pieces: + +- **Creator field:** `Package.creator` is a nullable, indexed `String` on the + `Package` model (`prisma/schema.prisma:476-524`). Filtering by exact creator is + cheap. +- **Creator filter:** The packages list already supports `?creator=` + (`src/app/(app)/stls/page.tsx:22,38`), rendered by the packages column as a + clickable value (`package-columns.tsx`). +- **Single send:** `send-to-telegram-button.tsx` → `POST /api/telegram/bot/send` + creates a `BotSendRequest` (status `PENDING`) and fires + `pg_notify('bot_send', requestId)`; the button then polls the request to + completion. +- **Existing bulk send (group):** `sendAllInGroupAction(groupId)` in + `src/app/(app)/stls/actions.ts:515-591` fetches the group's packages, filters to + those with `destChannelId` + `destMessageId`, skips any with an existing + `PENDING`/`SENDING` request for the same package + telegram link, creates a + `BotSendRequest` per remaining package, and fires `pg_notify` per package. It is + wired into the table via `handleSendAllInGroup` (`stl-table.tsx:264-278`) with a + `confirm()` dialog and a success toast — no polling. +- **Bot side:** `bot/src/send-listener.ts` processes every `BotSendRequest` + uniformly; nothing there needs to change. + +## Requirements + +1. When the packages tab is filtered by a single creator (`?creator=` is + present), show a toolbar button labeled **"Send all from [Creator]"**. +2. When no creator filter is active, the button is not rendered. +3. Clicking the button shows a confirmation dialog before sending (consistent with + the single-send and group-send flows, since this pushes files to Telegram). +4. On confirm, queue **all** sendable packages from that creator across all pages — + not just the current page. +5. Skip packages that are not yet uploaded (missing `destChannelId` / + `destMessageId`) and packages that already have a `PENDING`/`SENDING` request for + the same package + telegram link (dedup). +6. Report real counts back to the user via toast, e.g. + "Queued 12 packages from [Creator]" and, when applicable, a skipped count. +7. No polling — the bulk action queues and returns; sends process in the background + via the bot listener (same behavior as group send). + +## Design + +### Server action — `sendAllFromCreatorAction(creatorName: string)` + +New action in `src/app/(app)/stls/actions.ts`, mirroring `sendAllInGroupAction`: + +1. Auth check (`auth()`), return `Unauthorized` if no session. +2. Load the user's `TelegramLink`; if none, return + "No linked Telegram account. Link one in Settings." +3. Reject an empty/blank `creatorName` (guard against sending the entire "no + creator" set — see Edge cases). +4. Fetch sendable packages directly with a `where` filter (no in-memory filtering): + ```ts + prisma.package.findMany({ + where: { + creator: creatorName, + destChannelId: { not: null }, + destMessageId: { not: null }, + }, + select: { id: true }, + }) + ``` +5. If none, return "No uploaded packages found for this creator." +6. For each package, skip if an existing `PENDING`/`SENDING` `BotSendRequest` + exists for that package + telegram link; otherwise create a `BotSendRequest` + (status `PENDING`) and fire `pg_notify('bot_send', id)` (best-effort, wrapped in + try/catch — the bot also polls). +7. `revalidatePath("/stls")`. +8. Return counts so the UI can show them. This is a small improvement over + `sendAllInGroupAction`, which currently returns `{ success: true, data: undefined }`. + Shape: + ```ts + { success: true, data: { queued: number, skipped: number } } + ``` + where `skipped` counts packages that already had a live request. (Packages not + yet uploaded are excluded by the query and not counted as skipped.) + +### UI — toolbar button in `stl-table.tsx` + +- Derive the active creator from the URL: `const activeCreator = + searchParams.get("creator") ?? "";` (parallel to the existing + `activeTag` at `stl-table.tsx:445`). +- In the packages-tab toolbar (`stl-table.tsx:478` `flex flex-wrap items-center + gap-2` row), render the button only when `activeCreator` is non-empty: + ```tsx + {activeCreator && ( + + )} + ``` + (Reuse the icon already used by the send action; place near the Upload / Group + buttons.) +- Add `handleSendAllFromCreator`, modeled on `handleSendAllInGroup` + (`stl-table.tsx:264-278`): + ```ts + const handleSendAllFromCreator = useCallback(() => { + if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return; + startTransition(async () => { + const result = await sendAllFromCreatorAction(activeCreator); + if (result.success) { + const { queued, skipped } = result.data; + toast.success( + `Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` + + (skipped ? ` (${skipped} already queued)` : "") + ); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, [activeCreator, router]); + ``` +- Import `sendAllFromCreatorAction` alongside the existing `sendAllInGroupAction` + import (`stl-table.tsx:51`). + +### Data flow + +``` +User (filtered by creator) → clicks button → confirm dialog + → sendAllFromCreatorAction(creatorName) + → for each sendable, un-queued package: create BotSendRequest + pg_notify + → returns { queued, skipped } → toast + router.refresh() + +(background) bot send-listener consumes each BotSendRequest → delivers to user +``` + +## Edge cases + +- **Blank creator:** The action rejects an empty/whitespace `creatorName` so a + stray `?creator=` never queues every package. The UI already gates the button on + a non-empty `activeCreator`, so this is defense-in-depth. +- **No linked Telegram account:** Returns an error toast, queues nothing. +- **No uploaded packages for creator:** Returns an error toast, queues nothing. +- **All packages already queued:** `queued: 0`, `skipped: N` → toast reflects it. +- **pg_notify failure:** Swallowed per-package (best-effort); the bot's periodic + poll picks the request up. Matches existing behavior. + +## Out of scope / non-goals + +- No per-row "send all from this creator" action (toolbar-only, per decision). +- No progress polling / live completion tracking for the bulk send. +- No changes to the bot, `send-listener`, `BotSendRequest` schema, or API routes. +- No batching/throttling beyond what the bot listener already does. + +## Files touched + +- `src/app/(app)/stls/actions.ts` — add `sendAllFromCreatorAction`. +- `src/app/(app)/stls/_components/stl-table.tsx` — derive `activeCreator`, add + `handleSendAllFromCreator`, render the toolbar button, import the new action. From 1cae855c26993f025571379f45f564b439c0bee4 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Sat, 4 Jul 2026 09:15:58 +0200 Subject: [PATCH 3/7] docs: implementation plan for send-all-from-creator button Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-04-send-all-from-creator.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-send-all-from-creator.md diff --git a/docs/superpowers/plans/2026-07-04-send-all-from-creator.md b/docs/superpowers/plans/2026-07-04-send-all-from-creator.md new file mode 100644 index 0000000..828d5fb --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-send-all-from-creator.md @@ -0,0 +1,258 @@ +# Send All From Creator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a toolbar button on the STL packages page that queues every sendable package from the currently-filtered creator for delivery to the user's Telegram. + +**Architecture:** A new server action `sendAllFromCreatorAction(creatorName)` mirrors the existing `sendAllInGroupAction`: it fetches sendable packages for the creator, dedups against live `BotSendRequest`s, creates a `BotSendRequest` per package, and fires `pg_notify('bot_send', id)`. The bot's existing `send-listener` consumes these unchanged. The client table renders a "Send all from [Creator]" button in the packages-tab toolbar only when `?creator=` is active, wired to the action with a confirm dialog and a count toast. + +**Tech Stack:** Next.js 16 App Router, TypeScript, Prisma v7, server actions, `pg_notify`, sonner toasts, lucide-react icons. + +**Testing note:** This repo has no test framework (`CLAUDE.md`: "Testing is manual"). Verification for each task is `npm run lint` + `npm run build`, plus manual UI checks at the end. Do not scaffold a test harness. + +--- + +### Task 1: Add `sendAllFromCreatorAction` server action + +**Files:** +- Modify: `src/app/(app)/stls/actions.ts` (append new action after `sendAllInGroupAction`, which ends at line 591) + +Reference implementation to mirror: `sendAllInGroupAction` at `src/app/(app)/stls/actions.ts:515-591`. +Existing imports already present in the file (do not re-add): `auth` from `@/lib/auth`, `prisma` from `@/lib/prisma`, `ActionResult` from `@/types/api.types`, `revalidatePath` from `next/cache`. +`ActionResult` is generic: `ActionResult` (`src/types/api.types.ts`). + +- [ ] **Step 1: Append the new action** + +Add this to the end of `src/app/(app)/stls/actions.ts`: + +```ts +export async function sendAllFromCreatorAction( + creatorName: string +): Promise> { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const creator = creatorName.trim(); + if (!creator) { + return { success: false, error: "No creator specified" }; + } + + 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 sendablePackages = await prisma.package.findMany({ + where: { + creator, + destChannelId: { not: null }, + destMessageId: { not: null }, + }, + select: { id: true }, + }); + + if (sendablePackages.length === 0) { + return { success: false, error: "No uploaded packages found for this creator" }; + } + + let queued = 0; + let skipped = 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) { + skipped++; + continue; + } + + 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: { queued, skipped } }; + } catch { + return { success: false, error: "Failed to send creator packages" }; + } +} +``` + +- [ ] **Step 2: Verify lint + typecheck pass** + +Run: `npm run lint` +Expected: no new errors in `src/app/(app)/stls/actions.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/\(app\)/stls/actions.ts +git commit -m "feat(stls): add sendAllFromCreatorAction server action" +``` + +--- + +### Task 2: Wire up the toolbar button in the STL table + +**Files:** +- Modify: `src/app/(app)/stls/_components/stl-table.tsx` + - lucide import (line 6) + - actions import block (lines 44-54) + - handler (after `handleSendAllInGroup`, which ends at line 278) + - `activeCreator` derivation (near `activeTag` at line 445) + - toolbar JSX (packages-tab toolbar row starting at line 478) + +- [ ] **Step 1: Add the `Send` icon to the lucide import** + +Change line 6 from: + +```ts +import { Search, Layers, Upload } from "lucide-react"; +``` + +to: + +```ts +import { Search, Layers, Upload, Send } from "lucide-react"; +``` + +- [ ] **Step 2: Import the new action** + +In the actions import block (lines 44-54), add `sendAllFromCreatorAction` next to `sendAllInGroupAction`: + +```ts +import { + updatePackageCreator, + updatePackageTags, + renameGroupAction, + dissolveGroupAction, + createGroupAction, + removeFromGroupAction, + sendAllInGroupAction, + sendAllFromCreatorAction, + updateGroupPreviewAction, + mergeGroupsAction, +} from "../actions"; +``` + +- [ ] **Step 3: Derive the active creator from the URL** + +Immediately after the `activeTag` line (`src/app/(app)/stls/_components/stl-table.tsx:445`): + +```ts + const activeTag = searchParams.get("tag") ?? ""; +``` + +add: + +```ts + const activeCreator = searchParams.get("creator") ?? ""; +``` + +- [ ] **Step 4: Add the click handler** + +After `handleSendAllInGroup` (which ends at line 278), add: + +```ts + const handleSendAllFromCreator = useCallback(() => { + if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return; + startTransition(async () => { + const result = await sendAllFromCreatorAction(activeCreator); + if (result.success) { + const { queued, skipped } = result.data; + toast.success( + `Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` + + (skipped ? ` (${skipped} already queued)` : "") + ); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, [activeCreator, router]); +``` + +- [ ] **Step 5: Render the toolbar button** + +In the packages-tab toolbar (`flex flex-wrap items-center gap-2` row at line 478), add the button right after the "Upload Files" button (which closes at line 507, before the `selectedPackages.size >= 2` block at line 508): + +```tsx + {activeCreator && ( + + )} +``` + +- [ ] **Step 6: Verify lint + build pass** + +Run: `npm run lint && npm run build` +Expected: no new errors; build completes. + +- [ ] **Step 7: Manual verification** + +1. `npm run dev`, open `/stls`. +2. Click a creator name in the Creator column to apply `?creator=` (or navigate to `/stls?creator=`). +3. Confirm the "Send all from [Creator]" button appears in the toolbar. +4. Remove the creator filter → confirm the button disappears. +5. Click the button → confirm the dialog appears; on confirm, a toast reports the queued count. +6. (If a linked Telegram account + uploaded packages exist) confirm packages arrive via the bot. + +- [ ] **Step 8: Commit** + +```bash +git add src/app/\(app\)/stls/_components/stl-table.tsx +git commit -m "feat(stls): add 'send all from creator' toolbar button" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Req 1 (button when filtered by creator) → Task 2 Step 5 (`{activeCreator && ...}`). +- Req 2 (hidden when no filter) → Task 2 Step 5 conditional. +- Req 3 (confirm dialog) → Task 2 Step 4 `confirm(...)`. +- Req 4 (all pages, not current page) → Task 1 queries `prisma.package.findMany` by `creator`, unpaginated. +- Req 5 (skip not-uploaded + dedup live requests) → Task 1 `where` filter on `destChannelId`/`destMessageId` + `existing` check. +- Req 6 (report counts) → Task 1 returns `{ queued, skipped }`; Task 2 Step 4 toast. +- Req 7 (no polling) → Task 2 handler queues + `router.refresh()`, no poll loop. +- Blank-creator guard → Task 1 `creator.trim()` check. + +**Placeholder scan:** No TBD/TODO/placeholder steps; all code shown in full. + +**Type consistency:** Action returns `ActionResult<{ queued: number; skipped: number }>`; handler destructures `result.data.{queued,skipped}` inside the `result.success` branch (where `data` is typed). `sendAllFromCreatorAction` name matches between Task 1 definition, Task 2 import, and Task 2 call site. `activeCreator` defined once (Step 3) and used in Steps 4-5. From 50e89719bbaf768a86f1c0f868e5d363a7b89ae8 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Sat, 4 Jul 2026 09:20:36 +0200 Subject: [PATCH 4/7] feat(stls): add sendAllFromCreatorAction server action --- src/app/(app)/stls/actions.ts | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/app/(app)/stls/actions.ts b/src/app/(app)/stls/actions.ts index d58a074..5e7ba5a 100644 --- a/src/app/(app)/stls/actions.ts +++ b/src/app/(app)/stls/actions.ts @@ -589,3 +589,82 @@ export async function sendAllInGroupAction( return { success: false, error: "Failed to send group packages" }; } } + +export async function sendAllFromCreatorAction( + creatorName: string +): Promise> { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const creator = creatorName.trim(); + if (!creator) { + return { success: false, error: "No creator specified" }; + } + + 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 sendablePackages = await prisma.package.findMany({ + where: { + creator, + destChannelId: { not: null }, + destMessageId: { not: null }, + }, + select: { id: true }, + }); + + if (sendablePackages.length === 0) { + return { success: false, error: "No uploaded packages found for this creator" }; + } + + let queued = 0; + let skipped = 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) { + skipped++; + continue; + } + + 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: { queued, skipped } }; + } catch { + return { success: false, error: "Failed to send creator packages" }; + } +} From 20e60bc6af85112aa82cf2aab68eb28db29698aa Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Sat, 4 Jul 2026 09:26:36 +0200 Subject: [PATCH 5/7] feat(stls): add 'send all from creator' toolbar button --- src/app/(app)/stls/_components/stl-table.tsx | 32 +++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/stls/_components/stl-table.tsx b/src/app/(app)/stls/_components/stl-table.tsx index 9235e4d..fabc3ce 100644 --- a/src/app/(app)/stls/_components/stl-table.tsx +++ b/src/app/(app)/stls/_components/stl-table.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useTransition, useMemo, useRef } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { Search, Layers, Upload } from "lucide-react"; +import { Search, Layers, Upload, Send } from "lucide-react"; import { UploadDialog } from "./upload-dialog"; import { useDataTable } from "@/hooks/use-data-table"; import { @@ -49,6 +49,7 @@ import { createGroupAction, removeFromGroupAction, sendAllInGroupAction, + sendAllFromCreatorAction, updateGroupPreviewAction, mergeGroupsAction, } from "../actions"; @@ -85,6 +86,7 @@ export function StlTable({ const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const activeCreator = searchParams.get("creator") ?? ""; const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? ""); const [viewPkg, setViewPkg] = useState(null); @@ -277,6 +279,23 @@ export function StlTable({ [router] ); + const handleSendAllFromCreator = useCallback(() => { + if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return; + startTransition(async () => { + const result = await sendAllFromCreatorAction(activeCreator); + if (result.success) { + const { queued, skipped } = result.data; + toast.success( + `Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` + + (skipped ? ` (${skipped} already queued)` : "") + ); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, [activeCreator, router]); + const handleRemoveFromGroup = useCallback( (packageId: string) => { startTransition(async () => { @@ -505,6 +524,17 @@ export function StlTable({ Upload Files + {activeCreator && ( + + )} {selectedPackages.size >= 2 && ( + + + + + + No creators found. + + { + onChange(""); + setOpen(false); + }} + > + + All Creators + + {creators.map((creator) => ( + { + onChange(creator); + setOpen(false); + }} + > + + {creator} + + ))} + + + + + + ); +} diff --git a/src/app/(app)/stls/_components/stl-table.tsx b/src/app/(app)/stls/_components/stl-table.tsx index fabc3ce..d914bb0 100644 --- a/src/app/(app)/stls/_components/stl-table.tsx +++ b/src/app/(app)/stls/_components/stl-table.tsx @@ -16,6 +16,7 @@ import { import { PackageFilesDrawer } from "./package-files-drawer"; import { IngestionStatus } from "./ingestion-status"; import { SkippedPackagesTab } from "./skipped-packages-tab"; +import { CreatorFilter } from "./creator-filter"; import { DataTable } from "@/components/shared/data-table"; import { DataTablePagination } from "@/components/shared/data-table-pagination"; import { DataTableViewOptions } from "@/components/shared/data-table-view-options"; @@ -60,6 +61,7 @@ interface StlTableProps { totalCount: number; ingestionStatus: IngestionAccountStatus[]; availableTags: string[]; + availableCreators: string[]; searchTerm: string; skippedData: SkippedRow[]; skippedPageCount: number; @@ -75,6 +77,7 @@ export function StlTable({ totalCount, ingestionStatus, availableTags, + availableCreators, searchTerm, skippedData, skippedPageCount, @@ -209,6 +212,20 @@ export function StlTable({ [router, pathname, searchParams] ); + const updateCreatorFilter = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set("creator", value); + params.set("page", "1"); + } else { + params.delete("creator"); + } + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [router, pathname, searchParams] + ); + const activeTab = searchParams.get("tab") ?? "packages"; const updateTab = useCallback( @@ -519,6 +536,13 @@ export function StlTable({ )} + {availableCreators.length > 0 && ( + + )} + + + + + + No creators found. + + { + onChange(""); + setOpen(false); + }} + > + + All Creators + + {creators.map((creator) => ( + { + onChange(creator); + setOpen(false); + }} + > + + {creator} + + ))} + + + + + + ); +} +``` + +- [ ] **Step 3: Wire the query through the page** + +In `src/app/(app)/stls/page.tsx`: +- Add `getAllPackageCreators` to the import from `@/lib/telegram/queries` (line 3). +- Add `getAllPackageCreators()` to the `Promise.all` (line 27) and destructure + `availableCreators`: + ```ts + const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] = + await Promise.all([ + // ...existing entries unchanged... + getIngestionStatus(), + getAllPackageTags(), + getAllPackageCreators(), + countSkippedPackages(), + countUngroupedPackages(), + ]); + ``` + (Insert `getAllPackageCreators()` immediately after `getAllPackageTags()`, and add + `availableCreators` in the matching position of the destructure.) +- Pass the prop to ``: `availableCreators={availableCreators}` (next to + `availableTags={availableTags}`). + +- [ ] **Step 4: Add prop + handler + render in the table** + +In `src/app/(app)/stls/_components/stl-table.tsx`: +- Import the component: `import { CreatorFilter } from "./creator-filter";` +- Add to `StlTableProps` (next to `availableTags: string[];`): `availableCreators: string[];` +- Add to the destructured params (next to `availableTags,`): `availableCreators,` +- Add the handler after `updateTagFilter` (ends ~line 210): + ```ts + const updateCreatorFilter = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set("creator", value); + params.set("page", "1"); + } else { + params.delete("creator"); + } + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [router, pathname, searchParams] + ); + ``` +- Render it in the toolbar right after the Tags `Select` block (the + `{availableTags.length > 0 && (...)}` block, ~lines 507-522): + ```tsx + {availableCreators.length > 0 && ( + + )} + ``` + +- [ ] **Step 5: Verify + manual test** + +Run: `npx tsc --noEmit` (filter for the touched files — expect none new). Note: full +`npm run build` fails on a pre-existing stale Prisma client issue in +`src/lib/telegram/*` unrelated to this change; do not attempt to fix it. +Manual: open `/stls`, use the "All Creators" combobox, type to filter, pick a +creator → list filters and the "Send all from [Creator]" button appears; pick +"All Creators" → filter clears and button disappears. + +- [ ] **Step 6: Commit** + +```bash +git add "src/lib/telegram/queries.ts" "src/app/(app)/stls/page.tsx" \ + "src/app/(app)/stls/_components/creator-filter.tsx" \ + "src/app/(app)/stls/_components/stl-table.tsx" +git commit -m "feat(stls): add searchable creator filter combobox to toolbar" +``` + +--- + ## Self-Review **Spec coverage:**