From 5fd341dfc427422c3826289a7630ee3929fb4f9f Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Mon, 23 Mar 2026 18:17:44 +0100 Subject: [PATCH] feat: fix channel scanning bugs, add package tags, and kickstarters tab Bug fixes: - Fix channels not being scanned by paginating TDLib getChats (was only loading first batch, additional channels were unknown to TDLib) - Add per-channel getChat pre-load as safety net before scanning - Fix preview pictures not loading by checking previewData instead of previewMsgId for hasPreview flag - Prevent previewMsgId from being set when preview download fails Package Tags: - Add tags Text[] column to Package with migration backfilling from channel categories - Worker auto-inherits source channel category as initial tag - Tag filter dropdown and Tags column in STL Files table - Server actions for individual and bulk tag editing Kickstarters Tab: - New KickstarterHost, Kickstarter, and KickstarterPackage models - Full CRUD with delivery status, payment status, host management - Package linking (many-to-many with existing packages) - Sidebar entry with Gift icon - Table with search, filters, modal forms Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 10 + .../migration.sql | 50 +++ prisma/schema.prisma | 67 +++- .../_components/kickstarter-columns.tsx | 187 +++++++++++ .../_components/kickstarter-form.tsx | 301 ++++++++++++++++++ .../_components/kickstarter-modal.tsx | 54 ++++ .../_components/kickstarter-table.tsx | 193 +++++++++++ src/app/(app)/kickstarters/actions.ts | 148 +++++++++ src/app/(app)/kickstarters/page.tsx | 29 ++ .../stls/_components/package-columns.tsx | 41 ++- src/app/(app)/stls/_components/stl-table.tsx | 61 +++- src/app/(app)/stls/actions.ts | 42 +++ src/app/(app)/stls/page.tsx | 10 +- src/components/layout/mobile-sidebar.tsx | 3 +- src/components/layout/sidebar.tsx | 2 + src/data/kickstarter.queries.ts | 97 ++++++ src/lib/constants.ts | 1 + src/lib/telegram/queries.ts | 27 +- src/lib/telegram/types.ts | 1 + src/schemas/kickstarter.schema.ts | 19 ++ worker/src/db/queries.ts | 2 + worker/src/tdlib/chats.ts | 15 +- worker/src/worker.ts | 47 ++- 23 files changed, 1375 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260323000000_add_package_tags/migration.sql create mode 100644 prisma/migrations/20260323010000_add_kickstarters/migration.sql create mode 100644 src/app/(app)/kickstarters/_components/kickstarter-columns.tsx create mode 100644 src/app/(app)/kickstarters/_components/kickstarter-form.tsx create mode 100644 src/app/(app)/kickstarters/_components/kickstarter-modal.tsx create mode 100644 src/app/(app)/kickstarters/_components/kickstarter-table.tsx create mode 100644 src/app/(app)/kickstarters/actions.ts create mode 100644 src/app/(app)/kickstarters/page.tsx create mode 100644 src/data/kickstarter.queries.ts create mode 100644 src/schemas/kickstarter.schema.ts diff --git a/prisma/migrations/20260323000000_add_package_tags/migration.sql b/prisma/migrations/20260323000000_add_package_tags/migration.sql new file mode 100644 index 0000000..ce18955 --- /dev/null +++ b/prisma/migrations/20260323000000_add_package_tags/migration.sql @@ -0,0 +1,10 @@ +-- Add tags array column to packages +ALTER TABLE "packages" ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT '{}'; + +-- Backfill: inherit source channel category as initial tag +UPDATE "packages" p +SET "tags" = ARRAY[c."category"] +FROM "telegram_channels" c +WHERE p."sourceChannelId" = c."id" + AND c."category" IS NOT NULL + AND c."category" != ''; diff --git a/prisma/migrations/20260323010000_add_kickstarters/migration.sql b/prisma/migrations/20260323010000_add_kickstarters/migration.sql new file mode 100644 index 0000000..87b7b3c --- /dev/null +++ b/prisma/migrations/20260323010000_add_kickstarters/migration.sql @@ -0,0 +1,50 @@ +-- CreateEnum +CREATE TYPE "DeliveryStatus" AS ENUM ('NOT_DELIVERED', 'PARTIAL', 'DELIVERED'); +CREATE TYPE "PaymentStatus" AS ENUM ('PAID', 'UNPAID'); + +-- CreateTable +CREATE TABLE "kickstarter_hosts" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "kickstarter_hosts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "kickstarters" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "link" TEXT, + "filesUrl" TEXT, + "deliveryStatus" "DeliveryStatus" NOT NULL DEFAULT 'NOT_DELIVERED', + "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID', + "notes" TEXT, + "hostId" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "kickstarters_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "kickstarter_packages" ( + "kickstarterId" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + + CONSTRAINT "kickstarter_packages_pkey" PRIMARY KEY ("kickstarterId","packageId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "kickstarter_hosts_name_key" ON "kickstarter_hosts"("name"); +CREATE INDEX "kickstarters_hostId_idx" ON "kickstarters"("hostId"); +CREATE INDEX "kickstarters_userId_idx" ON "kickstarters"("userId"); +CREATE INDEX "kickstarters_deliveryStatus_idx" ON "kickstarters"("deliveryStatus"); +CREATE INDEX "kickstarters_paymentStatus_idx" ON "kickstarters"("paymentStatus"); + +-- AddForeignKey +ALTER TABLE "kickstarters" ADD CONSTRAINT "kickstarters_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "kickstarter_hosts"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "kickstarters" ADD CONSTRAINT "kickstarters_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "kickstarter_packages" ADD CONSTRAINT "kickstarter_packages_kickstarterId_fkey" FOREIGN KEY ("kickstarterId") REFERENCES "kickstarters"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "kickstarter_packages" ADD CONSTRAINT "kickstarter_packages_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b9a786..473b0fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model User { tags Tag[] settings UserSettings? telegramLink TelegramLink? + kickstarters Kickstarter[] inviteCodes InviteCode[] @relation("InviteCreator") usedInvite InviteCode? @relation("InviteUser", fields: [usedInviteId], references: [id], onDelete: SetNull) usedInviteId String? @@ -468,6 +469,7 @@ model Package { isMultipart Boolean @default(false) partCount Int @default(1) fileCount Int @default(0) + tags String[] @default([]) previewData Bytes? // JPEG thumbnail from nearby Telegram photo (stored as raw bytes) previewMsgId BigInt? // Telegram message ID of the matched photo indexedAt DateTime @default(now()) @@ -477,8 +479,9 @@ model Package { files PackageFile[] ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id]) ingestionRunId String? - sendRequests BotSendRequest[] - extractRequests ArchiveExtractRequest[] + sendRequests BotSendRequest[] + extractRequests ArchiveExtractRequest[] + kickstarterLinks KickstarterPackage[] @@index([sourceChannelId]) @@index([destChannelId]) @@ -682,3 +685,63 @@ model ArchiveExtractRequest { @@index([status]) @@map("archive_extract_requests") } + +// ─────────────────────────────────────── +// Purchased Kickstarters +// ─────────────────────────────────────── + +enum DeliveryStatus { + NOT_DELIVERED + PARTIAL + DELIVERED +} + +enum PaymentStatus { + PAID + UNPAID +} + +model KickstarterHost { + id String @id @default(cuid()) + name String @unique + createdAt DateTime @default(now()) + + kickstarters Kickstarter[] + + @@map("kickstarter_hosts") +} + +model Kickstarter { + id String @id @default(cuid()) + name String + link String? + filesUrl String? + deliveryStatus DeliveryStatus @default(NOT_DELIVERED) + paymentStatus PaymentStatus @default(UNPAID) + notes String? + hostId String? + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + host KickstarterHost? @relation(fields: [hostId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + packages KickstarterPackage[] + + @@index([hostId]) + @@index([userId]) + @@index([deliveryStatus]) + @@index([paymentStatus]) + @@map("kickstarters") +} + +model KickstarterPackage { + kickstarterId String + packageId String + + kickstarter Kickstarter @relation(fields: [kickstarterId], references: [id], onDelete: Cascade) + package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) + + @@id([kickstarterId, packageId]) + @@map("kickstarter_packages") +} diff --git a/src/app/(app)/kickstarters/_components/kickstarter-columns.tsx b/src/app/(app)/kickstarters/_components/kickstarter-columns.tsx new file mode 100644 index 0000000..6c59dae --- /dev/null +++ b/src/app/(app)/kickstarters/_components/kickstarter-columns.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Pencil, Trash2, ExternalLink } from "lucide-react"; +import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export interface KickstarterRow { + id: string; + name: string; + link: string | null; + filesUrl: string | null; + deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED"; + paymentStatus: "PAID" | "UNPAID"; + notes: string | null; + hostId: string | null; + userId: string; + createdAt: Date; + updatedAt: Date; + host: { id: string; name: string } | null; + _count: { packages: number }; +} + +interface KickstarterColumnsProps { + onEdit: (kickstarter: KickstarterRow) => void; + onDelete: (id: string) => void; +} + +const deliveryConfig: Record = { + NOT_DELIVERED: { + label: "Not Delivered", + className: "bg-red-500/15 text-red-400 border-red-500/30", + }, + PARTIAL: { + label: "Partial", + className: "bg-orange-500/15 text-orange-400 border-orange-500/30", + }, + DELIVERED: { + label: "Delivered", + className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + }, +}; + +const paymentConfig: Record = { + PAID: { + label: "Paid", + className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + }, + UNPAID: { + label: "Unpaid", + className: "bg-red-500/15 text-red-400 border-red-500/30", + }, +}; + +export function getKickstarterColumns({ + onEdit, + onDelete, +}: KickstarterColumnsProps): ColumnDef[] { + return [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.link && ( + e.stopPropagation()} + > + + + )} +
+ ), + enableHiding: false, + }, + { + accessorKey: "host", + header: ({ column }) => , + cell: ({ row }) => + row.original.host ? ( + {row.original.host.name} + ) : ( + -- + ), + }, + { + id: "files", + header: "Files", + cell: ({ row }) => + row.original.filesUrl ? ( + e.stopPropagation()} + > + + + ) : ( + -- + ), + }, + { + accessorKey: "deliveryStatus", + header: ({ column }) => , + cell: ({ row }) => { + const config = deliveryConfig[row.original.deliveryStatus]; + return ( + + {config.label} + + ); + }, + }, + { + accessorKey: "paymentStatus", + header: ({ column }) => , + cell: ({ row }) => { + const config = paymentConfig[row.original.paymentStatus]; + return ( + + {config.label} + + ); + }, + }, + { + id: "packages", + header: "Packages", + cell: ({ row }) => ( + + {row.original._count.packages} + + ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => , + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString()} + + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + onEdit(row.original)}> + + Edit + + + onDelete(row.original.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ), + enableHiding: false, + }, + ]; +} diff --git a/src/app/(app)/kickstarters/_components/kickstarter-form.tsx b/src/app/(app)/kickstarters/_components/kickstarter-form.tsx new file mode 100644 index 0000000..624bf56 --- /dev/null +++ b/src/app/(app)/kickstarters/_components/kickstarter-form.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { Plus } from "lucide-react"; +import { kickstarterSchema, type KickstarterInput } from "@/schemas/kickstarter.schema"; +import { createKickstarter, updateKickstarter, createHost } from "../actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface HostOption { + id: string; + name: string; + _count: { kickstarters: number }; +} + +interface KickstarterFormProps { + kickstarter?: { + id: string; + name: string; + link: string | null; + filesUrl: string | null; + deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED"; + paymentStatus: "PAID" | "UNPAID"; + hostId: string | null; + notes: string | null; + }; + hosts: HostOption[]; + onSuccess: () => void; +} + +export function KickstarterForm({ kickstarter, hosts, onSuccess }: KickstarterFormProps) { + const [isPending, startTransition] = useTransition(); + const [hostList, setHostList] = useState(hosts); + const [showNewHost, setShowNewHost] = useState(false); + const [newHostName, setNewHostName] = useState(""); + const isEditing = !!kickstarter; + + const form = useForm({ + resolver: zodResolver(kickstarterSchema), + defaultValues: { + name: kickstarter?.name ?? "", + link: kickstarter?.link ?? "", + filesUrl: kickstarter?.filesUrl ?? "", + deliveryStatus: kickstarter?.deliveryStatus ?? "NOT_DELIVERED", + paymentStatus: kickstarter?.paymentStatus ?? "UNPAID", + hostId: kickstarter?.hostId ?? "", + notes: kickstarter?.notes ?? "", + }, + }); + + function onSubmit(values: KickstarterInput) { + startTransition(async () => { + const result = isEditing + ? await updateKickstarter(kickstarter!.id, values) + : await createKickstarter(values); + + if (!result.success) { + toast.error(result.error); + return; + } + + toast.success(isEditing ? "Kickstarter updated" : "Kickstarter created"); + form.reset(); + onSuccess(); + }); + } + + function handleAddHost() { + if (!newHostName.trim()) return; + startTransition(async () => { + const result = await createHost({ name: newHostName.trim() }); + if (!result.success) { + toast.error(result.error); + return; + } + toast.success(`Host "${result.data!.name}" created`); + setHostList((prev) => [ + ...prev, + { id: result.data!.id, name: result.data!.name, _count: { kickstarters: 0 } }, + ]); + form.setValue("hostId", result.data!.id); + setNewHostName(""); + setShowNewHost(false); + }); + } + + return ( +
+ + ( + + Name + + + + + + )} + /> + + ( + + Link + + + + + + )} + /> + + ( + + Files URL + + + + + + )} + /> + +
+ ( + + Delivery Status + + + + )} + /> + + ( + + Payment Status + + + + )} + /> +
+ + ( + + Host + {!showNewHost ? ( +
+ + +
+ ) : ( +
+ setNewHostName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddHost(); + } + if (e.key === "Escape") { + setShowNewHost(false); + setNewHostName(""); + } + }} + autoFocus + className="flex-1" + /> + + +
+ )} + +
+ )} + /> + + ( + + Notes + +