mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-22 23:12:02 +00:00
Compare commits
7 Commits
0cf5fcd3a7
...
6178ff3b08
| Author | SHA1 | Date | |
|---|---|---|---|
| 6178ff3b08 | |||
| 57842c6d95 | |||
| b3c49c3794 | |||
| 20e60bc6af | |||
| 50e89719bb | |||
| 1cae855c26 | |||
| b0baf72f0a |
454
docs/superpowers/plans/2026-07-04-send-all-from-creator.md
Normal file
454
docs/superpowers/plans/2026-07-04-send-all-from-creator.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# 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=<name>` 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<T = void>` (`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<ActionResult<{ queued: number; skipped: number }>> {
|
||||
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 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
onClick={handleSendAllFromCreator}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Send all from {activeCreator}
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **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=<name>` (or navigate to `/stls?creator=<known 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Searchable creator filter combobox (makes the filter reachable)
|
||||
|
||||
**Why:** Discovered during execution — the `?creator=` filter that reveals the Task 2
|
||||
button had no UI trigger. The creator table cell click only opens the edit prompt
|
||||
(`package-columns.tsx:339` → `onSetCreator`). This task adds a searchable
|
||||
"All Creators" combobox to the packages-tab toolbar (like the existing Tags select,
|
||||
but type-to-filter since there can be many creators). The creator cell stays
|
||||
edit-only.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/telegram/queries.ts` — add `getAllPackageCreators()`.
|
||||
- Modify: `src/app/(app)/stls/page.tsx` — fetch + pass `availableCreators`.
|
||||
- Create: `src/app/(app)/stls/_components/creator-filter.tsx` — the combobox.
|
||||
- Modify: `src/app/(app)/stls/_components/stl-table.tsx` — new prop, handler, render.
|
||||
|
||||
- [ ] **Step 1: Add the distinct-creators query**
|
||||
|
||||
Append to `src/lib/telegram/queries.ts` (mirrors `getAllPackageTags` at line 530):
|
||||
|
||||
```ts
|
||||
export async function getAllPackageCreators(): Promise<string[]> {
|
||||
const result = await prisma.$queryRaw<{ creator: string }[]>`
|
||||
SELECT DISTINCT creator FROM packages
|
||||
WHERE creator IS NOT NULL AND creator <> ''
|
||||
ORDER BY creator
|
||||
`;
|
||||
return result.map((r) => r.creator);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the combobox component**
|
||||
|
||||
Create `src/app/(app)/stls/_components/creator-filter.tsx` (mirrors the
|
||||
Popover+Command pattern in `src/components/shared/data-table-faceted-filter.tsx`):
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
interface CreatorFilterProps {
|
||||
creators: string[];
|
||||
value: string; // active creator, "" when none
|
||||
onChange: (creator: string) => void; // "" clears the filter
|
||||
}
|
||||
|
||||
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-9 w-[200px] justify-between"
|
||||
>
|
||||
<span className="truncate">{value || "All Creators"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search creators..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No creators found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__all__"
|
||||
onSelect={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
All Creators
|
||||
</CommandItem>
|
||||
{creators.map((creator) => (
|
||||
<CommandItem
|
||||
key={creator}
|
||||
value={creator}
|
||||
onSelect={() => {
|
||||
onChange(creator);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === creator ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{creator}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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 `<StlTable>`: `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 && (
|
||||
<CreatorFilter
|
||||
creators={availableCreators}
|
||||
value={activeCreator}
|
||||
onChange={updateCreatorFilter}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **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:**
|
||||
- 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.
|
||||
@@ -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=<name>`).
|
||||
|
||||
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=<name>`
|
||||
(`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=<name>` 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 && (
|
||||
<Button variant="outline" size="sm" className="h-9 gap-1.5"
|
||||
onClick={handleSendAllFromCreator}>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Send all from {activeCreator}
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
(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.
|
||||
82
src/app/(app)/stls/_components/creator-filter.tsx
Normal file
82
src/app/(app)/stls/_components/creator-filter.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
interface CreatorFilterProps {
|
||||
creators: string[];
|
||||
value: string; // active creator, "" when none
|
||||
onChange: (creator: string) => void; // "" clears the filter
|
||||
}
|
||||
|
||||
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-9 w-[200px] justify-between"
|
||||
>
|
||||
<span className="truncate">{value || "All Creators"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search creators..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No creators found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__all__"
|
||||
onSelect={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
All Creators
|
||||
</CommandItem>
|
||||
{creators.map((creator) => (
|
||||
<CommandItem
|
||||
key={creator}
|
||||
value={creator}
|
||||
onSelect={() => {
|
||||
onChange(creator);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === creator ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{creator}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
createGroupAction,
|
||||
removeFromGroupAction,
|
||||
sendAllInGroupAction,
|
||||
sendAllFromCreatorAction,
|
||||
updateGroupPreviewAction,
|
||||
mergeGroupsAction,
|
||||
} from "../actions";
|
||||
@@ -59,6 +61,7 @@ interface StlTableProps {
|
||||
totalCount: number;
|
||||
ingestionStatus: IngestionAccountStatus[];
|
||||
availableTags: string[];
|
||||
availableCreators: string[];
|
||||
searchTerm: string;
|
||||
skippedData: SkippedRow[];
|
||||
skippedPageCount: number;
|
||||
@@ -74,6 +77,7 @@ export function StlTable({
|
||||
totalCount,
|
||||
ingestionStatus,
|
||||
availableTags,
|
||||
availableCreators,
|
||||
searchTerm,
|
||||
skippedData,
|
||||
skippedPageCount,
|
||||
@@ -85,6 +89,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<PackageRow | null>(null);
|
||||
@@ -207,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(
|
||||
@@ -277,6 +296,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 () => {
|
||||
@@ -500,11 +536,29 @@ export function StlTable({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{availableCreators.length > 0 && (
|
||||
<CreatorFilter
|
||||
creators={availableCreators}
|
||||
value={activeCreator}
|
||||
onChange={updateCreatorFilter}
|
||||
/>
|
||||
)}
|
||||
<DataTableViewOptions table={table} />
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload Files
|
||||
</Button>
|
||||
{activeCreator && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
onClick={handleSendAllFromCreator}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Send all from {activeCreator}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPackages.size >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -589,3 +589,82 @@ export async function sendAllInGroupAction(
|
||||
return { success: false, error: "Failed to send group packages" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAllFromCreatorAction(
|
||||
creatorName: string
|
||||
): Promise<ActionResult<{ queued: number; skipped: number }>> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
|
||||
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, getAllPackageCreators, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
|
||||
import { StlTable } from "./_components/stl-table";
|
||||
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
|
||||
|
||||
@@ -24,7 +24,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
const tab = (params.tab as string) ?? "packages";
|
||||
|
||||
// Fetch packages, ingestion status, tags, and skipped count in parallel
|
||||
const [result, ingestionStatus, availableTags, skippedCount, ungroupedCount] = await Promise.all([
|
||||
const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] = await Promise.all([
|
||||
search
|
||||
? searchPackages({
|
||||
query: search,
|
||||
@@ -42,6 +42,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
}),
|
||||
getIngestionStatus(),
|
||||
getAllPackageTags(),
|
||||
getAllPackageCreators(),
|
||||
countSkippedPackages(),
|
||||
countUngroupedPackages(),
|
||||
]);
|
||||
@@ -68,6 +69,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
totalCount={result.pagination.total}
|
||||
ingestionStatus={ingestionStatus}
|
||||
availableTags={availableTags}
|
||||
availableCreators={availableCreators}
|
||||
searchTerm={search}
|
||||
skippedData={skippedResult?.items ?? []}
|
||||
skippedPageCount={skippedResult?.pagination.totalPages ?? 0}
|
||||
|
||||
@@ -534,6 +534,15 @@ export async function getAllPackageTags(): Promise<string[]> {
|
||||
return result.map((r) => r.tag);
|
||||
}
|
||||
|
||||
export async function getAllPackageCreators(): Promise<string[]> {
|
||||
const result = await prisma.$queryRaw<{ creator: string }[]>`
|
||||
SELECT DISTINCT creator FROM packages
|
||||
WHERE creator IS NOT NULL AND creator <> ''
|
||||
ORDER BY creator
|
||||
`;
|
||||
return result.map((r) => r.creator);
|
||||
}
|
||||
|
||||
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
||||
const accounts = await prisma.telegramAccount.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
|
||||
Reference in New Issue
Block a user