diff --git a/.env.example b/.env.example index 4e32bcc..271e081 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,14 @@ AUTH_GITHUB_SECRET="" # App NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# Telegram integration (get from https://my.telegram.org/apps) +TELEGRAM_API_ID="" +TELEGRAM_API_HASH="" + +# Worker (only needed when running worker container) +WORKER_INTERVAL_MINUTES=60 +WORKER_TEMP_DIR="/tmp/zips" +TDLIB_STATE_DIR="/data/tdlib" +WORKER_MAX_ZIP_SIZE_MB=4096 +LOG_LEVEL="info" diff --git a/.gitignore b/.gitignore index edaa892..0f0f125 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies /node_modules +worker/node_modules /.pnp .pnp.* .yarn/* @@ -48,3 +49,7 @@ src/generated # ide .idea .vscode + +# temp files +nul +tmpclaude-* diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 174be10..6fd65a8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,5 +15,27 @@ services: timeout: 5s retries: 5 + worker: + build: + context: . + dockerfile: worker/Dockerfile + environment: + - DATABASE_URL=postgresql://dragons:stash@db:5432/dragonsstash + - WORKER_INTERVAL_MINUTES=5 + - WORKER_TEMP_DIR=/tmp/zips + - TDLIB_STATE_DIR=/data/tdlib + - WORKER_MAX_ZIP_SIZE_MB=4096 + - LOG_LEVEL=debug + - TELEGRAM_API_ID=${TELEGRAM_API_ID} + - TELEGRAM_API_HASH=${TELEGRAM_API_HASH} + volumes: + - tdlib_dev_state:/data/tdlib + - tmp_dev_zips:/tmp/zips + depends_on: + db: + condition: service_healthy + volumes: postgres_dev_data: + tdlib_dev_state: + tmp_dev_zips: diff --git a/docker-compose.yml b/docker-compose.yml index 86e4e54..4118fa8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,37 @@ services: - AUTH_SECRET=change-me-to-a-random-secret-in-production - AUTH_TRUST_HOST=true - NEXT_PUBLIC_APP_URL=http://localhost:3000 + - TELEGRAM_API_KEY=${TELEGRAM_API_KEY:-} depends_on: db: condition: service_healthy restart: unless-stopped + worker: + build: + context: . + dockerfile: worker/Dockerfile + environment: + - DATABASE_URL=postgresql://dragons:stash@db:5432/dragonsstash + - WORKER_INTERVAL_MINUTES=60 + - WORKER_TEMP_DIR=/tmp/zips + - TDLIB_STATE_DIR=/data/tdlib + - WORKER_MAX_ZIP_SIZE_MB=4096 + - LOG_LEVEL=info + volumes: + - tdlib_state:/data/tdlib + - tmp_zips:/tmp/zips + depends_on: + db: + condition: service_healthy + restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 256M + db: image: postgres:16-alpine ports: @@ -34,3 +60,5 @@ services: volumes: postgres_data: + tdlib_state: + tmp_zips: diff --git a/prisma/migrations/20260224084446_add_telegram_models/migration.sql b/prisma/migrations/20260224084446_add_telegram_models/migration.sql new file mode 100644 index 0000000..7d1312a --- /dev/null +++ b/prisma/migrations/20260224084446_add_telegram_models/migration.sql @@ -0,0 +1,183 @@ +-- CreateEnum +CREATE TYPE "AuthState" AS ENUM ('PENDING', 'AWAITING_CODE', 'AWAITING_PASSWORD', 'AUTHENTICATED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "ChannelType" AS ENUM ('SOURCE', 'DESTINATION'); + +-- CreateEnum +CREATE TYPE "ChannelRole" AS ENUM ('READER', 'WRITER'); + +-- CreateEnum +CREATE TYPE "ArchiveType" AS ENUM ('ZIP', 'RAR'); + +-- CreateEnum +CREATE TYPE "IngestionStatus" AS ENUM ('RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "telegram_accounts" ( + "id" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "displayName" TEXT, + "apiId" INTEGER NOT NULL, + "apiHash" TEXT NOT NULL, + "sessionPath" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "authState" "AuthState" NOT NULL DEFAULT 'PENDING', + "authCode" TEXT, + "lastSeenAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "telegram_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "telegram_channels" ( + "id" TEXT NOT NULL, + "telegramId" BIGINT NOT NULL, + "title" TEXT NOT NULL, + "type" "ChannelType" NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "telegram_channels_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "account_channel_map" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "role" "ChannelRole" NOT NULL DEFAULT 'READER', + "lastProcessedMessageId" BIGINT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "account_channel_map_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "packages" ( + "id" TEXT NOT NULL, + "contentHash" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "fileSize" BIGINT NOT NULL, + "archiveType" "ArchiveType" NOT NULL, + "sourceChannelId" TEXT NOT NULL, + "sourceMessageId" BIGINT NOT NULL, + "destChannelId" TEXT, + "destMessageId" BIGINT, + "isMultipart" BOOLEAN NOT NULL DEFAULT false, + "partCount" INTEGER NOT NULL DEFAULT 1, + "fileCount" INTEGER NOT NULL DEFAULT 0, + "indexedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ingestionRunId" TEXT, + + CONSTRAINT "packages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "package_files" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "path" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "extension" TEXT, + "compressedSize" BIGINT NOT NULL DEFAULT 0, + "uncompressedSize" BIGINT NOT NULL DEFAULT 0, + "crc32" TEXT, + + CONSTRAINT "package_files_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ingestion_runs" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "status" "IngestionStatus" NOT NULL DEFAULT 'RUNNING', + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "messagesScanned" INTEGER NOT NULL DEFAULT 0, + "zipsFound" INTEGER NOT NULL DEFAULT 0, + "zipsDuplicate" INTEGER NOT NULL DEFAULT 0, + "zipsIngested" INTEGER NOT NULL DEFAULT 0, + "errorMessage" TEXT, + + CONSTRAINT "ingestion_runs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_accounts_phone_key" ON "telegram_accounts"("phone"); + +-- CreateIndex +CREATE INDEX "telegram_accounts_isActive_idx" ON "telegram_accounts"("isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_channels_telegramId_key" ON "telegram_channels"("telegramId"); + +-- CreateIndex +CREATE INDEX "telegram_channels_type_isActive_idx" ON "telegram_channels"("type", "isActive"); + +-- CreateIndex +CREATE INDEX "account_channel_map_accountId_idx" ON "account_channel_map"("accountId"); + +-- CreateIndex +CREATE INDEX "account_channel_map_channelId_idx" ON "account_channel_map"("channelId"); + +-- CreateIndex +CREATE UNIQUE INDEX "account_channel_map_accountId_channelId_key" ON "account_channel_map"("accountId", "channelId"); + +-- CreateIndex +CREATE UNIQUE INDEX "packages_contentHash_key" ON "packages"("contentHash"); + +-- CreateIndex +CREATE INDEX "packages_sourceChannelId_idx" ON "packages"("sourceChannelId"); + +-- CreateIndex +CREATE INDEX "packages_destChannelId_idx" ON "packages"("destChannelId"); + +-- CreateIndex +CREATE INDEX "packages_fileName_idx" ON "packages"("fileName"); + +-- CreateIndex +CREATE INDEX "packages_indexedAt_idx" ON "packages"("indexedAt"); + +-- CreateIndex +CREATE INDEX "packages_archiveType_idx" ON "packages"("archiveType"); + +-- CreateIndex +CREATE INDEX "package_files_packageId_idx" ON "package_files"("packageId"); + +-- CreateIndex +CREATE INDEX "package_files_extension_idx" ON "package_files"("extension"); + +-- CreateIndex +CREATE INDEX "package_files_fileName_idx" ON "package_files"("fileName"); + +-- CreateIndex +CREATE INDEX "ingestion_runs_accountId_idx" ON "ingestion_runs"("accountId"); + +-- CreateIndex +CREATE INDEX "ingestion_runs_status_idx" ON "ingestion_runs"("status"); + +-- CreateIndex +CREATE INDEX "ingestion_runs_startedAt_idx" ON "ingestion_runs"("startedAt"); + +-- AddForeignKey +ALTER TABLE "account_channel_map" ADD CONSTRAINT "account_channel_map_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "telegram_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account_channel_map" ADD CONSTRAINT "account_channel_map_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "telegram_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "packages" ADD CONSTRAINT "packages_sourceChannelId_fkey" FOREIGN KEY ("sourceChannelId") REFERENCES "telegram_channels"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "packages" ADD CONSTRAINT "packages_ingestionRunId_fkey" FOREIGN KEY ("ingestionRunId") REFERENCES "ingestion_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "package_files" ADD CONSTRAINT "package_files_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ingestion_runs" ADD CONSTRAINT "ingestion_runs_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "telegram_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260224092159_add_live_activity_tracking/migration.sql b/prisma/migrations/20260224092159_add_live_activity_tracking/migration.sql new file mode 100644 index 0000000..324f0d2 --- /dev/null +++ b/prisma/migrations/20260224092159_add_live_activity_tracking/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "ingestion_runs" ADD COLUMN "currentActivity" TEXT, +ADD COLUMN "currentChannel" TEXT, +ADD COLUMN "currentFile" TEXT, +ADD COLUMN "currentFileNum" INTEGER, +ADD COLUMN "currentStep" TEXT, +ADD COLUMN "downloadPercent" INTEGER, +ADD COLUMN "downloadedBytes" BIGINT, +ADD COLUMN "lastActivityAt" TIMESTAMP(3), +ADD COLUMN "totalBytes" BIGINT, +ADD COLUMN "totalFiles" INTEGER; diff --git a/prisma/migrations/20260224095311_add_package_preview/migration.sql b/prisma/migrations/20260224095311_add_package_preview/migration.sql new file mode 100644 index 0000000..48fb48e --- /dev/null +++ b/prisma/migrations/20260224095311_add_package_preview/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "packages" ADD COLUMN "previewData" BYTEA, +ADD COLUMN "previewMsgId" BIGINT; diff --git a/prisma/migrations/20260224141558_drop_account_api_credentials/migration.sql b/prisma/migrations/20260224141558_drop_account_api_credentials/migration.sql new file mode 100644 index 0000000..c277332 --- /dev/null +++ b/prisma/migrations/20260224141558_drop_account_api_credentials/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `apiHash` on the `telegram_accounts` table. All the data in the column will be lost. + - You are about to drop the column `apiId` on the `telegram_accounts` table. All the data in the column will be lost. + - You are about to drop the column `sessionPath` on the `telegram_accounts` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "telegram_accounts" DROP COLUMN "apiHash", +DROP COLUMN "apiId", +DROP COLUMN "sessionPath"; diff --git a/prisma/migrations/20260224143824_add_forum_topics_and_creator/migration.sql b/prisma/migrations/20260224143824_add_forum_topics_and_creator/migration.sql new file mode 100644 index 0000000..646408a --- /dev/null +++ b/prisma/migrations/20260224143824_add_forum_topics_and_creator/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "packages" ADD COLUMN "creator" TEXT, +ADD COLUMN "sourceTopicId" BIGINT; + +-- AlterTable +ALTER TABLE "telegram_channels" ADD COLUMN "isForum" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "topic_progress" ( + "id" TEXT NOT NULL, + "accountChannelMapId" TEXT NOT NULL, + "topicId" BIGINT NOT NULL, + "topicName" TEXT, + "lastProcessedMessageId" BIGINT, + + CONSTRAINT "topic_progress_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "topic_progress_accountChannelMapId_idx" ON "topic_progress"("accountChannelMapId"); + +-- CreateIndex +CREATE UNIQUE INDEX "topic_progress_accountChannelMapId_topicId_key" ON "topic_progress"("accountChannelMapId", "topicId"); + +-- CreateIndex +CREATE INDEX "packages_creator_idx" ON "packages"("creator"); + +-- AddForeignKey +ALTER TABLE "topic_progress" ADD CONSTRAINT "topic_progress_accountChannelMapId_fkey" FOREIGN KEY ("accountChannelMapId") REFERENCES "account_channel_map"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e177f49..66f567b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -349,3 +349,189 @@ model UserSettings { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } + +// ─────────────────────────────────────── +// Telegram ingestion models +// ─────────────────────────────────────── + +enum AuthState { + PENDING + AWAITING_CODE + AWAITING_PASSWORD + AUTHENTICATED + EXPIRED +} + +enum ChannelType { + SOURCE + DESTINATION +} + +enum ChannelRole { + READER + WRITER +} + +enum ArchiveType { + ZIP + RAR +} + +enum IngestionStatus { + RUNNING + COMPLETED + FAILED + CANCELLED +} + +model TelegramAccount { + id String @id @default(cuid()) + phone String @unique + displayName String? + isActive Boolean @default(true) + authState AuthState @default(PENDING) + authCode String? + lastSeenAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + channelMaps AccountChannelMap[] + ingestionRuns IngestionRun[] + + @@index([isActive]) + @@map("telegram_accounts") +} + +model TelegramChannel { + id String @id @default(cuid()) + telegramId BigInt @unique + title String + type ChannelType + isForum Boolean @default(false) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + accountMaps AccountChannelMap[] + packages Package[] + + @@index([type, isActive]) + @@map("telegram_channels") +} + +model AccountChannelMap { + id String @id @default(cuid()) + accountId String + channelId String + role ChannelRole @default(READER) + lastProcessedMessageId BigInt? + createdAt DateTime @default(now()) + + account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) + channel TelegramChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + topicProgress TopicProgress[] + + @@unique([accountId, channelId]) + @@index([accountId]) + @@index([channelId]) + @@map("account_channel_map") +} + +model Package { + id String @id @default(cuid()) + contentHash String @unique + fileName String + fileSize BigInt + archiveType ArchiveType + creator String? + sourceChannelId String + sourceMessageId BigInt + sourceTopicId BigInt? + destChannelId String? + destMessageId BigInt? + isMultipart Boolean @default(false) + partCount Int @default(1) + fileCount Int @default(0) + 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()) + createdAt DateTime @default(now()) + + sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id]) + files PackageFile[] + ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id]) + ingestionRunId String? + + @@index([sourceChannelId]) + @@index([destChannelId]) + @@index([fileName]) + @@index([indexedAt]) + @@index([archiveType]) + @@index([creator]) + @@map("packages") +} + +model PackageFile { + id String @id @default(cuid()) + packageId String + path String + fileName String + extension String? + compressedSize BigInt @default(0) + uncompressedSize BigInt @default(0) + crc32 String? + + package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) + + @@index([packageId]) + @@index([extension]) + @@index([fileName]) + @@map("package_files") +} + +model IngestionRun { + id String @id @default(cuid()) + accountId String + status IngestionStatus @default(RUNNING) + startedAt DateTime @default(now()) + finishedAt DateTime? + messagesScanned Int @default(0) + zipsFound Int @default(0) + zipsDuplicate Int @default(0) + zipsIngested Int @default(0) + errorMessage String? + + // Live activity tracking — written by worker in real-time + currentActivity String? // Human-readable: "Downloading pack.zip (part 2/5)" + currentStep String? // Machine-readable step key + currentChannel String? // Channel title being processed + currentFile String? // File currently being processed + currentFileNum Int? // Which archive set (1-indexed) + totalFiles Int? // Total archive sets found + downloadedBytes BigInt? // Current download progress in bytes + totalBytes BigInt? // Total size of current download + downloadPercent Int? // 0-100 + lastActivityAt DateTime? // When activity was last updated + + account TelegramAccount @relation(fields: [accountId], references: [id]) + packages Package[] + + @@index([accountId]) + @@index([status]) + @@index([startedAt]) + @@map("ingestion_runs") +} + +model TopicProgress { + id String @id @default(cuid()) + accountChannelMapId String + topicId BigInt + topicName String? + lastProcessedMessageId BigInt? + + accountChannelMap AccountChannelMap @relation(fields: [accountChannelMapId], references: [id], onDelete: Cascade) + + @@unique([accountChannelMapId, topicId]) + @@index([accountChannelMapId]) + @@map("topic_progress") +} diff --git a/src/app/(app)/stls/_components/ingestion-status.tsx b/src/app/(app)/stls/_components/ingestion-status.tsx new file mode 100644 index 0000000..5826e22 --- /dev/null +++ b/src/app/(app)/stls/_components/ingestion-status.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, CheckCircle2, XCircle, CloudOff } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { IngestionAccountStatus } from "@/lib/telegram/types"; + +interface IngestionStatusProps { + initialStatus: IngestionAccountStatus[]; +} + +/** + * Polls /api/ingestion/status every 3 seconds while a run is active, + * or every 30 seconds when idle. Shows a compact status banner with + * a spinning throbber when ingestion is running. + */ +export function IngestionStatus({ initialStatus }: IngestionStatusProps) { + const [accounts, setAccounts] = useState(initialStatus); + const [error, setError] = useState(false); + + // Determine if any account is currently running + const activeRun = accounts.find((a) => a.currentRun); + const isRunning = !!activeRun; + + useEffect(() => { + let timer: ReturnType; + let mounted = true; + + const poll = async () => { + try { + const res = await fetch("/api/ingestion/status"); + if (!res.ok) throw new Error("fetch failed"); + const data = await res.json(); + if (mounted) { + setAccounts(data.accounts ?? []); + setError(false); + } + } catch { + if (mounted) setError(true); + } + if (mounted) { + // Poll fast while running, slow when idle + const interval = accounts.some((a) => a.currentRun) ? 3_000 : 30_000; + timer = setTimeout(poll, interval); + } + }; + + // Start polling after a short delay to avoid double-fetching on mount + timer = setTimeout(poll, 3_000); + + return () => { + mounted = false; + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRunning]); + + // Nothing to show if no accounts configured + if (accounts.length === 0 && !error) return null; + + // If we can't reach the API, show a muted offline badge + if (error) { + return ( +
+ + Sync status unavailable +
+ ); + } + + // Active run — show throbber with live activity + if (activeRun?.currentRun) { + const run = activeRun.currentRun; + return ( +
+ +
+

+ {run.currentActivity ?? "Syncing..."} +

+ {run.downloadPercent != null && run.downloadPercent > 0 && ( +
+
+
+
+ {run.downloadPercent}% +
+ )} +
+ {run.totalFiles != null && run.currentFileNum != null && ( + + {run.currentFileNum}/{run.totalFiles} + + )} +
+ ); + } + + // All idle — show last run summary + const lastCompleted = accounts + .filter((a) => a.lastRun) + .sort( + (a, b) => + new Date(b.lastRun!.finishedAt ?? b.lastRun!.startedAt).getTime() - + new Date(a.lastRun!.finishedAt ?? a.lastRun!.startedAt).getTime() + )[0]; + + if (!lastCompleted?.lastRun) return null; + + const last = lastCompleted.lastRun; + const isFailed = last.status === "FAILED"; + const timeAgo = getTimeAgo(last.finishedAt ?? last.startedAt); + + return ( +
+ {isFailed ? ( + + ) : ( + + )} + + {isFailed + ? `Last sync failed ${timeAgo}` + : `Last sync ${timeAgo} — ${last.zipsIngested} new, ${last.zipsDuplicate} skipped`} + +
+ ); +} + +function getTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/src/app/(app)/stls/_components/package-columns.tsx b/src/app/(app)/stls/_components/package-columns.tsx new file mode 100644 index 0000000..c75804f --- /dev/null +++ b/src/app/(app)/stls/_components/package-columns.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { FileArchive, Eye, ImageIcon } from "lucide-react"; +import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +export interface PackageRow { + id: string; + fileName: string; + fileSize: string; + contentHash: string; + archiveType: "ZIP" | "RAR"; + fileCount: number; + isMultipart: boolean; + hasPreview: boolean; + creator: string | null; + indexedAt: string; + sourceChannel: { + id: string; + title: string; + }; +} + +interface PackageColumnsProps { + onViewFiles: (pkg: PackageRow) => void; +} + +function formatBytes(bytesStr: string): string { + const bytes = Number(bytesStr); + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +function PreviewCell({ pkg }: { pkg: PackageRow }) { + if (pkg.hasPreview) { + return ( + + ); + } + return ( +
+ +
+ ); +} + +export function getPackageColumns({ + onViewFiles, +}: PackageColumnsProps): ColumnDef[] { + return [ + { + id: "preview", + header: "", + cell: ({ row }) => , + enableHiding: false, + enableSorting: false, + size: 52, + }, + { + accessorKey: "fileName", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.fileName} + {row.original.isMultipart && ( + + Multi + + )} +
+ ), + enableHiding: false, + }, + { + accessorKey: "archiveType", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.archiveType} + + ), + }, + { + accessorKey: "fileSize", + header: ({ column }) => , + cell: ({ row }) => ( + + {formatBytes(row.original.fileSize)} + + ), + }, + { + accessorKey: "fileCount", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.fileCount.toLocaleString()} + + ), + }, + { + accessorKey: "creator", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.creator ?? "\u2014"} + + ), + }, + { + id: "channel", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.sourceChannel.title} + + ), + accessorFn: (row) => row.sourceChannel.title, + }, + { + accessorKey: "indexedAt", + header: ({ column }) => , + cell: ({ row }) => ( + + {new Date(row.original.indexedAt).toLocaleDateString()} + + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + ), + enableHiding: false, + }, + ]; +} diff --git a/src/app/(app)/stls/_components/package-files-drawer.tsx b/src/app/(app)/stls/_components/package-files-drawer.tsx new file mode 100644 index 0000000..ab08264 --- /dev/null +++ b/src/app/(app)/stls/_components/package-files-drawer.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import { + FileText, + Folder, + FolderOpen, + Loader2, + Search, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { PackageRow } from "./package-columns"; + +interface FileItem { + id: string; + path: string; + fileName: string; + extension: string | null; + compressedSize: string; + uncompressedSize: string; + crc32: string | null; +} + +interface TreeNode { + name: string; + isFolder: boolean; + children: Map; + file?: FileItem; +} + +interface PackageFilesDrawerProps { + pkg: PackageRow | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function formatBytes(bytesStr: string): string { + const bytes = Number(bytesStr); + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +const EXTENSION_COLORS: Record = { + stl: "bg-blue-500/15 text-blue-400 border-blue-500/30", + obj: "bg-violet-500/15 text-violet-400 border-violet-500/30", + "3mf": "bg-cyan-500/15 text-cyan-400 border-cyan-500/30", + gcode: "bg-amber-500/15 text-amber-400 border-amber-500/30", + png: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + jpg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + jpeg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + pdf: "bg-red-500/15 text-red-400 border-red-500/30", + txt: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", + lys: "bg-pink-500/15 text-pink-400 border-pink-500/30", +}; + +function getExtBadgeClass(ext: string | null): string { + if (!ext) return "bg-zinc-500/15 text-zinc-400 border-zinc-500/30"; + return EXTENSION_COLORS[ext.toLowerCase()] ?? "bg-zinc-500/15 text-zinc-400 border-zinc-500/30"; +} + +/** + * Build a tree structure from flat file paths. + */ +function buildFileTree(files: FileItem[]): TreeNode { + const root: TreeNode = { name: "", isFolder: true, children: new Map() }; + + for (const file of files) { + // Normalize path separators (Windows RAR archives may use backslashes) + const parts = file.path.replace(/\\/g, "/").split("/").filter(Boolean); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + + if (!current.children.has(part)) { + current.children.set(part, { + name: part, + isFolder: !isLast, + children: new Map(), + file: isLast ? file : undefined, + }); + } + + current = current.children.get(part)!; + } + } + + return root; +} + +/** + * Recursively renders a file tree node with indentation. + */ +function TreeNodeView({ + node, + depth, + search, + defaultOpen, +}: { + node: TreeNode; + depth: number; + search: string; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + + // Sort children: folders first, then files, alphabetical within each group + const sortedChildren = useMemo(() => { + const arr = Array.from(node.children.values()); + return arr.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [node.children]); + + // If searching, force all open + useEffect(() => { + if (search) setOpen(true); + }, [search]); + + if (node.isFolder && node.children.size > 0) { + return ( +
+ {/* Don't render a row for the root node */} + {depth >= 0 && ( + + )} + {open && + sortedChildren.map((child) => ( + + ))} +
+ ); + } + + // File node + if (node.file) { + return ( +
+ + + {node.name} + + {node.file.extension && ( + + .{node.file.extension} + + )} + + {formatBytes(node.file.uncompressedSize)} + +
+ ); + } + + return null; +} + +function countFiles(node: TreeNode): number { + if (!node.isFolder) return 1; + let count = 0; + for (const child of node.children.values()) { + count += countFiles(child); + } + return count; +} + +const PAGE_SIZE = 100; + +export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDrawerProps) { + const [files, setFiles] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + + const fetchFiles = useCallback( + async (pageNum: number, append: boolean) => { + if (!pkg) return; + if (pageNum === 1) setLoading(true); + else setLoadingMore(true); + + try { + const params = new URLSearchParams({ + page: String(pageNum), + limit: String(PAGE_SIZE), + }); + const res = await fetch(`/api/zips/${pkg.id}/files?${params}`); + if (!res.ok) throw new Error("fetch failed"); + const data = await res.json(); + setFiles((prev) => (append ? [...prev, ...data.items] : data.items)); + setTotal(data.pagination.total); + } catch { + // Silently handle + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [pkg] + ); + + // Reset and fetch when package changes + useEffect(() => { + if (open && pkg) { + setFiles([]); + setTotal(0); + setSearch(""); + setPage(1); + fetchFiles(1, false); + } + }, [open, pkg, fetchFiles]); + + const loadMore = () => { + const nextPage = page + 1; + setPage(nextPage); + fetchFiles(nextPage, true); + }; + + const hasMore = files.length < total; + + // Client-side search filter (over loaded files) + const filtered = search + ? files.filter( + (f) => + f.fileName.toLowerCase().includes(search.toLowerCase()) || + f.path.toLowerCase().includes(search.toLowerCase()) + ) + : files; + + // Build tree from filtered files + const tree = useMemo(() => buildFileTree(filtered), [filtered]); + + // If all files are in root (no folders), skip the tree and show flat list + const hasNesting = useMemo(() => { + return filtered.some((f) => f.path.replace(/\\/g, "/").includes("/")); + }, [filtered]); + + return ( + + + + {/* Preview image + title row */} +
+ {pkg?.hasPreview && ( + + )} +
+ + {pkg?.fileName ?? "Package Files"} + + + {total.toLocaleString()} file{total !== 1 ? "s" : ""} in archive + +
+
+ + {/* Search within file list */} + {files.length > 0 && ( +
+ + setSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ )} +
+ + +
+ {loading ? ( +
+ + Loading files... +
+ ) : filtered.length === 0 ? ( +
+ + + {search ? "No matching files" : "No files indexed"} + +
+ ) : hasNesting ? ( + <> + {/* Render as folder tree */} + {Array.from(tree.children.values()) + .sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; + return a.name.localeCompare(b.name); + }) + .map((child) => ( + + ))} + + ) : ( + <> + {/* Flat list for archives without folders */} + {filtered.map((file) => ( +
+ +
+

+ {file.fileName} +

+
+ {file.extension && ( + + .{file.extension} + + )} + + {formatBytes(file.uncompressedSize)} + +
+ ))} + + )} + + {/* Load more button */} + {hasMore && !search && ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/(app)/stls/_components/stl-table.tsx b/src/app/(app)/stls/_components/stl-table.tsx new file mode 100644 index 0000000..1b46f9b --- /dev/null +++ b/src/app/(app)/stls/_components/stl-table.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { Search, FileBox } from "lucide-react"; +import { useDataTable } from "@/hooks/use-data-table"; +import { getPackageColumns, type PackageRow } from "./package-columns"; +import { PackageFilesDrawer } from "./package-files-drawer"; +import { IngestionStatus } from "./ingestion-status"; +import { DataTable } from "@/components/shared/data-table"; +import { DataTablePagination } from "@/components/shared/data-table-pagination"; +import { DataTableViewOptions } from "@/components/shared/data-table-view-options"; +import { PageHeader } from "@/components/shared/page-header"; +import { Input } from "@/components/ui/input"; +import type { IngestionAccountStatus } from "@/lib/telegram/types"; + +interface StlTableProps { + data: PackageRow[]; + pageCount: number; + totalCount: number; + ingestionStatus: IngestionAccountStatus[]; +} + +export function StlTable({ + data, + pageCount, + totalCount, + ingestionStatus, +}: StlTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? ""); + const [viewPkg, setViewPkg] = useState(null); + + const updateSearch = useCallback( + (value: string) => { + setSearchValue(value); + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set("search", value); + params.set("page", "1"); + } else { + params.delete("search"); + } + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [router, pathname, searchParams] + ); + + const columns = getPackageColumns({ + onViewFiles: (pkg) => setViewPkg(pkg), + }); + + const { table } = useDataTable({ data, columns, pageCount }); + + return ( +
+ + + + +
+
+ + updateSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ +
+ + + + + { + if (!open) setViewPkg(null); + }} + /> +
+ ); +} diff --git a/src/app/(app)/stls/page.tsx b/src/app/(app)/stls/page.tsx new file mode 100644 index 0000000..6845c0b --- /dev/null +++ b/src/app/(app)/stls/page.tsx @@ -0,0 +1,50 @@ +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { listPackages, searchPackages, getIngestionStatus } from "@/lib/telegram/queries"; +import { StlTable } from "./_components/stl-table"; + +interface Props { + searchParams: Promise>; +} + +export default async function StlFilesPage({ searchParams }: Props) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const params = await searchParams; + + const page = Number(params.page) || 1; + const perPage = Number(params.perPage) || 20; + const sort = (params.sort as string) ?? "indexedAt"; + const order = (params.order as "asc" | "desc") ?? "desc"; + const search = (params.search as string) ?? ""; + const creator = (params.creator as string) || undefined; + + // Fetch packages and ingestion status in parallel + const [result, ingestionStatus] = await Promise.all([ + search + ? searchPackages({ + query: search, + page, + limit: perPage, + searchIn: "both", + }) + : listPackages({ + page, + limit: perPage, + creator, + sortBy: sort as "indexedAt" | "fileName" | "fileSize", + order, + }), + getIngestionStatus(), + ]); + + return ( + + ); +} diff --git a/src/app/(app)/telegram/_components/account-columns.tsx b/src/app/(app)/telegram/_components/account-columns.tsx new file mode 100644 index 0000000..e831e89 --- /dev/null +++ b/src/app/(app)/telegram/_components/account-columns.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { + MoreHorizontal, + Pencil, + Trash2, + Power, + Link2, + Play, + KeyRound, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { AccountRow } from "@/lib/telegram/admin-queries"; + +const authStateColors: Record = { + PENDING: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20", + AWAITING_CODE: "bg-orange-500/10 text-orange-600 border-orange-500/20", + AWAITING_PASSWORD: "bg-orange-500/10 text-orange-600 border-orange-500/20", + AUTHENTICATED: "bg-green-500/10 text-green-600 border-green-500/20", + EXPIRED: "bg-red-500/10 text-red-600 border-red-500/20", +}; + +interface AccountColumnsProps { + onEdit: (account: AccountRow) => void; + onToggleActive: (id: string) => void; + onDelete: (id: string) => void; + onViewLinks: (id: string) => void; + onTriggerSync: (id: string) => void; + onEnterCode: (account: AccountRow) => void; +} + +export function getAccountColumns({ + onEdit, + onToggleActive, + onDelete, + onViewLinks, + onTriggerSync, + onEnterCode, +}: AccountColumnsProps): ColumnDef[] { + return [ + { + accessorKey: "displayName", + header: "Account", + cell: ({ row }) => ( +
+ + {row.original.displayName || row.original.phone} + + {row.original.displayName && ( + + {row.original.phone} + + )} +
+ ), + enableHiding: false, + }, + { + accessorKey: "authState", + header: "Auth State", + cell: ({ row }) => { + const needsCode = + row.original.authState === "AWAITING_CODE" || + row.original.authState === "AWAITING_PASSWORD"; + return ( +
+ + {row.original.authState.replace(/_/g, " ")} + + {needsCode && ( + + )} +
+ ); + }, + }, + { + accessorKey: "isActive", + header: "Status", + cell: ({ row }) => ( + + {row.original.isActive ? "Active" : "Disabled"} + + ), + }, + { + id: "channels", + header: "Channels", + cell: ({ row }) => ( + + ), + }, + { + id: "runs", + header: "Runs", + cell: ({ row }) => ( + + {row.original.runCount} + + ), + }, + { + accessorKey: "lastSeenAt", + header: "Last Seen", + cell: ({ row }) => + row.original.lastSeenAt ? ( + + {new Date(row.original.lastSeenAt).toLocaleDateString()} + + ) : ( + Never + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + onEdit(row.original)}> + + Edit + + onViewLinks(row.original.id)}> + + Manage Channels + + onTriggerSync(row.original.id)}> + + Sync Now + + onToggleActive(row.original.id)} + > + + {row.original.isActive ? "Disable" : "Enable"} + + + onDelete(row.original.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ), + enableHiding: false, + }, + ]; +} diff --git a/src/app/(app)/telegram/_components/account-form.tsx b/src/app/(app)/telegram/_components/account-form.tsx new file mode 100644 index 0000000..9b04c8b --- /dev/null +++ b/src/app/(app)/telegram/_components/account-form.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { + telegramAccountSchema, + type TelegramAccountInput, +} from "@/schemas/telegram"; +import { createAccount, updateAccount } from "../actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import type { AccountRow } from "@/lib/telegram/admin-queries"; + +interface AccountFormProps { + account?: AccountRow; + onSuccess: () => void; +} + +export function AccountForm({ account, onSuccess }: AccountFormProps) { + const [isPending, startTransition] = useTransition(); + const isEditing = !!account; + + const form = useForm({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolver: zodResolver(telegramAccountSchema) as any, + defaultValues: { + phone: account?.phone ?? "", + displayName: account?.displayName ?? "", + }, + }); + + function onSubmit(values: TelegramAccountInput) { + startTransition(async () => { + const result = isEditing + ? await updateAccount(account!.id, values) + : await createAccount(values); + + if (!result.success) { + toast.error(result.error); + return; + } + + toast.success(isEditing ? "Account updated" : "Account created"); + form.reset(); + onSuccess(); + }); + } + + return ( +
+ + ( + + Phone Number + + + + + International format with country code + + + + )} + /> + + ( + + Display Name + + + + + + )} + /> + +
+ +
+ + + ); +} diff --git a/src/app/(app)/telegram/_components/account-links-drawer.tsx b/src/app/(app)/telegram/_components/account-links-drawer.tsx new file mode 100644 index 0000000..5378416 --- /dev/null +++ b/src/app/(app)/telegram/_components/account-links-drawer.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState, useEffect, useTransition, useCallback } from "react"; +import { Link2Off, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { linkChannel, unlinkChannel } from "../actions"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + +interface ChannelLink { + id: string; + channelId: string; + role: string; + lastProcessedMessageId: string | null; + channel: { + id: string; + title: string; + type: string; + telegramId: string; + }; +} + +interface UnlinkedChannel { + id: string; + title: string; + type: string; + telegramId: string; +} + +interface AccountLinksDrawerProps { + accountId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AccountLinksDrawer({ + accountId, + open, + onOpenChange, +}: AccountLinksDrawerProps) { + const [isPending, startTransition] = useTransition(); + const [links, setLinks] = useState([]); + const [unlinked, setUnlinked] = useState([]); + const [selectedChannelId, setSelectedChannelId] = useState(""); + const [selectedRole, setSelectedRole] = useState<"READER" | "WRITER">("READER"); + const [loading, setLoading] = useState(false); + + const fetchLinks = useCallback(async () => { + if (!accountId) return; + setLoading(true); + try { + const [linksRes, unlinkedRes] = await Promise.all([ + fetch(`/api/telegram/accounts/${accountId}/links`), + fetch(`/api/telegram/accounts/${accountId}/unlinked-channels`), + ]); + if (linksRes.ok) setLinks(await linksRes.json()); + if (unlinkedRes.ok) setUnlinked(await unlinkedRes.json()); + } catch { + toast.error("Failed to load channel links"); + } + setLoading(false); + }, [accountId]); + + useEffect(() => { + if (open && accountId) { + fetchLinks(); + } + }, [open, accountId, fetchLinks]); + + const handleLink = () => { + if (!accountId || !selectedChannelId) return; + startTransition(async () => { + const result = await linkChannel({ + accountId, + channelId: selectedChannelId, + role: selectedRole, + }); + if (result.success) { + toast.success("Channel linked"); + setSelectedChannelId(""); + await fetchLinks(); + } else { + toast.error(result.error); + } + }); + }; + + const handleUnlink = (linkId: string) => { + startTransition(async () => { + const result = await unlinkChannel(linkId); + if (result.success) { + toast.success("Channel unlinked"); + await fetchLinks(); + } else { + toast.error(result.error); + } + }); + }; + + return ( + + + + Manage Channel Links + + Link channels to this account. The account will read from Source + channels and write to Destination channels. + + + + {/* Add new link */} + {unlinked.length > 0 && ( +
+

Link a Channel

+
+
+ +
+ + +
+ +
+ )} + + {/* Existing links */} +
+

+ Linked Channels ({links.length}) +

+ {loading ? ( +

Loading...

+ ) : links.length === 0 ? ( +

+ No channels linked to this account. +

+ ) : ( +
+ {links.map((link) => ( +
+
+
+ + {link.channel.title} + + + {link.channel.type} + + + {link.role} + +
+ + ID: {link.channel.telegramId} + {link.lastProcessedMessageId && + ` | Last msg: ${link.lastProcessedMessageId}`} + +
+ +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/(app)/telegram/_components/account-modal.tsx b/src/app/(app)/telegram/_components/account-modal.tsx new file mode 100644 index 0000000..2aaf923 --- /dev/null +++ b/src/app/(app)/telegram/_components/account-modal.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { AccountForm } from "./account-form"; +import type { AccountRow } from "@/lib/telegram/admin-queries"; + +interface AccountModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + account?: AccountRow; +} + +export function AccountModal({ + open, + onOpenChange, + account, +}: AccountModalProps) { + return ( + + + + + {account ? "Edit Account" : "Add Telegram Account"} + + + {account + ? "Update the account details below." + : "Configure a new Telegram account for ingestion. You'll need an API ID and hash from my.telegram.org."} + + + onOpenChange(false)} + /> + + + ); +} diff --git a/src/app/(app)/telegram/_components/accounts-tab.tsx b/src/app/(app)/telegram/_components/accounts-tab.tsx new file mode 100644 index 0000000..92405e8 --- /dev/null +++ b/src/app/(app)/telegram/_components/accounts-tab.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Plus, Play } from "lucide-react"; +import { toast } from "sonner"; +import { getAccountColumns } from "./account-columns"; +import { AccountModal } from "./account-modal"; +import { AccountLinksDrawer } from "./account-links-drawer"; +import { AuthCodeDialog } from "./auth-code-dialog"; +import { deleteAccount, toggleAccountActive, triggerIngestion } from "../actions"; +import { DataTable } from "@/components/shared/data-table"; +import { DeleteDialog } from "@/components/shared/delete-dialog"; +import { Button } from "@/components/ui/button"; +import type { AccountRow } from "@/lib/telegram/admin-queries"; +import { useDataTable } from "@/hooks/use-data-table"; + +interface AccountsTabProps { + accounts: AccountRow[]; +} + +export function AccountsTab({ accounts }: AccountsTabProps) { + const [isPending, startTransition] = useTransition(); + const [modalOpen, setModalOpen] = useState(false); + const [editAccount, setEditAccount] = useState(); + const [deleteId, setDeleteId] = useState(null); + const [linksAccountId, setLinksAccountId] = useState(null); + const [authCodeAccount, setAuthCodeAccount] = useState(null); + + const columns = getAccountColumns({ + onEdit: (account) => { + setEditAccount(account); + setModalOpen(true); + }, + onToggleActive: (id) => { + startTransition(async () => { + const result = await toggleAccountActive(id); + if (result.success) toast.success("Account toggled"); + else toast.error(result.error); + }); + }, + onDelete: (id) => setDeleteId(id), + onViewLinks: (id) => setLinksAccountId(id), + onEnterCode: (account) => setAuthCodeAccount(account), + onTriggerSync: (id) => { + startTransition(async () => { + const result = await triggerIngestion(id); + if (result.success) toast.success("Ingestion triggered"); + else toast.error(result.error); + }); + }, + }); + + const { table } = useDataTable({ + data: accounts, + columns, + pageCount: 1, + }); + + const handleDelete = () => { + if (!deleteId) return; + startTransition(async () => { + const result = await deleteAccount(deleteId); + if (result.success) { + toast.success("Account deleted"); + setDeleteId(null); + } else { + toast.error(result.error); + } + }); + }; + + return ( +
+
+ + +
+ + + + { + setModalOpen(open); + if (!open) setEditAccount(undefined); + }} + account={editAccount} + /> + + !open && setDeleteId(null)} + title="Delete Account" + description="This will permanently delete this Telegram account and all its channel links. Existing packages will NOT be deleted." + onConfirm={handleDelete} + isLoading={isPending} + /> + + { + if (!open) setLinksAccountId(null); + }} + /> + + { + if (!open) setAuthCodeAccount(null); + }} + /> +
+ ); +} diff --git a/src/app/(app)/telegram/_components/auth-code-dialog.tsx b/src/app/(app)/telegram/_components/auth-code-dialog.tsx new file mode 100644 index 0000000..1ac77d8 --- /dev/null +++ b/src/app/(app)/telegram/_components/auth-code-dialog.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { submitAuthCode } from "../actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import type { AccountRow } from "@/lib/telegram/admin-queries"; + +interface AuthCodeDialogProps { + account: AccountRow | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AuthCodeDialog({ + account, + open, + onOpenChange, +}: AuthCodeDialogProps) { + const [code, setCode] = useState(""); + const [isPending, startTransition] = useTransition(); + + const isPassword = account?.authState === "AWAITING_PASSWORD"; + const title = isPassword ? "Enter 2FA Password" : "Enter Auth Code"; + const description = isPassword + ? "Your Telegram account requires a two-factor authentication password." + : "Enter the code sent to your Telegram app or SMS."; + const placeholder = isPassword ? "Password" : "12345"; + + function handleSubmit() { + if (!account || !code.trim()) return; + + startTransition(async () => { + const result = await submitAuthCode(account.id, { code: code.trim() }); + if (result.success) { + toast.success(isPassword ? "Password submitted" : "Code submitted"); + setCode(""); + onOpenChange(false); + } else { + toast.error(result.error); + } + }); + } + + return ( + { + if (!v) setCode(""); + onOpenChange(v); + }} + > + + + {title} + {description} + +
+ + setCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + autoFocus + /> +
+ + + + +
+
+ ); +} diff --git a/src/app/(app)/telegram/_components/channel-columns.tsx b/src/app/(app)/telegram/_components/channel-columns.tsx new file mode 100644 index 0000000..04f69ad --- /dev/null +++ b/src/app/(app)/telegram/_components/channel-columns.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { + MoreHorizontal, + Pencil, + Trash2, + Power, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { ChannelRow } from "@/lib/telegram/admin-queries"; + +interface ChannelColumnsProps { + onEdit: (channel: ChannelRow) => void; + onToggleActive: (id: string) => void; + onDelete: (id: string) => void; +} + +export function getChannelColumns({ + onEdit, + onToggleActive, + onDelete, +}: ChannelColumnsProps): ColumnDef[] { + return [ + { + accessorKey: "title", + header: "Channel", + cell: ({ row }) => ( +
+ {row.original.title} + + ID: {row.original.telegramId} + +
+ ), + enableHiding: false, + }, + { + accessorKey: "type", + header: "Type", + cell: ({ row }) => ( + + {row.original.type} + + ), + }, + { + accessorKey: "isActive", + header: "Status", + cell: ({ row }) => ( + + {row.original.isActive ? "Active" : "Disabled"} + + ), + }, + { + id: "accounts", + header: "Accounts", + cell: ({ row }) => ( + + {row.original.accountCount} + + ), + }, + { + id: "packages", + header: "Packages", + cell: ({ row }) => ( + + {row.original.packageCount} + + ), + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString()} + + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + onEdit(row.original)}> + + Edit + + onToggleActive(row.original.id)} + > + + {row.original.isActive ? "Disable" : "Enable"} + + + onDelete(row.original.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ), + enableHiding: false, + }, + ]; +} diff --git a/src/app/(app)/telegram/_components/channel-form.tsx b/src/app/(app)/telegram/_components/channel-form.tsx new file mode 100644 index 0000000..a2f8914 --- /dev/null +++ b/src/app/(app)/telegram/_components/channel-form.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { + telegramChannelSchema, + type TelegramChannelInput, +} from "@/schemas/telegram"; +import { createChannel, updateChannel } from "../actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import type { ChannelRow } from "@/lib/telegram/admin-queries"; + +interface ChannelFormProps { + channel?: ChannelRow; + onSuccess: () => void; +} + +export function ChannelForm({ channel, onSuccess }: ChannelFormProps) { + const [isPending, startTransition] = useTransition(); + const isEditing = !!channel; + + const form = useForm({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolver: zodResolver(telegramChannelSchema) as any, + defaultValues: { + telegramId: channel ? Number(channel.telegramId) : (0 as unknown as number), + title: channel?.title ?? "", + type: channel?.type ?? "SOURCE", + }, + }); + + function onSubmit(values: TelegramChannelInput) { + startTransition(async () => { + const result = isEditing + ? await updateChannel(channel!.id, values) + : await createChannel(values); + + if (!result.success) { + toast.error(result.error); + return; + } + + toast.success(isEditing ? "Channel updated" : "Channel created"); + form.reset(); + onSuccess(); + }); + } + + return ( +
+ + ( + + Title + + + + + + )} + /> + + ( + + Telegram ID + + + + + Numeric ID of the Telegram channel or group + + + + )} + /> + + ( + + Type + + + + )} + /> + +
+ +
+ + + ); +} diff --git a/src/app/(app)/telegram/_components/channel-modal.tsx b/src/app/(app)/telegram/_components/channel-modal.tsx new file mode 100644 index 0000000..747f9ec --- /dev/null +++ b/src/app/(app)/telegram/_components/channel-modal.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ChannelForm } from "./channel-form"; +import type { ChannelRow } from "@/lib/telegram/admin-queries"; + +interface ChannelModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + channel?: ChannelRow; +} + +export function ChannelModal({ + open, + onOpenChange, + channel, +}: ChannelModalProps) { + return ( + + + + + {channel ? "Edit Channel" : "Add Channel"} + + + {channel + ? "Update the channel details below." + : "Add a Telegram channel. Source channels are scanned for archives, destination channels receive indexed files."} + + + onOpenChange(false)} + /> + + + ); +} diff --git a/src/app/(app)/telegram/_components/channels-tab.tsx b/src/app/(app)/telegram/_components/channels-tab.tsx new file mode 100644 index 0000000..69deb6a --- /dev/null +++ b/src/app/(app)/telegram/_components/channels-tab.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Plus } from "lucide-react"; +import { toast } from "sonner"; +import { getChannelColumns } from "./channel-columns"; +import { ChannelModal } from "./channel-modal"; +import { deleteChannel, toggleChannelActive } from "../actions"; +import { DataTable } from "@/components/shared/data-table"; +import { DeleteDialog } from "@/components/shared/delete-dialog"; +import { Button } from "@/components/ui/button"; +import type { ChannelRow } from "@/lib/telegram/admin-queries"; +import { useDataTable } from "@/hooks/use-data-table"; + +interface ChannelsTabProps { + channels: ChannelRow[]; +} + +export function ChannelsTab({ channels }: ChannelsTabProps) { + const [isPending, startTransition] = useTransition(); + const [modalOpen, setModalOpen] = useState(false); + const [editChannel, setEditChannel] = useState(); + const [deleteId, setDeleteId] = useState(null); + + const columns = getChannelColumns({ + onEdit: (channel) => { + setEditChannel(channel); + setModalOpen(true); + }, + onToggleActive: (id) => { + startTransition(async () => { + const result = await toggleChannelActive(id); + if (result.success) toast.success("Channel toggled"); + else toast.error(result.error); + }); + }, + onDelete: (id) => setDeleteId(id), + }); + + const { table } = useDataTable({ + data: channels, + columns, + pageCount: 1, + }); + + const handleDelete = () => { + if (!deleteId) return; + startTransition(async () => { + const result = await deleteChannel(deleteId); + if (result.success) { + toast.success("Channel deleted"); + setDeleteId(null); + } else { + toast.error(result.error); + } + }); + }; + + return ( +
+
+ +
+ + + + { + setModalOpen(open); + if (!open) setEditChannel(undefined); + }} + channel={editChannel} + /> + + !open && setDeleteId(null)} + title="Delete Channel" + description="This will permanently delete this channel and unlink it from all accounts. Existing packages will NOT be deleted." + onConfirm={handleDelete} + isLoading={isPending} + /> +
+ ); +} diff --git a/src/app/(app)/telegram/_components/telegram-admin.tsx b/src/app/(app)/telegram/_components/telegram-admin.tsx new file mode 100644 index 0000000..8923caf --- /dev/null +++ b/src/app/(app)/telegram/_components/telegram-admin.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PageHeader } from "@/components/shared/page-header"; +import { AccountsTab } from "./accounts-tab"; +import { ChannelsTab } from "./channels-tab"; +import type { AccountRow, ChannelRow } from "@/lib/telegram/admin-queries"; + +interface TelegramAdminProps { + accounts: AccountRow[]; + channels: ChannelRow[]; +} + +export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) { + return ( +
+ + + + + + Accounts ({accounts.length}) + + + Channels ({channels.length}) + + + + + + + + + + +
+ ); +} diff --git a/src/app/(app)/telegram/actions.ts b/src/app/(app)/telegram/actions.ts new file mode 100644 index 0000000..65984af --- /dev/null +++ b/src/app/(app)/telegram/actions.ts @@ -0,0 +1,345 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; +import type { ActionResult } from "@/types/api.types"; +import { + telegramAccountSchema, + telegramChannelSchema, + linkChannelSchema, + submitAuthCodeSchema, +} from "@/schemas/telegram"; + +const REVALIDATE_PATH = "/telegram"; + +async function requireAdmin(): Promise< + { success: true; userId: string } | { success: false; error: string } +> { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + if (session.user.role !== "ADMIN") + return { success: false, error: "Admin access required" }; + return { success: true, userId: session.user.id }; +} + +// ── Account actions ── + +export async function createAccount( + input: unknown +): Promise> { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const parsed = telegramAccountSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + try { + const account = await prisma.telegramAccount.create({ + data: { + phone: parsed.data.phone.replace(/[\s\-]/g, ""), + displayName: parsed.data.displayName || null, + }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: { id: account.id } }; + } catch (err: unknown) { + if ( + err instanceof Error && + err.message.includes("Unique constraint failed") + ) { + return { success: false, error: "Phone number already registered" }; + } + return { success: false, error: "Failed to create account" }; + } +} + +export async function updateAccount( + id: string, + input: unknown +): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const parsed = telegramAccountSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + const existing = await prisma.telegramAccount.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Account not found" }; + + try { + await prisma.telegramAccount.update({ + where: { id }, + data: { + phone: parsed.data.phone.replace(/[\s\-]/g, ""), + displayName: parsed.data.displayName || null, + }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch (err: unknown) { + if ( + err instanceof Error && + err.message.includes("Unique constraint failed") + ) { + return { success: false, error: "Phone number already registered" }; + } + return { success: false, error: "Failed to update account" }; + } +} + +export async function toggleAccountActive(id: string): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const existing = await prisma.telegramAccount.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Account not found" }; + + try { + await prisma.telegramAccount.update({ + where: { id }, + data: { isActive: !existing.isActive }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to toggle account" }; + } +} + +export async function deleteAccount(id: string): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const existing = await prisma.telegramAccount.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Account not found" }; + + try { + await prisma.telegramAccount.delete({ where: { id } }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to delete account" }; + } +} + +export async function submitAuthCode( + accountId: string, + input: unknown +): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const parsed = submitAuthCodeSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + const existing = await prisma.telegramAccount.findUnique({ + where: { id: accountId }, + }); + if (!existing) return { success: false, error: "Account not found" }; + if ( + existing.authState !== "AWAITING_CODE" && + existing.authState !== "AWAITING_PASSWORD" + ) { + return { success: false, error: "Account is not waiting for a code" }; + } + + try { + await prisma.telegramAccount.update({ + where: { id: accountId }, + data: { authCode: parsed.data.code }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to submit code" }; + } +} + +// ── Channel actions ── + +export async function createChannel( + input: unknown +): Promise> { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const parsed = telegramChannelSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + try { + const channel = await prisma.telegramChannel.create({ + data: { + telegramId: BigInt(parsed.data.telegramId), + title: parsed.data.title, + type: parsed.data.type, + }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: { id: channel.id } }; + } catch (err: unknown) { + if ( + err instanceof Error && + err.message.includes("Unique constraint failed") + ) { + return { success: false, error: "Channel with this Telegram ID already exists" }; + } + return { success: false, error: "Failed to create channel" }; + } +} + +export async function updateChannel( + id: string, + input: unknown +): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const parsed = telegramChannelSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + const existing = await prisma.telegramChannel.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Channel not found" }; + + try { + await prisma.telegramChannel.update({ + where: { id }, + data: { + telegramId: BigInt(parsed.data.telegramId), + title: parsed.data.title, + type: parsed.data.type, + }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch (err: unknown) { + if ( + err instanceof Error && + err.message.includes("Unique constraint failed") + ) { + return { success: false, error: "Channel with this Telegram ID already exists" }; + } + return { success: false, error: "Failed to update channel" }; + } +} + +export async function toggleChannelActive(id: string): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const existing = await prisma.telegramChannel.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Channel not found" }; + + try { + await prisma.telegramChannel.update({ + where: { id }, + data: { isActive: !existing.isActive }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to toggle channel" }; + } +} + +export async function deleteChannel(id: string): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const existing = await prisma.telegramChannel.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Channel not found" }; + + try { + await prisma.telegramChannel.delete({ where: { id } }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to delete channel" }; + } +} + +// ── Account-Channel link actions ── + +export async function linkChannel( + input: unknown +): Promise> { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const parsed = linkChannelSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + try { + const link = await prisma.accountChannelMap.create({ + data: { + accountId: parsed.data.accountId, + channelId: parsed.data.channelId, + role: parsed.data.role, + }, + }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: { id: link.id } }; + } catch (err: unknown) { + if ( + err instanceof Error && + err.message.includes("Unique constraint failed") + ) { + return { success: false, error: "This channel is already linked to this account" }; + } + return { success: false, error: "Failed to link channel" }; + } +} + +export async function unlinkChannel(id: string): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const existing = await prisma.accountChannelMap.findUnique({ + where: { id }, + }); + if (!existing) return { success: false, error: "Link not found" }; + + try { + await prisma.accountChannelMap.delete({ where: { id } }); + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to unlink channel" }; + } +} + +// ── Ingestion trigger ── + +export async function triggerIngestion( + accountId?: string +): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/ingestion/trigger`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.INGESTION_API_KEY || "", + }, + body: JSON.stringify({ accountId }), + } + ); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + return { + success: false, + error: (data as { error?: string }).error || "Failed to trigger ingestion", + }; + } + + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to trigger ingestion" }; + } +} diff --git a/src/app/(app)/telegram/page.tsx b/src/app/(app)/telegram/page.tsx new file mode 100644 index 0000000..097ae89 --- /dev/null +++ b/src/app/(app)/telegram/page.tsx @@ -0,0 +1,17 @@ +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { listAccounts, listChannels } from "@/lib/telegram/admin-queries"; +import { TelegramAdmin } from "./_components/telegram-admin"; + +export default async function TelegramPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + if (session.user.role !== "ADMIN") redirect("/dashboard"); + + const [accounts, channels] = await Promise.all([ + listAccounts(), + listChannels(), + ]); + + return ; +} diff --git a/src/app/api/ingestion/status/route.ts b/src/app/api/ingestion/status/route.ts new file mode 100644 index 0000000..dc8c76d --- /dev/null +++ b/src/app/api/ingestion/status/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { getIngestionStatus } from "@/lib/telegram/queries"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request); + if ("error" in authResult) return authResult.error; + + const accounts = await getIngestionStatus(); + return NextResponse.json({ accounts }); +} diff --git a/src/app/api/ingestion/trigger/route.ts b/src/app/api/ingestion/trigger/route.ts new file mode 100644 index 0000000..ebf9562 --- /dev/null +++ b/src/app/api/ingestion/trigger/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { triggerIngestionSchema } from "@/schemas/telegram"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const authResult = await authenticateApiRequest(request, true); + if ("error" in authResult) return authResult.error; + + let body: unknown = {}; + try { + body = await request.json(); + } catch { + // Empty body is fine — triggers all accounts + } + + const parsed = triggerIngestionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid parameters", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + // Find accounts to trigger + const where: { isActive: boolean; authState: "AUTHENTICATED"; id?: string } = { + isActive: true, + authState: "AUTHENTICATED", + }; + if (parsed.data.accountId) { + where.id = parsed.data.accountId; + } + + const accounts = await prisma.telegramAccount.findMany({ + where, + select: { id: true }, + }); + + if (accounts.length === 0) { + return NextResponse.json( + { triggered: false, message: "No eligible accounts found" }, + { status: 404 } + ); + } + + // Create ingestion runs marked as RUNNING — the worker will pick these up + // when it next polls, or we use pg_notify for immediate pickup + for (const account of accounts) { + // Only create if no run is already RUNNING for this account + const existing = await prisma.ingestionRun.findFirst({ + where: { accountId: account.id, status: "RUNNING" }, + }); + if (!existing) { + await prisma.ingestionRun.create({ + data: { accountId: account.id, status: "RUNNING" }, + }); + } + } + + // Send pg_notify for immediate worker pickup + try { + await prisma.$queryRawUnsafe( + `SELECT pg_notify('ingestion_trigger', $1)`, + accounts.map((a) => a.id).join(",") + ); + } catch { + // pg_notify is best-effort — worker will pick up on next cycle anyway + } + + return NextResponse.json({ + triggered: true, + accountIds: accounts.map((a) => a.id), + message: `Ingestion queued for ${accounts.length} account(s)`, + }); +} diff --git a/src/app/api/telegram/accounts/[accountId]/links/route.ts b/src/app/api/telegram/accounts/[accountId]/links/route.ts new file mode 100644 index 0000000..b289821 --- /dev/null +++ b/src/app/api/telegram/accounts/[accountId]/links/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { listAccountChannelLinks } from "@/lib/telegram/admin-queries"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ accountId: string }> } +) { + const authResult = await authenticateApiRequest(request, true); + if ("error" in authResult) return authResult.error; + + const { accountId } = await params; + const links = await listAccountChannelLinks(accountId); + return NextResponse.json(links); +} diff --git a/src/app/api/telegram/accounts/[accountId]/unlinked-channels/route.ts b/src/app/api/telegram/accounts/[accountId]/unlinked-channels/route.ts new file mode 100644 index 0000000..193f8fd --- /dev/null +++ b/src/app/api/telegram/accounts/[accountId]/unlinked-channels/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { getUnlinkedChannels } from "@/lib/telegram/admin-queries"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ accountId: string }> } +) { + const authResult = await authenticateApiRequest(request, true); + if ("error" in authResult) return authResult.error; + + const { accountId } = await params; + const channels = await getUnlinkedChannels(accountId); + return NextResponse.json(channels); +} diff --git a/src/app/api/zips/[id]/files/route.ts b/src/app/api/zips/[id]/files/route.ts new file mode 100644 index 0000000..2f2fac9 --- /dev/null +++ b/src/app/api/zips/[id]/files/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { listPackageFiles } from "@/lib/telegram/queries"; +import { listFilesSchema } from "@/schemas/telegram"; + +export const dynamic = "force-dynamic"; + +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 { searchParams } = new URL(request.url); + const parsed = listFilesSchema.safeParse(Object.fromEntries(searchParams)); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid parameters", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const result = await listPackageFiles({ + packageId: id, + ...parsed.data, + }); + + return NextResponse.json(result); +} diff --git a/src/app/api/zips/[id]/preview/route.ts b/src/app/api/zips/[id]/preview/route.ts new file mode 100644 index 0000000..7b3e671 --- /dev/null +++ b/src/app/api/zips/[id]/preview/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; + +/** + * GET /api/zips/:id/preview + * Returns the preview thumbnail image as JPEG binary. + * Cached for 1 hour (immutable once set). + */ +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 pkg = await prisma.package.findUnique({ + where: { id }, + select: { previewData: true }, + }); + + if (!pkg || !pkg.previewData) { + return new NextResponse(null, { status: 404 }); + } + + // previewData is stored as Bytes (Buffer) from Prisma + const buffer = + pkg.previewData instanceof Buffer + ? pkg.previewData + : Buffer.from(pkg.previewData); + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": "image/jpeg", + "Content-Length": String(buffer.length), + "Cache-Control": "public, max-age=3600, immutable", + }, + }); +} diff --git a/src/app/api/zips/[id]/route.ts b/src/app/api/zips/[id]/route.ts new file mode 100644 index 0000000..c84283c --- /dev/null +++ b/src/app/api/zips/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { getPackageById } from "@/lib/telegram/queries"; + +export const dynamic = "force-dynamic"; + +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 pkg = await getPackageById(id); + + if (!pkg) { + return NextResponse.json({ error: "Package not found" }, { status: 404 }); + } + + return NextResponse.json(pkg); +} diff --git a/src/app/api/zips/route.ts b/src/app/api/zips/route.ts new file mode 100644 index 0000000..5ede251 --- /dev/null +++ b/src/app/api/zips/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { listPackages } from "@/lib/telegram/queries"; +import { listPackagesSchema } from "@/schemas/telegram"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request); + if ("error" in authResult) return authResult.error; + + const { searchParams } = new URL(request.url); + const parsed = listPackagesSchema.safeParse(Object.fromEntries(searchParams)); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid parameters", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const result = await listPackages(parsed.data); + return NextResponse.json(result); +} diff --git a/src/app/api/zips/search/route.ts b/src/app/api/zips/search/route.ts new file mode 100644 index 0000000..4dc9b78 --- /dev/null +++ b/src/app/api/zips/search/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { authenticateApiRequest } from "@/lib/telegram/api-auth"; +import { searchPackages } from "@/lib/telegram/queries"; +import { searchSchema } from "@/schemas/telegram"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request); + if ("error" in authResult) return authResult.error; + + const { searchParams } = new URL(request.url); + const parsed = searchSchema.safeParse(Object.fromEntries(searchParams)); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid parameters", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { q, ...rest } = parsed.data; + const result = await searchPackages({ query: q, ...rest }); + return NextResponse.json(result); +} diff --git a/src/components/layout/mobile-sidebar.tsx b/src/components/layout/mobile-sidebar.tsx index ea1068d..93c0043 100644 --- a/src/components/layout/mobile-sidebar.tsx +++ b/src/components/layout/mobile-sidebar.tsx @@ -8,6 +8,8 @@ import { Droplets, Paintbrush, Gem, + FileBox, + Send, ClipboardList, Building2, MapPin, @@ -18,7 +20,7 @@ import { cn } from "@/lib/utils"; import { APP_NAME } from "@/lib/constants"; import { SheetHeader, SheetTitle } from "@/components/ui/sheet"; -const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, ClipboardList, Building2, MapPin, Settings }; +const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, FileBox, Send, ClipboardList, Building2, MapPin, Settings }; const navItems = [ { label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const }, @@ -26,6 +28,8 @@ const navItems = [ { label: "Resins", href: "/resins", icon: "Droplets" as const }, { label: "Paints", href: "/paints", icon: "Paintbrush" as const }, { label: "Supplies", href: "/supplies", icon: "Gem" as const }, + { label: "STL Files", href: "/stls", icon: "FileBox" as const }, + { label: "Telegram", href: "/telegram", icon: "Send" as const }, { label: "Usage", href: "/usage", icon: "ClipboardList" as const }, { label: "Vendors", href: "/vendors", icon: "Building2" as const }, { label: "Locations", href: "/locations", icon: "MapPin" as const }, diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 826b5c5..c5ed3cc 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -9,6 +9,8 @@ import { Droplets, Paintbrush, Gem, + FileBox, + Send, ClipboardList, Building2, MapPin, @@ -28,6 +30,8 @@ const icons = { Droplets, Paintbrush, Gem, + FileBox, + Send, ClipboardList, Building2, MapPin, @@ -40,6 +44,8 @@ const navItems = [ { label: "Resins", href: "/resins", icon: "Droplets" as const }, { label: "Paints", href: "/paints", icon: "Paintbrush" as const }, { label: "Supplies", href: "/supplies", icon: "Gem" as const }, + { label: "STL Files", href: "/stls", icon: "FileBox" as const }, + { label: "Telegram", href: "/telegram", icon: "Send" as const }, { label: "Usage", href: "/usage", icon: "ClipboardList" as const }, { label: "Vendors", href: "/vendors", icon: "Building2" as const }, { label: "Locations", href: "/locations", icon: "MapPin" as const }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9681b31..22c2882 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,6 +6,8 @@ export const NAV_ITEMS = [ { label: "Resins", href: "/resins", icon: "Droplets" }, { label: "Paints", href: "/paints", icon: "Paintbrush" }, { label: "Supplies", href: "/supplies", icon: "Gem" }, + { label: "STL Files", href: "/stls", icon: "FileBox" }, + { label: "Telegram", href: "/telegram", icon: "Send" }, { label: "Usage", href: "/usage", icon: "ClipboardList" }, { label: "Vendors", href: "/vendors", icon: "Building2" }, { label: "Locations", href: "/locations", icon: "MapPin" }, diff --git a/src/lib/telegram/admin-queries.ts b/src/lib/telegram/admin-queries.ts new file mode 100644 index 0000000..15e68fd --- /dev/null +++ b/src/lib/telegram/admin-queries.ts @@ -0,0 +1,105 @@ +import { prisma } from "@/lib/prisma"; + +// ── Account queries ── + +export async function listAccounts() { + const accounts = await prisma.telegramAccount.findMany({ + orderBy: { createdAt: "desc" }, + include: { + _count: { select: { channelMaps: true, ingestionRuns: true } }, + }, + }); + + return accounts.map((a) => ({ + id: a.id, + phone: a.phone, + displayName: a.displayName, + isActive: a.isActive, + authState: a.authState, + authCode: a.authCode, + lastSeenAt: a.lastSeenAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + channelCount: a._count.channelMaps, + runCount: a._count.ingestionRuns, + })); +} + +export type AccountRow = Awaited>[number]; + +// ── Channel queries ── + +export async function listChannels() { + const channels = await prisma.telegramChannel.findMany({ + orderBy: { createdAt: "desc" }, + include: { + _count: { select: { accountMaps: true, packages: true } }, + }, + }); + + return channels.map((c) => ({ + id: c.id, + telegramId: c.telegramId.toString(), + title: c.title, + type: c.type, + isActive: c.isActive, + createdAt: c.createdAt.toISOString(), + accountCount: c._count.accountMaps, + packageCount: c._count.packages, + })); +} + +export type ChannelRow = Awaited>[number]; + +// ── Account-Channel link queries ── + +export async function listAccountChannelLinks(accountId: string) { + const links = await prisma.accountChannelMap.findMany({ + where: { accountId }, + include: { + channel: { select: { id: true, title: true, type: true, telegramId: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return links.map((l) => ({ + id: l.id, + accountId: l.accountId, + channelId: l.channelId, + role: l.role, + lastProcessedMessageId: l.lastProcessedMessageId?.toString() ?? null, + channel: { + id: l.channel.id, + title: l.channel.title, + type: l.channel.type, + telegramId: l.channel.telegramId.toString(), + }, + })); +} + +export type AccountChannelLinkRow = Awaited< + ReturnType +>[number]; + +export async function getUnlinkedChannels(accountId: string) { + const linked = await prisma.accountChannelMap.findMany({ + where: { accountId }, + select: { channelId: true }, + }); + const linkedIds = linked.map((l) => l.channelId); + + const unlinked = await prisma.telegramChannel.findMany({ + where: { + id: { notIn: linkedIds }, + isActive: true, + }, + orderBy: { title: "asc" }, + select: { id: true, title: true, type: true, telegramId: true }, + }); + + return unlinked.map((c) => ({ + id: c.id, + title: c.title, + type: c.type, + telegramId: c.telegramId.toString(), + })); +} diff --git a/src/lib/telegram/api-auth.ts b/src/lib/telegram/api-auth.ts new file mode 100644 index 0000000..37386f8 --- /dev/null +++ b/src/lib/telegram/api-auth.ts @@ -0,0 +1,45 @@ +import { auth } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +/** + * Authenticate an API request. Checks: + * 1. X-API-Key header against TELEGRAM_API_KEY env var + * 2. NextAuth session + * + * Returns null if authenticated, or a NextResponse error if not. + */ +export async function authenticateApiRequest( + request: Request, + requireAdmin = false +): Promise<{ error: NextResponse } | { userId: string; role: string }> { + // Check API key first + const apiKey = request.headers.get("X-API-Key"); + const envKey = process.env.TELEGRAM_API_KEY; + + if (apiKey && envKey && apiKey === envKey) { + // API key auth — treated as admin + return { userId: "api-key", role: "ADMIN" }; + } + + // Fall back to session auth + const session = await auth(); + if (!session?.user?.id) { + return { + error: NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ), + }; + } + + if (requireAdmin && session.user.role !== "ADMIN") { + return { + error: NextResponse.json( + { error: "Forbidden: admin role required" }, + { status: 403 } + ), + }; + } + + return { userId: session.user.id, role: session.user.role }; +} diff --git a/src/lib/telegram/queries.ts b/src/lib/telegram/queries.ts new file mode 100644 index 0000000..6c0ed12 --- /dev/null +++ b/src/lib/telegram/queries.ts @@ -0,0 +1,314 @@ +import { prisma } from "@/lib/prisma"; +import type { + PackageListItem, + PackageDetail, + PackageFileItem, + IngestionAccountStatus, +} from "./types"; + +export async function listPackages(options: { + page: number; + limit: number; + channelId?: string; + creator?: string; + sortBy: "indexedAt" | "fileName" | "fileSize"; + order: "asc" | "desc"; +}) { + const where: Record = {}; + if (options.channelId) where.sourceChannelId = options.channelId; + if (options.creator) where.creator = options.creator; + + const [items, total] = await Promise.all([ + prisma.package.findMany({ + where, + orderBy: { [options.sortBy]: options.order }, + skip: (options.page - 1) * options.limit, + take: options.limit, + select: { + id: true, + fileName: true, + fileSize: true, + contentHash: true, + archiveType: true, + fileCount: true, + isMultipart: true, + indexedAt: true, + creator: true, + previewMsgId: true, // cheap null check — avoids loading blob + sourceChannel: { select: { id: true, title: true } }, + }, + }), + prisma.package.count({ where }), + ]); + + const mapped: PackageListItem[] = items.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.previewMsgId !== null, + creator: pkg.creator, + indexedAt: pkg.indexedAt.toISOString(), + sourceChannel: pkg.sourceChannel, + })); + + return { + items: mapped, + pagination: { + page: options.page, + limit: options.limit, + total, + totalPages: Math.ceil(total / options.limit), + }, + }; +} + +export async function getPackageById( + id: string +): Promise { + const pkg = await prisma.package.findUnique({ + where: { id }, + include: { + sourceChannel: { select: { id: true, title: true } }, + ingestionRun: { select: { id: true, startedAt: true } }, + }, + }); + + if (!pkg) return null; + + let destChannel: { id: string; title: string } | null = null; + if (pkg.destChannelId) { + const ch = await prisma.telegramChannel.findUnique({ + where: { id: pkg.destChannelId }, + select: { id: true, title: true }, + }); + destChannel = ch; + } + + return { + id: pkg.id, + fileName: pkg.fileName, + fileSize: pkg.fileSize.toString(), + contentHash: pkg.contentHash, + archiveType: pkg.archiveType, + fileCount: pkg.fileCount, + isMultipart: pkg.isMultipart, + hasPreview: pkg.previewMsgId !== null, + creator: pkg.creator, + partCount: pkg.partCount, + indexedAt: pkg.indexedAt.toISOString(), + sourceChannel: pkg.sourceChannel, + destChannel, + destMessageId: pkg.destMessageId?.toString() ?? null, + sourceMessageId: pkg.sourceMessageId.toString(), + ingestionRun: pkg.ingestionRun + ? { + id: pkg.ingestionRun.id, + startedAt: pkg.ingestionRun.startedAt.toISOString(), + } + : null, + }; +} + +export async function listPackageFiles(options: { + packageId: string; + page: number; + limit: number; + extension?: string; +}) { + const where: { packageId: string; extension?: string } = { + packageId: options.packageId, + }; + if (options.extension) { + where.extension = options.extension; + } + + const [items, total] = await Promise.all([ + prisma.packageFile.findMany({ + where, + orderBy: { path: "asc" }, + skip: (options.page - 1) * options.limit, + take: options.limit, + }), + prisma.packageFile.count({ where }), + ]); + + const mapped: PackageFileItem[] = items.map((f) => ({ + id: f.id, + path: f.path, + fileName: f.fileName, + extension: f.extension, + compressedSize: f.compressedSize.toString(), + uncompressedSize: f.uncompressedSize.toString(), + crc32: f.crc32, + })); + + return { + items: mapped, + pagination: { + page: options.page, + limit: options.limit, + total, + totalPages: Math.ceil(total / options.limit), + }, + }; +} + +export async function searchPackages(options: { + query: string; + page: number; + limit: number; + searchIn: "packages" | "files" | "both"; +}) { + const q = options.query; + + if (options.searchIn === "files" || options.searchIn === "both") { + // Search in package files, return parent packages + const fileMatches = await prisma.packageFile.findMany({ + where: { + OR: [ + { fileName: { contains: q, mode: "insensitive" } }, + { path: { contains: q, mode: "insensitive" } }, + ], + }, + select: { packageId: true }, + distinct: ["packageId"], + }); + + const packageIds = fileMatches.map((f) => f.packageId); + + const packageNameIds = + options.searchIn === "both" + ? ( + await prisma.package.findMany({ + where: { fileName: { contains: q, mode: "insensitive" } }, + select: { id: true }, + }) + ).map((p) => p.id) + : []; + + const allIds = [...new Set([...packageIds, ...packageNameIds])]; + + const [items, total] = await Promise.all([ + prisma.package.findMany({ + where: { id: { in: allIds } }, + orderBy: { indexedAt: "desc" }, + skip: (options.page - 1) * options.limit, + take: options.limit, + select: { + id: true, + fileName: true, + fileSize: true, + contentHash: true, + archiveType: true, + fileCount: true, + isMultipart: true, + indexedAt: true, + creator: true, + previewMsgId: true, + sourceChannel: { select: { id: true, title: true } }, + }, + }), + Promise.resolve(allIds.length), + ]); + + const mapped: PackageListItem[] = items.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.previewMsgId !== null, + creator: pkg.creator, + indexedAt: pkg.indexedAt.toISOString(), + sourceChannel: pkg.sourceChannel, + })); + + return { + items: mapped, + pagination: { + page: options.page, + limit: options.limit, + total, + totalPages: Math.ceil(total / options.limit), + }, + }; + } + + // Search packages only + return listPackages({ + page: options.page, + limit: options.limit, + sortBy: "indexedAt", + order: "desc", + }); +} + +export async function getIngestionStatus(): Promise { + const accounts = await prisma.telegramAccount.findMany({ + orderBy: { createdAt: "asc" }, + }); + + const statuses: IngestionAccountStatus[] = []; + + for (const account of accounts) { + const lastRun = await prisma.ingestionRun.findFirst({ + where: { accountId: account.id, status: { not: "RUNNING" } }, + orderBy: { startedAt: "desc" }, + }); + + const currentRun = await prisma.ingestionRun.findFirst({ + where: { accountId: account.id, status: "RUNNING" }, + orderBy: { startedAt: "desc" }, + }); + + statuses.push({ + id: account.id, + displayName: account.displayName, + phone: account.phone, + isActive: account.isActive, + authState: account.authState, + lastSeenAt: account.lastSeenAt?.toISOString() ?? null, + lastRun: lastRun + ? { + id: lastRun.id, + status: lastRun.status, + startedAt: lastRun.startedAt.toISOString(), + finishedAt: lastRun.finishedAt?.toISOString() ?? null, + messagesScanned: lastRun.messagesScanned, + zipsFound: lastRun.zipsFound, + zipsDuplicate: lastRun.zipsDuplicate, + zipsIngested: lastRun.zipsIngested, + } + : null, + currentRun: currentRun + ? { + id: currentRun.id, + startedAt: currentRun.startedAt.toISOString(), + messagesScanned: currentRun.messagesScanned, + zipsFound: currentRun.zipsFound, + zipsDuplicate: currentRun.zipsDuplicate, + zipsIngested: currentRun.zipsIngested, + // Live activity tracking + currentActivity: currentRun.currentActivity, + currentStep: currentRun.currentStep, + currentChannel: currentRun.currentChannel, + currentFile: currentRun.currentFile, + currentFileNum: currentRun.currentFileNum, + totalFiles: currentRun.totalFiles, + downloadedBytes: currentRun.downloadedBytes?.toString() ?? null, + totalBytes: currentRun.totalBytes?.toString() ?? null, + downloadPercent: currentRun.downloadPercent, + lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null, + } + : null, + }); + } + + return statuses; +} diff --git a/src/lib/telegram/types.ts b/src/lib/telegram/types.ts new file mode 100644 index 0000000..631bf8c --- /dev/null +++ b/src/lib/telegram/types.ts @@ -0,0 +1,88 @@ +export interface PackageListItem { + id: string; + fileName: string; + fileSize: string; // BigInt serialized as string + contentHash: string; + archiveType: "ZIP" | "RAR"; + fileCount: number; + isMultipart: boolean; + hasPreview: boolean; + creator: string | null; + indexedAt: string; + sourceChannel: { + id: string; + title: string; + }; +} + +export interface PackageDetail extends PackageListItem { + partCount: number; + destChannel: { + id: string; + title: string; + } | null; + destMessageId: string | null; + sourceMessageId: string; + ingestionRun: { + id: string; + startedAt: string; + } | null; +} + +export interface PackageFileItem { + id: string; + path: string; + fileName: string; + extension: string | null; + compressedSize: string; + uncompressedSize: string; + crc32: string | null; +} + +export interface PaginatedResponse { + items: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +export interface IngestionAccountStatus { + id: string; + displayName: string | null; + phone: string; + isActive: boolean; + authState: string; + lastSeenAt: string | null; + lastRun: { + id: string; + status: string; + startedAt: string; + finishedAt: string | null; + messagesScanned: number; + zipsFound: number; + zipsDuplicate: number; + zipsIngested: number; + } | null; + currentRun: { + id: string; + startedAt: string; + messagesScanned: number; + zipsFound: number; + zipsDuplicate: number; + zipsIngested: number; + // Live activity tracking + currentActivity: string | null; + currentStep: string | null; + currentChannel: string | null; + currentFile: string | null; + currentFileNum: number | null; + totalFiles: number | null; + downloadedBytes: string | null; // BigInt serialized as string + totalBytes: string | null; // BigInt serialized as string + downloadPercent: number | null; + lastActivityAt: string | null; + } | null; +} diff --git a/src/schemas/telegram.ts b/src/schemas/telegram.ts new file mode 100644 index 0000000..a6125ae --- /dev/null +++ b/src/schemas/telegram.ts @@ -0,0 +1,71 @@ +import { z } from "zod/v4"; + +export const paginationSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); + +export const listPackagesSchema = paginationSchema.extend({ + channelId: z.string().optional(), + creator: z.string().optional(), + sortBy: z.enum(["indexedAt", "fileName", "fileSize"]).default("indexedAt"), + order: z.enum(["asc", "desc"]).default("desc"), +}); + +export const listFilesSchema = paginationSchema.extend({ + limit: z.coerce.number().int().min(1).max(500).default(50), + extension: z.string().optional(), +}); + +export const searchSchema = paginationSchema.extend({ + q: z.string().min(1), + searchIn: z.enum(["packages", "files", "both"]).default("both"), +}); + +export const triggerIngestionSchema = z.object({ + accountId: z.string().optional(), +}); + +// ── Account CRUD ── + +export const telegramAccountSchema = z.object({ + phone: z + .string() + .min(1, "Phone number is required") + .regex(/^\+?\d[\d\s\-]{6,20}$/, "Invalid phone format (e.g. +31612345678)"), + displayName: z.string().max(64).optional().or(z.literal("")), +}); + +export type TelegramAccountInput = z.infer; + +export const submitAuthCodeSchema = z.object({ + code: z.string().min(3, "Auth code is required").max(10), +}); + +export type SubmitAuthCodeInput = z.infer; + +export const submitPasswordSchema = z.object({ + password: z.string().min(1, "Password is required"), +}); + +export type SubmitPasswordInput = z.infer; + +// ── Channel CRUD ── + +export const telegramChannelSchema = z.object({ + telegramId: z.coerce.number().int().min(1, "Telegram ID is required"), + title: z.string().min(1, "Title is required").max(256), + type: z.enum(["SOURCE", "DESTINATION"]), +}); + +export type TelegramChannelInput = z.infer; + +// ── Account-Channel linking ── + +export const linkChannelSchema = z.object({ + accountId: z.string().min(1), + channelId: z.string().min(1), + role: z.enum(["READER", "WRITER"]).default("READER"), +}); + +export type LinkChannelInput = z.infer; diff --git a/tsconfig.json b/tsconfig.json index 8b77395..9c0b009 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules", "prisma/seed.ts", "scripts/**"] + "exclude": ["node_modules", "prisma/seed.ts", "scripts/**", "worker/**"] } diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..e8e2fda --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,48 @@ +# ── Stage 1: Install production deps ───────────────────────── +FROM node:20-bookworm-slim AS deps + +RUN sed -i 's/^Components: main$/Components: main non-free/' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y \ + libssl-dev zlib1g-dev unrar \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY worker/package.json worker/package-lock.json* ./ +COPY prisma/ ./prisma/ + +# Install ALL deps (including devDependencies for tsc) and generate Prisma +RUN npm ci && npx prisma generate + +# ── Stage 2: Build TypeScript ───────────────────────────────── +FROM deps AS builder + +COPY worker/tsconfig.json ./ +COPY worker/src/ ./src/ +RUN npx tsc + +# ── Stage 3: Production runner ──────────────────────────────── +FROM node:20-bookworm-slim AS runner + +RUN sed -i 's/^Components: main$/Components: main non-free/' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y \ + libssl3 zlib1g unrar \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy only production node_modules (prune devDeps) +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/dist ./dist + +# Re-generate Prisma client for production (after pruning isn't needed since we copy all) +RUN npx prisma generate + +RUN addgroup --system worker && adduser --system --ingroup worker worker +RUN mkdir -p /data/tdlib /tmp/zips && chown -R worker:worker /data/tdlib /tmp/zips +USER worker + +VOLUME ["/data/tdlib", "/tmp/zips"] + +CMD ["node", "dist/index.js"] diff --git a/worker/package-lock.json b/worker/package-lock.json new file mode 100644 index 0000000..a1d88fe --- /dev/null +++ b/worker/package-lock.json @@ -0,0 +1,2140 @@ +{ + "name": "dragonsstash-worker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dragonsstash-worker", + "version": "0.1.0", + "dependencies": { + "@prisma/adapter-pg": "^7.4.0", + "@prisma/client": "^7.4.0", + "pg": "^8.18.0", + "pino": "^9.6.0", + "prebuilt-tdlib": "^0.1008050.0", + "tdl": "^8.0.0", + "yauzl": "^3.2.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/pg": "^8.16.0", + "@types/yauzl": "^2.10.3", + "prisma": "^7.4.0", + "tsx": "^4.21.0", + "typescript": "^5" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prebuilt-tdlib/darwin-arm64": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008050.0.tgz", + "integrity": "sha512-XrWN7M1gfvnzOBRX0YdXVfhSxIDSs/ZJ16QJ0ILDKe+grOFl/cfl7lwB/hK/MlHC6Rev56f5X7xaWnjMh0vktQ==", + "cpu": [ + "arm64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@prebuilt-tdlib/darwin-x64": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008050.0.tgz", + "integrity": "sha512-a1UfBW0lYx4tUy5viMPtsbqBfBncCAgDu3FPjljfYTHjP8wfkKFxpp5+8wdxhyqdy3QriWaipVtUXQgOeEWMJg==", + "cpu": [ + "x64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@prebuilt-tdlib/linux-arm64-glibc": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008050.0.tgz", + "integrity": "sha512-HRGspdQYzaBkU+W2M8uY5OgOkmgfTkyHkTYan/dn7EE/38QdIFW0YTvmGrl3DoFV2PA+SeJQw0xqK8tMSyHKaA==", + "cpu": [ + "arm64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@prebuilt-tdlib/linux-x64-glibc": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008050.0.tgz", + "integrity": "sha512-Yf6ve3Dzxc66kV1cijFLn7EXKhPN5YHTjtJABEaCR5euetCI2wZp/1uBsXvyYTuFXqQbMfjO3xUCXUIBhLoChw==", + "cpu": [ + "x64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@prebuilt-tdlib/win32-x64": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008050.0.tgz", + "integrity": "sha512-4v8tU5bodMcLhzrWWXzIzqdHBIpq0wim+7sDmQWQIMy3kDeIzVtpuM+vQjxrGoeH9oWr2WXSRKuj93ld7G5NbQ==", + "cpu": [ + "x64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.1.tgz", + "integrity": "sha512-AH9XrqvSoBAaStn0Gm/sAnF97pDKz8uLpNmn51j1S9O9dhUva6LIxGdoDiiU9VXRIR89wAJXsvJSy+mK40m2xw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.4.1", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.1.tgz", + "integrity": "sha512-pgIll2W1NVdof37xLeyySW+yfQ4rI+ERGCRwnO3BjVOx42GpYq6jhTyuALK8VKirvJJIvImgfGDA2qwhYVvMuA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.4.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.1.tgz", + "integrity": "sha512-8fy74OMYC7mt9cJ2MncIDk1awPRgmtXVvwTN2FlW4JVhbck8Dgt0wTkhPG85myfj4ZeP2stjF9Sdg12n5HrpQg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.1.tgz", + "integrity": "sha512-vteSXm8N46bo3FW9MhPGVHAj+KRgrR6TWtlSk6GqToCKjTnOexXdPZyiDyEsfVW38YhqEmVl6w/6iHN8uYVJcw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.1.tgz", + "integrity": "sha512-qEtzO8oLouRv18JDQUC3G3Gnv+fGVscHZm/x1DBB/WT+kOvPDQLM2woX6IGgWnSMYYlrxjuALshT7G/blvY0bQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.1.tgz", + "integrity": "sha512-gEZOC2tnlHaZNbHUdbK8YvQphq2tKq/Ovu1YixJ/hPSutDAvNzC3R+xUeBuJ4AJp236eELMzwxb7rgo3UbRkTg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, + "node_modules/@prisma/engines": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.1.tgz", + "integrity": "sha512-BZEBdHvNJx5PzIG37EI/Zi5UUI5hGWjkYsQmKa7OIK6evAvebOTwutjS/VRI6cA6grmA52eLZR+oekGRMqkKxQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1", + "@prisma/engines-version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", + "@prisma/fetch-engine": "7.4.1", + "@prisma/get-platform": "7.4.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3.tgz", + "integrity": "sha512-fUxVd1TjOW8K4XsZ8dAm88sDW5Ry7AxWDfsYEWwScS6Fjo3caKC6hgNumUfsmsy0Il9LjDn5X0PpVXNt3iwayw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.1.tgz", + "integrity": "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.1.tgz", + "integrity": "sha512-Z9kbuxX2bvEsyeS3LZEiEnxG0lVtZbpYgaAnPj69N+A9f2De8Lta0EoFtld9zhfERVPIQWhSWUc8himky3qYdA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1", + "@prisma/engines-version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", + "@prisma/get-platform": "7.4.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.1.tgz", + "integrity": "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuilt-tdlib": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008050.0.tgz", + "integrity": "sha512-CfeQE1rG51d2iC6m72fzrbCW4mqI17ugil9pVurWHtfUJi1Fcn7zadpTzDoUl4oc1dEtKgM7S24DVP67gcl4SQ==", + "license": "MIT", + "optionalDependencies": { + "@prebuilt-tdlib/darwin-arm64": "0.1008050.0", + "@prebuilt-tdlib/darwin-x64": "0.1008050.0", + "@prebuilt-tdlib/linux-arm64-glibc": "0.1008050.0", + "@prebuilt-tdlib/linux-x64-glibc": "0.1008050.0", + "@prebuilt-tdlib/win32-x64": "0.1008050.0" + } + }, + "node_modules/prisma": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.1.tgz", + "integrity": "sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.4.1", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.4.1", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/tdl": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/tdl/-/tdl-8.0.2.tgz", + "integrity": "sha512-KYxlJ4eao7FUu91U1dCDkaHmK70JAyZ1KqitkKqpPC7rxAiXWhaYxddWvt84UxIYoWbgdd0B70FYJ4p/YqpFCA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "node-addon-api": "^7.1.1", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.14.0" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yauzl": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + } + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..414401d --- /dev/null +++ b/worker/package.json @@ -0,0 +1,28 @@ +{ + "name": "dragonsstash-worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.4.0", + "@prisma/client": "^7.4.0", + "pg": "^8.18.0", + "pino": "^9.6.0", + "prebuilt-tdlib": "^0.1008050.0", + "tdl": "^8.0.0", + "yauzl": "^3.2.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/pg": "^8.16.0", + "@types/yauzl": "^2.10.3", + "prisma": "^7.4.0", + "tsx": "^4.21.0", + "typescript": "^5" + } +} diff --git a/worker/src/archive/creator.ts b/worker/src/archive/creator.ts new file mode 100644 index 0000000..dd04df7 --- /dev/null +++ b/worker/src/archive/creator.ts @@ -0,0 +1,21 @@ +/** + * Extract a creator name from common archive file naming patterns. + * + * Priority in the worker: topic name > filename extraction. + * This is the fallback when no forum topic name is available. + * + * Patterns handled (split on ` - `): + * "Mammoth Factory - 2026-01.zip" → "Mammoth Factory" + * "Artist Name - Pack Title.part01.rar" → "Artist Name" + * "some_random_file.zip" → null + */ +export function extractCreatorFromFileName(fileName: string): string | null { + // Strip archive extensions (.zip, .rar, .part01.rar, .z01, etc.) + const bare = fileName.replace(/(\.(part\d+\.rar|z\d{2}|zip|rar))+$/i, ""); + + const idx = bare.indexOf(" - "); + if (idx <= 0) return null; + + const creator = bare.slice(0, idx).trim(); + return creator.length > 0 ? creator : null; +} diff --git a/worker/src/archive/detect.ts b/worker/src/archive/detect.ts new file mode 100644 index 0000000..44eeb16 --- /dev/null +++ b/worker/src/archive/detect.ts @@ -0,0 +1,96 @@ +export type ArchiveFormat = "ZIP" | "RAR"; + +export interface MultipartInfo { + baseName: string; + partNumber: number; + format: ArchiveFormat; + pattern: "ZIP_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SINGLE"; +} + +const patterns: { + regex: RegExp; + format: ArchiveFormat; + pattern: MultipartInfo["pattern"]; + getBaseName: (match: RegExpMatchArray) => string; + getPartNumber: (match: RegExpMatchArray) => number; +}[] = [ + // pack.zip.001, pack.zip.002 + { + regex: /^(.+\.zip)\.(\d{3,})$/i, + format: "ZIP", + pattern: "ZIP_NUMBERED", + getBaseName: (m) => m[1], + getPartNumber: (m) => parseInt(m[2], 10), + }, + // pack.z01, pack.z02 (legacy split — final part is pack.zip) + { + regex: /^(.+)\.z(\d{2,})$/i, + format: "ZIP", + pattern: "ZIP_LEGACY", + getBaseName: (m) => m[1], + getPartNumber: (m) => parseInt(m[2], 10), + }, + // pack.part1.rar, pack.part2.rar + { + regex: /^(.+)\.part(\d+)\.rar$/i, + format: "RAR", + pattern: "RAR_PART", + getBaseName: (m) => m[1], + getPartNumber: (m) => parseInt(m[2], 10), + }, + // pack.r00, pack.r01 (legacy split — final part is pack.rar) + { + regex: /^(.+)\.r(\d{2,})$/i, + format: "RAR", + pattern: "RAR_LEGACY", + getBaseName: (m) => m[1], + getPartNumber: (m) => parseInt(m[2], 10), + }, +]; + +/** + * Detect if a filename is an archive and extract multipart info. + */ +export function detectArchive(fileName: string): MultipartInfo | null { + // Check multipart patterns first + for (const p of patterns) { + const match = fileName.match(p.regex); + if (match) { + return { + baseName: p.getBaseName(match), + partNumber: p.getPartNumber(match), + format: p.format, + pattern: p.pattern, + }; + } + } + + // Single .zip file — could be a standalone or the final part of a ZIP_LEGACY set + if (/\.zip$/i.test(fileName)) { + return { + baseName: fileName.replace(/\.zip$/i, ""), + partNumber: -1, // -1 signals "could be single or final legacy part" + format: "ZIP", + pattern: "SINGLE", + }; + } + + // Single .rar file — could be standalone or final part of RAR_LEGACY set + if (/\.rar$/i.test(fileName)) { + return { + baseName: fileName.replace(/\.rar$/i, ""), + partNumber: -1, + format: "RAR", + pattern: "SINGLE", + }; + } + + return null; +} + +/** + * Check if a filename looks like any archive attachment we should process. + */ +export function isArchiveAttachment(fileName: string): boolean { + return detectArchive(fileName) !== null; +} diff --git a/worker/src/archive/hash.ts b/worker/src/archive/hash.ts new file mode 100644 index 0000000..058024b --- /dev/null +++ b/worker/src/archive/hash.ts @@ -0,0 +1,25 @@ +import { createReadStream } from "fs"; +import { createHash } from "crypto"; +import { pipeline } from "stream/promises"; +import { PassThrough } from "stream"; + +/** + * Compute SHA-256 hash of one or more files by streaming them in order. + * Memory usage: O(1) — reads in 64KB chunks regardless of total size. + * For multipart archives, pass all parts sorted by part number. + */ +export async function hashParts(filePaths: string[]): Promise { + const hash = createHash("sha256"); + for (const filePath of filePaths) { + await pipeline( + createReadStream(filePath), + new PassThrough({ + transform(chunk, _encoding, callback) { + hash.update(chunk); + callback(); + }, + }) + ); + } + return hash.digest("hex"); +} diff --git a/worker/src/archive/multipart.ts b/worker/src/archive/multipart.ts new file mode 100644 index 0000000..055ea23 --- /dev/null +++ b/worker/src/archive/multipart.ts @@ -0,0 +1,100 @@ +import { detectArchive, type ArchiveFormat, type MultipartInfo } from "./detect.js"; +import { config } from "../util/config.js"; +import { childLogger } from "../util/logger.js"; + +const log = childLogger("multipart"); + +export interface TelegramMessage { + id: bigint; + fileName: string; + fileId: string; + fileSize: bigint; + date: Date; +} + +export interface ArchiveSet { + type: ArchiveFormat; + baseName: string; + parts: TelegramMessage[]; + isMultipart: boolean; +} + +/** + * Group messages into archive sets (single files + multipart groups). + * Messages should be pre-filtered to only include archive attachments. + */ +export function groupArchiveSets(messages: TelegramMessage[]): ArchiveSet[] { + // Detect and annotate each message + const annotated: { msg: TelegramMessage; info: MultipartInfo }[] = []; + for (const msg of messages) { + const info = detectArchive(msg.fileName); + if (info) { + annotated.push({ msg, info }); + } + } + + // Group by baseName + format + const groups = new Map(); + for (const item of annotated) { + const key = `${item.info.format}:${item.info.baseName.toLowerCase()}`; + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + + const results: ArchiveSet[] = []; + + for (const [, group] of groups) { + const format = group[0].info.format; + const baseName = group[0].info.baseName; + + // Separate explicit multipart entries from potential singles + const multipartEntries = group.filter((g) => g.info.pattern !== "SINGLE"); + const singleEntries = group.filter((g) => g.info.pattern === "SINGLE"); + + if (multipartEntries.length > 0) { + // This is a multipart set + // Check if any single entry is the "final part" of a legacy split + const allEntries = [...multipartEntries, ...singleEntries]; + + // Check time span — skip if parts span too long + const dates = allEntries.map((e) => e.msg.date.getTime()); + const span = Math.max(...dates) - Math.min(...dates); + const maxSpanMs = config.multipartTimeoutHours * 60 * 60 * 1000; + + if (span > maxSpanMs) { + log.warn( + { baseName, format, span: span / 3600000 }, + "Multipart set spans too long, skipping" + ); + continue; + } + + // Sort by part number (singles get a very high number so they come last — they're the final part) + allEntries.sort((a, b) => { + const aNum = a.info.partNumber === -1 ? 999999 : a.info.partNumber; + const bNum = b.info.partNumber === -1 ? 999999 : b.info.partNumber; + return aNum - bNum; + }); + + results.push({ + type: format, + baseName, + parts: allEntries.map((e) => e.msg), + isMultipart: true, + }); + } else { + // All entries are singles — each is its own archive set + for (const entry of singleEntries) { + results.push({ + type: format, + baseName: entry.info.baseName, + parts: [entry.msg], + isMultipart: false, + }); + } + } + } + + return results; +} diff --git a/worker/src/archive/rar-reader.ts b/worker/src/archive/rar-reader.ts new file mode 100644 index 0000000..0fd72eb --- /dev/null +++ b/worker/src/archive/rar-reader.ts @@ -0,0 +1,90 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import { childLogger } from "../util/logger.js"; +import type { FileEntry } from "./zip-reader.js"; + +const execFileAsync = promisify(execFile); +const log = childLogger("rar-reader"); + +/** + * Parse output of `unrar l -v ` to extract file metadata. + * unrar automatically discovers sibling parts when they're co-located. + */ +export async function readRarContents( + firstPartPath: string +): Promise { + try { + const { stdout } = await execFileAsync("unrar", ["l", "-v", firstPartPath], { + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, // 10MB for very large archives + }); + + return parseUnrarOutput(stdout); + } catch (err) { + log.warn({ err, file: firstPartPath }, "Failed to read RAR contents"); + return []; // Fallback: return empty on error + } +} + +/** + * Parse the tabular output of `unrar l -v`. + * + * Example output format: + * Archive: test.rar + * Details: RAR 5 + * + * Attributes Size Packed Ratio Date Time CRC-32 Name + * ----------- --------- --------- ----- -------- ----- -------- ---- + * ...A.... 12345 10234 83% 2024-01-15 10:30 DEADBEEF folder/file.stl + * ----------- --------- --------- ----- -------- ----- -------- ---- + */ +function parseUnrarOutput(output: string): FileEntry[] { + const entries: FileEntry[] = []; + const lines = output.split("\n"); + + let inFileList = false; + let separatorCount = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + // Detect separator lines (------- pattern) + if (/^-{5,}/.test(trimmed)) { + separatorCount++; + if (separatorCount === 1) { + inFileList = true; + } else if (separatorCount >= 2) { + inFileList = false; + } + continue; + } + + if (!inFileList) continue; + + // Parse file entry line + // Format: Attributes Size Packed Ratio Date Time CRC Name + const match = trimmed.match( + /^\S+\s+(\d+)\s+(\d+)\s+\d+%\s+\S+\s+\S+\s+([0-9A-Fa-f]+)\s+(.+)$/ + ); + + if (match) { + const [, uncompressedStr, compressedStr, crc32, filePath] = match; + + // Skip directory entries (typically end with / or have size 0 with dir attributes) + if (filePath.endsWith("/") || filePath.endsWith("\\")) continue; + + const ext = path.extname(filePath).toLowerCase(); + entries.push({ + path: filePath, + fileName: path.basename(filePath), + extension: ext ? ext.slice(1) : null, + compressedSize: BigInt(compressedStr), + uncompressedSize: BigInt(uncompressedStr), + crc32: crc32.toLowerCase(), + }); + } + } + + return entries; +} diff --git a/worker/src/archive/split.ts b/worker/src/archive/split.ts new file mode 100644 index 0000000..136d3e6 --- /dev/null +++ b/worker/src/archive/split.ts @@ -0,0 +1,48 @@ +import { createReadStream, createWriteStream } from "fs"; +import { stat } from "fs/promises"; +import path from "path"; +import { pipeline } from "stream/promises"; +import { childLogger } from "../util/logger.js"; + +const log = childLogger("split"); + +/** 2GB in bytes — Telegram's file size limit */ +const MAX_PART_SIZE = 2n * 1024n * 1024n * 1024n; + +/** + * Split a file into ≤2GB parts using byte-level splitting. + * Returns paths to the split parts. If the file is already ≤2GB, returns the original path. + */ +export async function byteLevelSplit(filePath: string): Promise { + const stats = await stat(filePath); + const fileSize = BigInt(stats.size); + + if (fileSize <= MAX_PART_SIZE) { + return [filePath]; + } + + const dir = path.dirname(filePath); + const baseName = path.basename(filePath); + const partSize = Number(MAX_PART_SIZE); + const totalParts = Math.ceil(Number(fileSize) / partSize); + const parts: string[] = []; + + log.info({ filePath, fileSize: Number(fileSize), totalParts }, "Splitting file"); + + for (let i = 0; i < totalParts; i++) { + const partNum = String(i + 1).padStart(3, "0"); + const partPath = path.join(dir, `${baseName}.${partNum}`); + const start = i * partSize; + const end = Math.min(start + partSize - 1, Number(fileSize) - 1); + + await pipeline( + createReadStream(filePath, { start, end }), + createWriteStream(partPath) + ); + + parts.push(partPath); + } + + log.info({ filePath, parts: parts.length }, "File split complete"); + return parts; +} diff --git a/worker/src/archive/zip-reader.ts b/worker/src/archive/zip-reader.ts new file mode 100644 index 0000000..53cbf7d --- /dev/null +++ b/worker/src/archive/zip-reader.ts @@ -0,0 +1,61 @@ +import yauzl from "yauzl"; +import path from "path"; +import { childLogger } from "../util/logger.js"; + +const log = childLogger("zip-reader"); + +export interface FileEntry { + path: string; + fileName: string; + extension: string | null; + compressedSize: bigint; + uncompressedSize: bigint; + crc32: string | null; +} + +/** + * Read the central directory of a ZIP file without extracting any contents. + * For multipart ZIPs, pass the paths sorted by part order. + * We attempt to read from the last part first (central directory is at the end). + */ +export async function readZipCentralDirectory( + filePaths: string[] +): Promise { + // The central directory lives at the end of the last file + const targetFile = filePaths[filePaths.length - 1]; + + return new Promise((resolve, reject) => { + yauzl.open(targetFile, { lazyEntries: true, autoClose: true }, (err, zipFile) => { + if (err) { + log.warn({ err, file: targetFile }, "Failed to open ZIP for reading"); + resolve([]); // Fallback: return empty on error + return; + } + + const entries: FileEntry[] = []; + + zipFile.readEntry(); + zipFile.on("entry", (entry: yauzl.Entry) => { + // Skip directories + if (!entry.fileName.endsWith("/")) { + const ext = path.extname(entry.fileName).toLowerCase(); + entries.push({ + path: entry.fileName, + fileName: path.basename(entry.fileName), + extension: ext ? ext.slice(1) : null, // Remove leading dot + compressedSize: BigInt(entry.compressedSize), + uncompressedSize: BigInt(entry.uncompressedSize), + crc32: entry.crc32 !== 0 ? entry.crc32.toString(16).padStart(8, "0") : null, + }); + } + zipFile.readEntry(); + }); + + zipFile.on("end", () => resolve(entries)); + zipFile.on("error", (error) => { + log.warn({ error, file: targetFile }, "Error reading ZIP entries"); + resolve(entries); // Return whatever we got + }); + }); + }); +} diff --git a/worker/src/db/client.ts b/worker/src/db/client.ts new file mode 100644 index 0000000..1b125a1 --- /dev/null +++ b/worker/src/db/client.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import pg from "pg"; +import { config } from "../util/config.js"; + +const pool = new pg.Pool({ + connectionString: config.databaseUrl, + max: 5, +}); + +const adapter = new PrismaPg(pool); +export const db = new PrismaClient({ adapter }); + +export { pool }; diff --git a/worker/src/db/locks.ts b/worker/src/db/locks.ts new file mode 100644 index 0000000..51df04f --- /dev/null +++ b/worker/src/db/locks.ts @@ -0,0 +1,56 @@ +import { pool } from "./client.js"; +import { childLogger } from "../util/logger.js"; + +const log = childLogger("locks"); + +/** + * Derive a stable 32-bit integer lock ID from an account ID string. + * PostgreSQL advisory locks use bigint, but we use 32-bit for safety. + */ +function hashToLockId(accountId: string): number { + let hash = 0; + for (let i = 0; i < accountId.length; i++) { + const char = accountId.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Try to acquire a PostgreSQL advisory lock for an account. + * Returns true if acquired, false if already held by another session. + */ +export async function tryAcquireLock(accountId: string): Promise { + const lockId = hashToLockId(accountId); + const client = await pool.connect(); + try { + const result = await client.query<{ pg_try_advisory_lock: boolean }>( + "SELECT pg_try_advisory_lock($1)", + [lockId] + ); + const acquired = result.rows[0]?.pg_try_advisory_lock ?? false; + if (acquired) { + log.debug({ accountId, lockId }, "Advisory lock acquired"); + } else { + log.debug({ accountId, lockId }, "Advisory lock already held"); + } + return acquired; + } finally { + client.release(); + } +} + +/** + * Release the advisory lock for an account. + */ +export async function releaseLock(accountId: string): Promise { + const lockId = hashToLockId(accountId); + const client = await pool.connect(); + try { + await client.query("SELECT pg_advisory_unlock($1)", [lockId]); + log.debug({ accountId, lockId }, "Advisory lock released"); + } finally { + client.release(); + } +} diff --git a/worker/src/db/queries.ts b/worker/src/db/queries.ts new file mode 100644 index 0000000..7a50e38 --- /dev/null +++ b/worker/src/db/queries.ts @@ -0,0 +1,270 @@ +import { db } from "./client.js"; +import type { ArchiveType } from "@prisma/client"; + +export async function getActiveAccounts() { + return db.telegramAccount.findMany({ + where: { isActive: true, authState: "AUTHENTICATED" }, + }); +} + +export async function getSourceChannelMappings(accountId: string) { + return db.accountChannelMap.findMany({ + where: { + accountId, + role: "READER", + channel: { type: "SOURCE", isActive: true }, + }, + include: { channel: true }, + }); +} + +export async function getDestinationChannel(accountId: string) { + const mapping = await db.accountChannelMap.findFirst({ + where: { + accountId, + role: "WRITER", + channel: { type: "DESTINATION", isActive: true }, + }, + include: { channel: true }, + }); + return mapping?.channel ?? null; +} + +export async function packageExistsByHash(contentHash: string) { + const pkg = await db.package.findUnique({ + where: { contentHash }, + select: { id: true }, + }); + return pkg !== null; +} + +export interface CreatePackageInput { + contentHash: string; + fileName: string; + fileSize: bigint; + archiveType: ArchiveType; + sourceChannelId: string; + sourceMessageId: bigint; + sourceTopicId?: bigint | null; + destChannelId?: string; + destMessageId?: bigint; + isMultipart: boolean; + partCount: number; + ingestionRunId: string; + creator?: string | null; + previewData?: Buffer | null; + previewMsgId?: bigint | null; + files: { + path: string; + fileName: string; + extension: string | null; + compressedSize: bigint; + uncompressedSize: bigint; + crc32: string | null; + }[]; +} + +export async function createPackageWithFiles(input: CreatePackageInput) { + return db.package.create({ + data: { + contentHash: input.contentHash, + fileName: input.fileName, + fileSize: input.fileSize, + archiveType: input.archiveType, + sourceChannelId: input.sourceChannelId, + sourceMessageId: input.sourceMessageId, + sourceTopicId: input.sourceTopicId ?? undefined, + destChannelId: input.destChannelId, + destMessageId: input.destMessageId, + isMultipart: input.isMultipart, + partCount: input.partCount, + fileCount: input.files.length, + ingestionRunId: input.ingestionRunId, + creator: input.creator ?? undefined, + previewData: input.previewData ? new Uint8Array(input.previewData) : undefined, + previewMsgId: input.previewMsgId ?? undefined, + files: { + create: input.files, + }, + }, + }); +} + +export async function createIngestionRun(accountId: string) { + return db.ingestionRun.create({ + data: { + accountId, + status: "RUNNING", + currentActivity: "Starting ingestion run", + currentStep: "initializing", + lastActivityAt: new Date(), + }, + }); +} + +export interface ActivityUpdate { + currentActivity: string; + currentStep: string; + currentChannel?: string | null; + currentFile?: string | null; + currentFileNum?: number | null; + totalFiles?: number | null; + downloadedBytes?: bigint | null; + totalBytes?: bigint | null; + downloadPercent?: number | null; + messagesScanned?: number; + zipsFound?: number; + zipsDuplicate?: number; + zipsIngested?: number; +} + +export async function updateRunActivity( + runId: string, + activity: ActivityUpdate +) { + return db.ingestionRun.update({ + where: { id: runId }, + data: { + currentActivity: activity.currentActivity, + currentStep: activity.currentStep, + currentChannel: activity.currentChannel ?? undefined, + currentFile: activity.currentFile ?? undefined, + currentFileNum: activity.currentFileNum ?? undefined, + totalFiles: activity.totalFiles ?? undefined, + downloadedBytes: activity.downloadedBytes ?? undefined, + totalBytes: activity.totalBytes ?? undefined, + downloadPercent: activity.downloadPercent ?? undefined, + lastActivityAt: new Date(), + ...(activity.messagesScanned !== undefined && { messagesScanned: activity.messagesScanned }), + ...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }), + ...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }), + ...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }), + }, + }); +} + +const CLEAR_ACTIVITY = { + currentActivity: null, + currentStep: null, + currentChannel: null, + currentFile: null, + currentFileNum: null, + totalFiles: null, + downloadedBytes: null, + totalBytes: null, + downloadPercent: null, + lastActivityAt: new Date(), +}; + +export async function completeIngestionRun( + runId: string, + counters: { + messagesScanned: number; + zipsFound: number; + zipsDuplicate: number; + zipsIngested: number; + } +) { + return db.ingestionRun.update({ + where: { id: runId }, + data: { + status: "COMPLETED", + finishedAt: new Date(), + ...counters, + ...CLEAR_ACTIVITY, + }, + }); +} + +export async function failIngestionRun(runId: string, errorMessage: string) { + return db.ingestionRun.update({ + where: { id: runId }, + data: { + status: "FAILED", + finishedAt: new Date(), + errorMessage, + ...CLEAR_ACTIVITY, + }, + }); +} + +export async function updateLastProcessedMessage( + mappingId: string, + messageId: bigint +) { + return db.accountChannelMap.update({ + where: { id: mappingId }, + data: { lastProcessedMessageId: messageId }, + }); +} + +export async function markStaleRunsAsFailed() { + return db.ingestionRun.updateMany({ + where: { status: "RUNNING" }, + data: { + status: "FAILED", + finishedAt: new Date(), + errorMessage: "Worker restarted — run was still marked as RUNNING", + }, + }); +} + +export async function updateAccountAuthState( + accountId: string, + authState: "PENDING" | "AWAITING_CODE" | "AWAITING_PASSWORD" | "AUTHENTICATED" | "EXPIRED", + authCode?: string | null +) { + return db.telegramAccount.update({ + where: { id: accountId }, + data: { authState, authCode, lastSeenAt: authState === "AUTHENTICATED" ? new Date() : undefined }, + }); +} + +export async function getAccountAuthCode(accountId: string) { + const account = await db.telegramAccount.findUnique({ + where: { id: accountId }, + select: { authCode: true, authState: true }, + }); + return account; +} + +// ── Forum / Topic progress ── + +export async function setChannelForum(channelId: string, isForum: boolean) { + return db.telegramChannel.update({ + where: { id: channelId }, + data: { isForum }, + }); +} + +export async function getTopicProgress(mappingId: string) { + return db.topicProgress.findMany({ + where: { accountChannelMapId: mappingId }, + }); +} + +export async function upsertTopicProgress( + mappingId: string, + topicId: bigint, + topicName: string | null, + lastProcessedMessageId: bigint +) { + return db.topicProgress.upsert({ + where: { + accountChannelMapId_topicId: { + accountChannelMapId: mappingId, + topicId, + }, + }, + create: { + accountChannelMapId: mappingId, + topicId, + topicName, + lastProcessedMessageId, + }, + update: { + topicName, + lastProcessedMessageId, + }, + }); +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..a93eeca --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,50 @@ +import { mkdir } from "fs/promises"; +import { config } from "./util/config.js"; +import { logger } from "./util/logger.js"; +import { markStaleRunsAsFailed } from "./db/queries.js"; +import { cleanupTempDir } from "./worker.js"; +import { startScheduler, stopScheduler } from "./scheduler.js"; +import { db, pool } from "./db/client.js"; + +const log = logger.child({ module: "main" }); + +async function main(): Promise { + log.info("DragonsStash Telegram Worker starting"); + log.info({ config: { ...config, databaseUrl: "***" } }, "Configuration loaded"); + + // Ensure temp directory exists + await mkdir(config.tempDir, { recursive: true }); + await mkdir(config.tdlibStateDir, { recursive: true }); + + // Clean up stale state + await cleanupTempDir(); + await markStaleRunsAsFailed(); + + // Start the scheduler + await startScheduler(); +} + +// Graceful shutdown +function shutdown(signal: string): void { + log.info({ signal }, "Shutdown signal received"); + stopScheduler(); + + // Close DB connections + Promise.all([db.$disconnect(), pool.end()]) + .then(() => { + log.info("Shutdown complete"); + process.exit(0); + }) + .catch((err) => { + log.error({ err }, "Error during shutdown"); + process.exit(1); + }); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +main().catch((err) => { + log.fatal({ err }, "Worker failed to start"); + process.exit(1); +}); diff --git a/worker/src/preview/match.ts b/worker/src/preview/match.ts new file mode 100644 index 0000000..933edcf --- /dev/null +++ b/worker/src/preview/match.ts @@ -0,0 +1,86 @@ +import { childLogger } from "../util/logger.js"; + +const log = childLogger("preview-match"); + +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; +} + +export interface ArchiveRef { + baseName: string; + firstMessageId: bigint; + firstMessageDate: Date; +} + +/** + * Try to match a photo message to an archive by: + * 1. Caption contains the archive baseName (without extension) + * 2. Photo was posted within ±10 messages (time-window: ±6 hours) + * + * Returns the best match (closest in time), or null. + */ +export function matchPreviewToArchive( + photos: TelegramPhoto[], + archives: ArchiveRef[] +): Map { + const results = new Map(); + const TIME_WINDOW_MS = 6 * 60 * 60 * 1000; // 6 hours + + for (const archive of archives) { + // Normalize the archive base name for matching + const normalizedBase = normalizeForMatch(archive.baseName); + if (!normalizedBase) continue; + + let bestMatch: TelegramPhoto | null = null; + let bestTimeDiff = Infinity; + + for (const photo of photos) { + const timeDiff = Math.abs( + photo.date.getTime() - archive.firstMessageDate.getTime() + ); + + // Must be within time window + if (timeDiff > TIME_WINDOW_MS) continue; + + // Check if the photo caption contains the archive base name + const normalizedCaption = normalizeForMatch(photo.caption); + if (!normalizedCaption) continue; + + const matches = + normalizedCaption.includes(normalizedBase) || + normalizedBase.includes(normalizedCaption); + + if (matches && timeDiff < bestTimeDiff) { + bestMatch = photo; + bestTimeDiff = timeDiff; + } + } + + if (bestMatch) { + log.debug( + { baseName: archive.baseName, photoId: bestMatch.id.toString() }, + "Matched preview photo to archive" + ); + results.set(archive.baseName, bestMatch); + } + } + + return results; +} + +/** + * Strip extension, punctuation, and normalize for fuzzy matching. + */ +function normalizeForMatch(input: string): string { + return input + .toLowerCase() + .replace(/\.[a-z0-9]{1,5}$/i, "") // strip extension + .replace(/[_\-.\s]+/g, " ") // normalize separators + .trim(); +} diff --git a/worker/src/scheduler.ts b/worker/src/scheduler.ts new file mode 100644 index 0000000..afac97e --- /dev/null +++ b/worker/src/scheduler.ts @@ -0,0 +1,92 @@ +import { config } from "./util/config.js"; +import { childLogger } from "./util/logger.js"; +import { getActiveAccounts } from "./db/queries.js"; +import { runWorkerForAccount } from "./worker.js"; + +const log = childLogger("scheduler"); + +let running = false; +let timer: ReturnType | null = null; + +/** + * Run one ingestion cycle: process all active, authenticated accounts sequentially. + */ +async function runCycle(): Promise { + if (running) { + log.warn("Previous cycle still running, skipping"); + return; + } + + running = true; + log.info("Starting ingestion cycle"); + + try { + const accounts = await getActiveAccounts(); + + if (accounts.length === 0) { + log.info("No active authenticated accounts, nothing to do"); + return; + } + + log.info({ accountCount: accounts.length }, "Processing accounts"); + + for (const account of accounts) { + await runWorkerForAccount(account); + } + + log.info("Ingestion cycle complete"); + } catch (err) { + log.error({ err }, "Ingestion cycle failed"); + } finally { + running = false; + } +} + +/** + * Schedule the next cycle with jitter. + */ +function scheduleNext(): void { + const intervalMs = config.workerIntervalMinutes * 60 * 1000; + const jitterMs = Math.random() * config.jitterMinutes * 60 * 1000; + const delay = intervalMs + jitterMs; + + log.info( + { nextRunInMinutes: Math.round(delay / 60000) }, + "Next cycle scheduled" + ); + + timer = setTimeout(async () => { + await runCycle(); + scheduleNext(); + }, delay); +} + +/** + * Start the scheduler. Runs an immediate first cycle, then schedules subsequent ones. + */ +export async function startScheduler(): Promise { + log.info( + { + intervalMinutes: config.workerIntervalMinutes, + jitterMinutes: config.jitterMinutes, + }, + "Scheduler starting" + ); + + // Run immediately on start + await runCycle(); + + // Then schedule recurring cycles + scheduleNext(); +} + +/** + * Stop the scheduler gracefully. + */ +export function stopScheduler(): void { + if (timer) { + clearTimeout(timer); + timer = null; + } + log.info("Scheduler stopped"); +} diff --git a/worker/src/tdlib/client.ts b/worker/src/tdlib/client.ts new file mode 100644 index 0000000..41493bf --- /dev/null +++ b/worker/src/tdlib/client.ts @@ -0,0 +1,120 @@ +import tdl, { createClient, type Client } from "tdl"; +import { getTdjson } from "prebuilt-tdlib"; +import path from "path"; +import { config } from "../util/config.js"; +import { childLogger } from "../util/logger.js"; +import { + updateAccountAuthState, + getAccountAuthCode, +} from "../db/queries.js"; + +const log = childLogger("tdlib-client"); + +// Configure tdl to use the prebuilt tdjson shared library +tdl.configure({ tdjson: getTdjson() }); + +interface AccountConfig { + id: string; + phone: string; +} + +/** + * Create and authenticate a TDLib client for a Telegram account. + * Authentication flow communicates with the admin UI via the database: + * - Worker sets authState to AWAITING_CODE when TDLib asks for phone code + * - Admin enters the code via UI, which writes it to authCode field + * - Worker polls DB for the code and feeds it to TDLib + */ +export async function createTdlibClient( + account: AccountConfig +): Promise { + const dbPath = path.join(config.tdlibStateDir, account.id); + + const client = createClient({ + apiId: config.telegramApiId, + apiHash: config.telegramApiHash, + databaseDirectory: dbPath, + filesDirectory: path.join(dbPath, "files"), + }); + + client.on("error", (err) => { + log.error({ err, accountId: account.id }, "TDLib client error"); + }); + + try { + await client.login(() => ({ + getPhoneNumber: async () => { + log.info({ accountId: account.id }, "TDLib requesting phone number"); + return account.phone; + }, + getAuthCode: async () => { + log.info({ accountId: account.id }, "TDLib requesting auth code"); + await updateAccountAuthState(account.id, "AWAITING_CODE"); + + // Poll database for the code entered via admin UI + const code = await pollForAuthCode(account.id); + if (!code) { + throw new Error("Auth code not provided within timeout"); + } + + // Clear the code after reading + await updateAccountAuthState(account.id, "AUTHENTICATED", null); + return code; + }, + getPassword: async () => { + log.info({ accountId: account.id }, "TDLib requesting 2FA password"); + await updateAccountAuthState(account.id, "AWAITING_PASSWORD"); + + // Poll database for the password entered via admin UI + const code = await pollForAuthCode(account.id); + if (!code) { + throw new Error("2FA password not provided within timeout"); + } + + await updateAccountAuthState(account.id, "AUTHENTICATED", null); + return code; + }, + })); + + await updateAccountAuthState(account.id, "AUTHENTICATED"); + log.info({ accountId: account.id }, "TDLib client authenticated"); + return client; + } catch (err) { + log.error({ err, accountId: account.id }, "TDLib authentication failed"); + await updateAccountAuthState(account.id, "EXPIRED"); + throw err; + } +} + +/** + * Poll the database every 5 seconds for an auth code, up to 5 minutes. + */ +async function pollForAuthCode( + accountId: string, + timeoutMs = 300_000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const result = await getAccountAuthCode(accountId); + if (result?.authCode) { + return result.authCode; + } + await sleep(5000); + } + return null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Close a TDLib client gracefully. + */ +export async function closeTdlibClient(client: Client): Promise { + try { + await client.close(); + } catch (err) { + log.warn({ err }, "Error closing TDLib client"); + } +} diff --git a/worker/src/tdlib/download.ts b/worker/src/tdlib/download.ts new file mode 100644 index 0000000..da0df5f --- /dev/null +++ b/worker/src/tdlib/download.ts @@ -0,0 +1,389 @@ +import type { Client } from "tdl"; +import { readFile, rename, stat } from "fs/promises"; +import { config } from "../util/config.js"; +import { childLogger } from "../util/logger.js"; +import { isArchiveAttachment } from "../archive/detect.js"; +import type { TelegramMessage } from "../archive/multipart.js"; +import type { TelegramPhoto } from "../preview/match.js"; + +const log = childLogger("download"); + +interface TdPhotoSize { + type: string; + photo: { + id: number; + size: number; + expected_size: number; + local?: { + path?: string; + is_downloading_active?: boolean; + is_downloading_completed?: boolean; + downloaded_size?: number; + }; + }; + width: number; + height: number; +} + +interface TdMessage { + id: number; + date: number; + content: { + _: string; + document?: { + file_name?: string; + document?: { + id: number; + size: number; + local?: { + path?: string; + is_downloading_completed?: boolean; + }; + }; + }; + photo?: { + sizes?: TdPhotoSize[]; + }; + caption?: { + text?: string; + }; + }; +} + +interface TdFile { + id: number; + size: number; + expected_size: number; + local: { + path: string; + is_downloading_active: boolean; + is_downloading_completed: boolean; + downloaded_size: number; + download_offset: number; + }; +} + +export interface ChannelScanResult { + archives: TelegramMessage[]; + photos: TelegramPhoto[]; +} + +/** + * Fetch messages from a channel since a given message ID. + * Collects both archive attachments AND photo messages (for preview matching). + * Returns messages in chronological order (oldest first). + */ +export async function getChannelMessages( + client: Client, + chatId: bigint, + fromMessageId?: bigint | null, + limit = 100 +): Promise { + const archives: TelegramMessage[] = []; + const photos: TelegramPhoto[] = []; + let currentFromId = fromMessageId ? Number(fromMessageId) : 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = (await client.invoke({ + _: "getChatHistory", + chat_id: Number(chatId), + from_message_id: currentFromId, + offset: 0, + limit: Math.min(limit, 100), + only_local: false, + })) as { messages: TdMessage[] }; + + if (!result.messages || result.messages.length === 0) break; + + for (const msg of result.messages) { + // Check for archive documents + const doc = msg.content?.document; + if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) { + 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), + }); + continue; + } + + // Check for photo messages (potential previews) + const photo = msg.content?.photo; + const caption = msg.content?.caption?.text ?? ""; + if (photo?.sizes && photo.sizes.length > 0) { + // Pick the smallest size for thumbnail (type "s" or "m") + // TDLib photo sizes are ordered from smallest to largest + const smallest = photo.sizes[0]; + 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, + }); + } + } + + currentFromId = result.messages[result.messages.length - 1].id; + if (result.messages.length < 100) break; + + // Rate limit delay + await sleep(config.apiDelayMs); + } + + // Return in chronological order (oldest first) + return { + archives: archives.reverse(), + photos: photos.reverse(), + }; +} + +/** + * Download a photo thumbnail from Telegram and return its raw bytes. + * Uses synchronous download (photos are small, typically < 100KB). + * Returns null if download fails (non-critical). + */ +export async function downloadPhotoThumbnail( + client: Client, + fileId: string +): Promise { + const numericId = parseInt(fileId, 10); + + try { + const result = (await client.invoke({ + _: "downloadFile", + file_id: numericId, + priority: 1, // Low priority — thumbnails are nice-to-have + offset: 0, + limit: 0, + synchronous: true, // Small file — wait for it + })) as TdFile; + + if (result?.local?.is_downloading_completed && result.local.path) { + const data = await readFile(result.local.path); + log.debug( + { fileId, bytes: data.length }, + "Downloaded photo thumbnail" + ); + return data; + } + } catch (err) { + log.warn({ fileId, err }, "Failed to download photo thumbnail"); + } + + return null; +} + +export interface DownloadProgress { + fileId: string; + fileName: string; + downloadedBytes: number; + totalBytes: number; + percent: number; + isComplete: boolean; +} + +export type ProgressCallback = (progress: DownloadProgress) => void; + +/** + * Download a file from Telegram to a local path with progress tracking + * and integrity verification. + * + * Progress flow: + * 1. Starts async download via TDLib + * 2. Listens for `updateFile` events to track download progress + * 3. Logs progress at every 10% increment + * 4. Once complete, verifies the local file size matches the expected size + * 5. Moves the file from TDLib's cache to the destination path + * + * Verification: + * - Compares actual file size on disk to the expected size from Telegram + * - Throws on mismatch (partial/corrupt download) + * - Throws on timeout (configurable, scales with file size) + * - Throws if download stops without completing (network error, etc.) + */ +export async function downloadFile( + client: Client, + fileId: string, + destPath: string, + expectedSize: bigint, + fileName: string, + onProgress?: ProgressCallback +): Promise { + const numericId = parseInt(fileId, 10); + const totalBytes = Number(expectedSize); + + log.info( + { fileId, fileName, destPath, totalBytes }, + "Starting file download" + ); + + // Report initial progress + onProgress?.({ + fileId, + fileName, + downloadedBytes: 0, + totalBytes, + percent: 0, + isComplete: false, + }); + + return new Promise((resolve, reject) => { + let lastLoggedPercent = 0; + let settled = false; + + // Timeout: 10 minutes per GB, minimum 5 minutes + const timeoutMs = Math.max( + 5 * 60_000, + (totalBytes / (1024 * 1024 * 1024)) * 10 * 60_000 + ); + const timer = setTimeout(() => { + if (!settled) { + settled = true; + cleanup(); + reject( + new Error( + `Download timed out after ${Math.round(timeoutMs / 60_000)}min for ${fileName}` + ) + ); + } + }, timeoutMs); + + // Listen for file update events to track progress + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleUpdate = (update: any) => { + if (update?._ !== "updateFile") return; + const file = update.file as TdFile | undefined; + if (!file || file.id !== numericId) return; + + const downloaded = file.local.downloaded_size; + const percent = + totalBytes > 0 ? Math.round((downloaded / totalBytes) * 100) : 0; + + // Log at every 10% increment + if (percent >= lastLoggedPercent + 10) { + lastLoggedPercent = percent - (percent % 10); + log.info( + { fileId, fileName, downloaded, totalBytes, percent: `${percent}%` }, + "Download progress" + ); + } + + // Report to callback + onProgress?.({ + fileId, + fileName, + downloadedBytes: downloaded, + totalBytes, + percent, + isComplete: file.local.is_downloading_completed, + }); + + // Download finished + if (file.local.is_downloading_completed) { + if (!settled) { + settled = true; + cleanup(); + verifyAndMove(file.local.path, destPath, totalBytes, fileName, fileId) + .then(resolve) + .catch(reject); + } + } + + // Download stopped without completing (network error, cancelled, etc.) + if ( + !file.local.is_downloading_active && + !file.local.is_downloading_completed + ) { + if (!settled) { + settled = true; + cleanup(); + reject( + new Error( + `Download stopped unexpectedly for ${fileName} ` + + `(${downloaded}/${totalBytes} bytes, ${percent}%)` + ) + ); + } + } + }; + + const cleanup = () => { + clearTimeout(timer); + client.off("update", handleUpdate); + }; + + // Subscribe to updates BEFORE starting download + client.on("update", handleUpdate); + + // Start async download (non-blocking — progress via updateFile events) + client + .invoke({ + _: "downloadFile", + file_id: numericId, + priority: 32, + offset: 0, + limit: 0, + synchronous: false, + }) + .then((result: unknown) => { + // If the file was already cached locally, invoke returns immediately + const file = result as TdFile | undefined; + if (file?.local?.is_downloading_completed && !settled) { + settled = true; + cleanup(); + verifyAndMove(file.local.path, destPath, totalBytes, fileName, fileId) + .then(resolve) + .catch(reject); + } + }) + .catch((err: unknown) => { + if (!settled) { + settled = true; + cleanup(); + reject(err); + } + }); + }); +} + +/** + * Verify the downloaded file's size matches the expected size, + * then move it to the destination path. + */ +async function verifyAndMove( + localPath: string, + destPath: string, + expectedBytes: number, + fileName: string, + fileId: string +): Promise { + const stats = await stat(localPath); + const actualBytes = stats.size; + + if (expectedBytes > 0 && actualBytes !== expectedBytes) { + log.error( + { fileId, fileName, expectedBytes, actualBytes }, + "Download size mismatch — file is incomplete or corrupted" + ); + throw new Error( + `Download verification failed for ${fileName}: ` + + `expected ${expectedBytes} bytes, got ${actualBytes} bytes` + ); + } + + log.info( + { fileId, fileName, bytes: actualBytes, destPath }, + "File verified and complete" + ); + + // Move from TDLib's cache to our temp directory + await rename(localPath, destPath); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/worker/src/tdlib/topics.ts b/worker/src/tdlib/topics.ts new file mode 100644 index 0000000..6eff4e1 --- /dev/null +++ b/worker/src/tdlib/topics.ts @@ -0,0 +1,222 @@ +import type { Client } from "tdl"; +import { config } from "../util/config.js"; +import { childLogger } from "../util/logger.js"; +import { isArchiveAttachment } from "../archive/detect.js"; +import type { TelegramMessage } from "../archive/multipart.js"; +import type { TelegramPhoto } from "../preview/match.js"; +import type { ChannelScanResult } from "./download.js"; + +const log = childLogger("topics"); + +export interface ForumTopic { + topicId: bigint; + name: string; +} + +/** + * Check if a chat is a forum supergroup (topics enabled). + */ +export async function isChatForum( + client: Client, + chatId: bigint +): Promise { + try { + const chat = (await client.invoke({ + _: "getChat", + chat_id: Number(chatId), + })) as { + type?: { + _: string; + supergroup_id?: number; + is_forum?: boolean; + }; + }; + + if (chat.type?._ === "chatTypeSupergroup" && chat.type.is_forum) { + return true; + } + + // Also check via getSupergroup for older TDLib versions + if (chat.type?._ === "chatTypeSupergroup" && chat.type.supergroup_id) { + const sg = (await client.invoke({ + _: "getSupergroup", + supergroup_id: chat.type.supergroup_id, + })) as { is_forum?: boolean }; + return sg.is_forum === true; + } + + return false; + } catch (err) { + log.warn({ err, chatId: chatId.toString() }, "Failed to check if chat is forum"); + return false; + } +} + +/** + * Get all forum topics in a supergroup. + */ +export async function getForumTopicList( + client: Client, + chatId: bigint +): Promise { + const topics: ForumTopic[] = []; + let offsetDate = 0; + let offsetMessageId = 0; + let offsetMessageThreadId = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = (await client.invoke({ + _: "getForumTopics", + chat_id: Number(chatId), + query: "", + offset_date: offsetDate, + offset_message_id: offsetMessageId, + offset_message_thread_id: offsetMessageThreadId, + limit: 100, + })) as { + topics?: { + info?: { + message_thread_id?: number; + name?: string; + is_general?: boolean; + }; + }[]; + next_offset_date?: number; + next_offset_message_id?: number; + next_offset_message_thread_id?: number; + }; + + if (!result.topics || result.topics.length === 0) break; + + for (const t of result.topics) { + if (!t.info?.message_thread_id) continue; + // Skip the "General" topic — it's not creator-specific + if (t.info.is_general) continue; + + topics.push({ + topicId: BigInt(t.info.message_thread_id), + name: t.info.name ?? "Unnamed", + }); + } + + // Check if there are more pages + if ( + !result.next_offset_date && + !result.next_offset_message_id && + !result.next_offset_message_thread_id + ) { + break; + } + + offsetDate = result.next_offset_date ?? 0; + offsetMessageId = result.next_offset_message_id ?? 0; + offsetMessageThreadId = result.next_offset_message_thread_id ?? 0; + + await sleep(config.apiDelayMs); + } + + log.info( + { chatId: chatId.toString(), topicCount: topics.length }, + "Enumerated forum topics" + ); + + return topics; +} + +/** + * Fetch messages from a specific forum topic (thread). + * Uses getMessageThreadHistory to scan within a topic. + */ +export async function getTopicMessages( + client: Client, + chatId: bigint, + topicId: bigint, + fromMessageId?: bigint | null, + limit = 100 +): Promise { + const archives: TelegramMessage[] = []; + const photos: TelegramPhoto[] = []; + let currentFromId = fromMessageId ? Number(fromMessageId) : 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = (await client.invoke({ + _: "getMessageThreadHistory", + chat_id: Number(chatId), + message_id: Number(topicId), + from_message_id: currentFromId, + offset: 0, + limit: Math.min(limit, 100), + })) as { + messages?: { + id: number; + date: number; + content: { + _: string; + document?: { + file_name?: string; + document?: { + id: number; + size: number; + }; + }; + photo?: { + sizes?: { + type: string; + photo: { id: number; size: number; expected_size: number }; + width: number; + height: number; + }[]; + }; + caption?: { text?: string }; + }; + }[]; + }; + + if (!result.messages || result.messages.length === 0) break; + + for (const msg of result.messages) { + // Check for archive documents + const doc = msg.content?.document; + if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) { + 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), + }); + continue; + } + + // Check for photo messages (potential previews) + const photo = msg.content?.photo; + const caption = msg.content?.caption?.text ?? ""; + if (photo?.sizes && photo.sizes.length > 0) { + const smallest = photo.sizes[0]; + 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, + }); + } + } + + currentFromId = result.messages[result.messages.length - 1].id; + if (result.messages.length < 100) break; + + await sleep(config.apiDelayMs); + } + + return { + archives: archives.reverse(), + photos: photos.reverse(), + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/worker/src/upload/channel.ts b/worker/src/upload/channel.ts new file mode 100644 index 0000000..c06057e --- /dev/null +++ b/worker/src/upload/channel.ts @@ -0,0 +1,76 @@ +import type { Client } from "tdl"; +import { config } from "../util/config.js"; +import { childLogger } from "../util/logger.js"; + +const log = childLogger("upload"); + +export interface UploadResult { + messageId: bigint; +} + +/** + * Upload one or more files to a destination Telegram channel. + * For multipart archives, each file is sent as a separate message. + * Returns the message ID of the first uploaded message. + */ +export async function uploadToChannel( + client: Client, + chatId: bigint, + filePaths: string[], + caption?: string +): Promise { + let firstMessageId: bigint | null = null; + + for (let i = 0; i < filePaths.length; i++) { + const filePath = filePaths[i]; + const fileCaption = + i === 0 && caption ? caption : undefined; + + log.debug( + { chatId: Number(chatId), filePath, part: i + 1, total: filePaths.length }, + "Uploading file to channel" + ); + + const result = (await client.invoke({ + _: "sendMessage", + chat_id: Number(chatId), + input_message_content: { + _: "inputMessageDocument", + document: { + _: "inputFileLocal", + path: filePath, + }, + caption: fileCaption + ? { + _: "formattedText", + text: fileCaption, + } + : undefined, + }, + })) as { id: number }; + + if (i === 0) { + firstMessageId = BigInt(result.id); + } + + // Rate limit delay between uploads + if (i < filePaths.length - 1) { + await sleep(config.apiDelayMs); + } + } + + if (firstMessageId === null) { + throw new Error("Upload failed: no messages sent"); + } + + log.info( + { chatId: Number(chatId), messageId: Number(firstMessageId), files: filePaths.length }, + "Upload complete" + ); + + return { messageId: firstMessageId }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/worker/src/util/config.ts b/worker/src/util/config.ts new file mode 100644 index 0000000..57bc09c --- /dev/null +++ b/worker/src/util/config.ts @@ -0,0 +1,18 @@ +export const config = { + databaseUrl: process.env.DATABASE_URL ?? "", + workerIntervalMinutes: parseInt(process.env.WORKER_INTERVAL_MINUTES ?? "60", 10), + tempDir: process.env.WORKER_TEMP_DIR ?? "/tmp/zips", + tdlibStateDir: process.env.TDLIB_STATE_DIR ?? "/data/tdlib", + maxZipSizeMB: parseInt(process.env.WORKER_MAX_ZIP_SIZE_MB ?? "4096", 10), + logLevel: (process.env.LOG_LEVEL ?? "info") as "debug" | "info" | "warn" | "error", + telegramApiId: parseInt(process.env.TELEGRAM_API_ID ?? "0", 10), + telegramApiHash: process.env.TELEGRAM_API_HASH ?? "", + /** Maximum jitter added to scheduler interval (in minutes) */ + jitterMinutes: 5, + /** Maximum time between multipart archive parts (in hours) */ + multipartTimeoutHours: 24, + /** Delay between Telegram API calls (in ms) to avoid rate limits */ + apiDelayMs: 1000, + /** Max retries for rate-limited requests */ + maxRetries: 5, +} as const; diff --git a/worker/src/util/logger.ts b/worker/src/util/logger.ts new file mode 100644 index 0000000..c63b9fc --- /dev/null +++ b/worker/src/util/logger.ts @@ -0,0 +1,14 @@ +import pino from "pino"; +import { config } from "./config.js"; + +export const logger = pino({ + level: config.logLevel, + transport: + config.logLevel === "debug" + ? { target: "pino/file", options: { destination: 1 } } + : undefined, +}); + +export function childLogger(name: string, extra?: Record) { + return logger.child({ module: name, ...extra }); +} diff --git a/worker/src/worker.ts b/worker/src/worker.ts new file mode 100644 index 0000000..aaf9be0 --- /dev/null +++ b/worker/src/worker.ts @@ -0,0 +1,665 @@ +import path from "path"; +import { unlink, readdir } from "fs/promises"; +import { config } from "./util/config.js"; +import { childLogger } from "./util/logger.js"; +import { tryAcquireLock, releaseLock } from "./db/locks.js"; +import { + getSourceChannelMappings, + getDestinationChannel, + packageExistsByHash, + createPackageWithFiles, + createIngestionRun, + completeIngestionRun, + failIngestionRun, + updateLastProcessedMessage, + updateRunActivity, + setChannelForum, + getTopicProgress, + upsertTopicProgress, +} from "./db/queries.js"; +import type { ActivityUpdate } from "./db/queries.js"; +import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js"; +import { getChannelMessages, downloadFile, downloadPhotoThumbnail } from "./tdlib/download.js"; +import type { DownloadProgress, ChannelScanResult } from "./tdlib/download.js"; +import { isChatForum, getForumTopicList, getTopicMessages } from "./tdlib/topics.js"; +import { matchPreviewToArchive } from "./preview/match.js"; +import { groupArchiveSets } from "./archive/multipart.js"; +import type { ArchiveSet } from "./archive/multipart.js"; +import { extractCreatorFromFileName } from "./archive/creator.js"; +import { hashParts } from "./archive/hash.js"; +import { readZipCentralDirectory } from "./archive/zip-reader.js"; +import { readRarContents } from "./archive/rar-reader.js"; +import { byteLevelSplit } from "./archive/split.js"; +import { uploadToChannel } from "./upload/channel.js"; +import type { TelegramAccount, TelegramChannel } from "@prisma/client"; +import type { Client } from "tdl"; + +const log = childLogger("worker"); + +/** + * Throttle DB writes for download progress to avoid hammering the DB. + * Only writes if at least 2 seconds have passed since the last write. + */ +function createThrottledActivityUpdater(runId: string, minIntervalMs = 2000) { + let lastWriteTime = 0; + let pendingUpdate: ActivityUpdate | null = null; + let flushTimer: ReturnType | null = null; + + const flush = async () => { + if (pendingUpdate) { + const update = pendingUpdate; + pendingUpdate = null; + lastWriteTime = Date.now(); + await updateRunActivity(runId, update).catch(() => {}); + } + }; + + return { + update: (activity: ActivityUpdate) => { + pendingUpdate = activity; + const elapsed = Date.now() - lastWriteTime; + if (elapsed >= minIntervalMs) { + if (flushTimer) clearTimeout(flushTimer); + flush(); + } else if (!flushTimer) { + flushTimer = setTimeout(() => { + flushTimer = null; + flush(); + }, minIntervalMs - elapsed); + } + }, + flush, + }; +} + +/** Shared context passed to the archive processing pipeline. */ +interface PipelineContext { + client: Client; + runId: string; + channelTitle: string; + channel: TelegramChannel; + destChannelTelegramId: bigint; + destChannelId: string; + throttled: ReturnType; + counters: { + messagesScanned: number; + zipsFound: number; + zipsDuplicate: number; + zipsIngested: number; + }; + /** Creator from forum topic name (null for non-forum). */ + topicCreator: string | null; + /** Forum topic ID (null for non-forum). */ + sourceTopicId: bigint | null; + accountLog: ReturnType; +} + +/** + * Run a full ingestion cycle for a single Telegram account. + * Every step writes live activity to the DB so the admin UI can display it. + */ +export async function runWorkerForAccount( + account: TelegramAccount +): Promise { + const accountLog = childLogger("worker", { accountId: account.id, phone: account.phone }); + + // 1. Acquire advisory lock + const acquired = await tryAcquireLock(account.id); + if (!acquired) { + accountLog.info("Account already locked, skipping"); + return; + } + + let runId: string | undefined; + + try { + // 2. Create ingestion run + const run = await createIngestionRun(account.id); + runId = run.id; + const activeRunId = runId; + accountLog.info({ runId }, "Ingestion run started"); + + const throttled = createThrottledActivityUpdater(activeRunId); + + // 3. Initialize TDLib client + await updateRunActivity(activeRunId, { + currentActivity: "Connecting to Telegram", + currentStep: "connecting", + }); + + const client = await createTdlibClient({ + id: account.id, + phone: account.phone, + }); + + const counters = { + messagesScanned: 0, + zipsFound: 0, + zipsDuplicate: 0, + zipsIngested: 0, + }; + + try { + // 4. Get assigned source channels and destination + const channelMappings = await getSourceChannelMappings(account.id); + const destChannel = await getDestinationChannel(account.id); + + if (!destChannel) { + throw new Error("No active destination channel configured"); + } + + for (const mapping of channelMappings) { + const channel = mapping.channel; + + // ── Check if channel is a forum ── + const forum = await isChatForum(client, channel.telegramId); + if (forum !== channel.isForum) { + await setChannelForum(channel.id, forum); + accountLog.info( + { channelId: channel.id, title: channel.title, isForum: forum }, + "Updated channel forum status" + ); + } + + const pipelineCtx: PipelineContext = { + client, + runId: activeRunId, + channelTitle: channel.title, + channel, + destChannelTelegramId: destChannel.telegramId, + destChannelId: destChannel.id, + throttled, + counters, + topicCreator: null, + sourceTopicId: null, + accountLog, + }; + + if (forum) { + // ── Forum channel: scan per-topic ── + await updateRunActivity(activeRunId, { + currentActivity: `Enumerating topics in "${channel.title}"`, + currentStep: "scanning", + currentChannel: channel.title, + currentFile: null, + currentFileNum: null, + totalFiles: null, + downloadedBytes: null, + totalBytes: null, + downloadPercent: null, + }); + + const topics = await getForumTopicList(client, channel.telegramId); + const topicProgressList = await getTopicProgress(mapping.id); + + accountLog.info( + { channelId: channel.id, title: channel.title, topicCount: topics.length }, + "Scanning forum channel by topic" + ); + + for (const topic of topics) { + const progress = topicProgressList.find( + (tp) => tp.topicId === topic.topicId + ); + + await updateRunActivity(activeRunId, { + currentActivity: `Scanning topic "${topic.name}" in "${channel.title}"`, + currentStep: "scanning", + currentChannel: `${channel.title} › ${topic.name}`, + currentFile: null, + currentFileNum: null, + totalFiles: null, + downloadedBytes: null, + totalBytes: null, + downloadPercent: null, + }); + + const scanResult = await getTopicMessages( + client, + channel.telegramId, + topic.topicId, + progress?.lastProcessedMessageId + ); + + if (scanResult.archives.length === 0) { + accountLog.debug( + { channelId: channel.id, topic: topic.name }, + "No new archives in topic" + ); + continue; + } + + accountLog.info( + { topic: topic.name, archives: scanResult.archives.length, photos: scanResult.photos.length }, + "Found messages in topic" + ); + + // Process archives with topic creator + pipelineCtx.topicCreator = topic.name; + pipelineCtx.sourceTopicId = topic.topicId; + pipelineCtx.channelTitle = `${channel.title} › ${topic.name}`; + + await processArchiveSets(pipelineCtx, scanResult, run.id); + + // Update topic progress + const allMsgIds = [ + ...scanResult.archives.map((m) => m.id), + ...scanResult.photos.map((p) => p.id), + ]; + if (allMsgIds.length > 0) { + const maxId = allMsgIds.reduce((a, b) => (a > b ? a : b)); + await upsertTopicProgress( + mapping.id, + topic.topicId, + topic.name, + maxId + ); + } + } + } else { + // ── Non-forum channel: flat scan (existing behavior) ── + await updateRunActivity(activeRunId, { + currentActivity: `Scanning "${channel.title}" for new archives`, + currentStep: "scanning", + currentChannel: channel.title, + currentFile: null, + currentFileNum: null, + totalFiles: null, + downloadedBytes: null, + totalBytes: null, + downloadPercent: null, + }); + + accountLog.info( + { channelId: channel.id, title: channel.title }, + "Processing source channel" + ); + + const scanResult = await getChannelMessages( + client, + channel.telegramId, + mapping.lastProcessedMessageId + ); + + if (scanResult.archives.length === 0) { + accountLog.debug({ channelId: channel.id }, "No new archives"); + continue; + } + + accountLog.info( + { archives: scanResult.archives.length, photos: scanResult.photos.length }, + "Found messages in channel" + ); + + // For non-forum, creator comes from filename (set to null, resolved per-archive) + pipelineCtx.topicCreator = null; + pipelineCtx.sourceTopicId = null; + pipelineCtx.channelTitle = channel.title; + + await processArchiveSets(pipelineCtx, scanResult, run.id); + + // Update last processed message + const allMsgIds = [ + ...scanResult.archives.map((m) => m.id), + ...scanResult.photos.map((p) => p.id), + ]; + if (allMsgIds.length > 0) { + const maxId = allMsgIds.reduce((a, b) => (a > b ? a : b)); + await updateLastProcessedMessage(mapping.id, maxId); + } + } + } + + // ── Done ── + await completeIngestionRun(activeRunId, counters); + accountLog.info({ counters }, "Ingestion run completed"); + } finally { + await closeTdlibClient(client); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + accountLog.error({ err }, "Ingestion run failed"); + if (runId) { + await failIngestionRun(runId, message).catch((e) => + accountLog.error({ e }, "Failed to mark run as failed") + ); + } + } finally { + await releaseLock(account.id); + } +} + +/** + * Process a scan result through the archive pipeline: + * group → download → hash → dedup → metadata → split → upload → preview → index. + */ +async function processArchiveSets( + ctx: PipelineContext, + scanResult: ChannelScanResult, + ingestionRunId: string +): Promise { + const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx; + + // Group into archive sets + const archiveSets = groupArchiveSets(scanResult.archives); + counters.zipsFound += archiveSets.length; + + // Match preview photos to archive sets + const previewMatches = matchPreviewToArchive( + scanResult.photos, + archiveSets.map((s) => ({ + baseName: s.baseName, + firstMessageId: s.parts[0].id, + firstMessageDate: s.parts[0].date, + })) + ); + + if (previewMatches.size > 0) { + accountLog.info( + { matched: previewMatches.size, total: archiveSets.length }, + "Matched preview photos to archives" + ); + } + + await updateRunActivity(runId, { + currentActivity: `Found ${archiveSets.length} archive(s) in "${channelTitle}"`, + currentStep: "scanning", + currentChannel: channelTitle, + totalFiles: archiveSets.length, + zipsFound: counters.zipsFound, + }); + + for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) { + await processOneArchiveSet( + ctx, + archiveSets[setIdx], + setIdx, + archiveSets.length, + previewMatches, + ingestionRunId + ); + } +} + +/** + * Process a single archive set through the full pipeline. + */ +async function processOneArchiveSet( + ctx: PipelineContext, + archiveSet: ArchiveSet, + setIdx: number, + totalSets: number, + previewMatches: Map, + ingestionRunId: string +): Promise { + const { + client, runId, channelTitle, channel, + destChannelTelegramId, destChannelId, + throttled, counters, topicCreator, sourceTopicId, accountLog, + } = ctx; + + counters.messagesScanned += archiveSet.parts.length; + const archiveName = archiveSet.parts[0].fileName; + const tempPaths: string[] = []; + let splitPaths: string[] = []; + + try { + // ── Downloading ── + for (let partIdx = 0; partIdx < archiveSet.parts.length; partIdx++) { + const part = archiveSet.parts[partIdx]; + const tempPath = path.join( + config.tempDir, + `${ingestionRunId}_${part.id}_${part.fileName}` + ); + + const partLabel = archiveSet.parts.length > 1 + ? ` (part ${partIdx + 1}/${archiveSet.parts.length})` + : ""; + + await updateRunActivity(runId, { + currentActivity: `Downloading ${part.fileName}${partLabel}`, + currentStep: "downloading", + currentChannel: channelTitle, + currentFile: part.fileName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + downloadedBytes: 0n, + totalBytes: part.fileSize, + downloadPercent: 0, + messagesScanned: counters.messagesScanned, + }); + + accountLog.info( + { + fileName: part.fileName, + fileSize: Number(part.fileSize), + part: partIdx + 1, + totalParts: archiveSet.parts.length, + }, + "Downloading archive part" + ); + + await downloadFile( + client, + part.fileId, + tempPath, + part.fileSize, + part.fileName, + (progress: DownloadProgress) => { + throttled.update({ + currentActivity: `Downloading ${part.fileName}${partLabel} — ${progress.percent}%`, + currentStep: "downloading", + currentChannel: channelTitle, + currentFile: part.fileName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + downloadedBytes: BigInt(progress.downloadedBytes), + totalBytes: BigInt(progress.totalBytes), + downloadPercent: progress.percent, + }); + } + ); + await throttled.flush(); + tempPaths.push(tempPath); + } + + // ── Hashing ── + await updateRunActivity(runId, { + currentActivity: `Computing hash for ${archiveName}`, + currentStep: "hashing", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + downloadedBytes: null, + totalBytes: null, + downloadPercent: null, + }); + + const contentHash = await hashParts(tempPaths); + + // ── Deduplicating ── + await updateRunActivity(runId, { + currentActivity: `Checking if ${archiveName} is a duplicate`, + currentStep: "deduplicating", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + }); + + const exists = await packageExistsByHash(contentHash); + if (exists) { + counters.zipsDuplicate++; + accountLog.debug({ contentHash }, "Duplicate archive, skipping"); + + await updateRunActivity(runId, { + currentActivity: `Skipped ${archiveName} (duplicate)`, + currentStep: "deduplicating", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + zipsDuplicate: counters.zipsDuplicate, + }); + return; + } + + // ── Reading metadata ── + await updateRunActivity(runId, { + currentActivity: `Reading file list from ${archiveName}`, + currentStep: "reading_metadata", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + }); + + let entries: { path: string; fileName: string; extension: string | null; compressedSize: bigint; uncompressedSize: bigint; crc32: string | null }[] = []; + try { + if (archiveSet.type === "ZIP") { + entries = await readZipCentralDirectory(tempPaths); + } else { + entries = await readRarContents(tempPaths[0]); + } + } catch (err) { + accountLog.warn({ err, baseName: archiveSet.baseName }, "Failed to read archive metadata, ingesting without file list"); + } + + // ── Splitting (if needed) ── + let uploadPaths = tempPaths; + const totalSize = archiveSet.parts.reduce( + (sum, p) => sum + p.fileSize, + 0n + ); + + if (!archiveSet.isMultipart && totalSize > 2n * 1024n * 1024n * 1024n) { + await updateRunActivity(runId, { + currentActivity: `Splitting ${archiveName} for upload (>2GB)`, + currentStep: "splitting", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + }); + splitPaths = await byteLevelSplit(tempPaths[0]); + uploadPaths = splitPaths; + } + + // ── Uploading ── + const uploadLabel = uploadPaths.length > 1 + ? ` (${uploadPaths.length} parts)` + : ""; + await updateRunActivity(runId, { + currentActivity: `Uploading ${archiveName} to archive channel${uploadLabel}`, + currentStep: "uploading", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + }); + + const destResult = await uploadToChannel( + client, + destChannelTelegramId, + uploadPaths + ); + + // ── Preview thumbnail ── + let previewData: Buffer | null = null; + let previewMsgId: bigint | null = null; + const matchedPhoto = previewMatches.get(archiveSet.baseName); + if (matchedPhoto) { + await updateRunActivity(runId, { + currentActivity: `Downloading preview image for ${archiveName}`, + currentStep: "preview", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + }); + previewData = await downloadPhotoThumbnail(client, matchedPhoto.fileId); + previewMsgId = matchedPhoto.id; + } + + // ── Resolve creator: topic name > filename extraction > null ── + const creator = topicCreator ?? extractCreatorFromFileName(archiveName) ?? null; + + // ── Indexing ── + await updateRunActivity(runId, { + currentActivity: `Saving metadata for ${archiveName} (${entries.length} files)`, + currentStep: "indexing", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + }); + + await createPackageWithFiles({ + contentHash, + fileName: archiveName, + fileSize: totalSize, + archiveType: archiveSet.type, + sourceChannelId: channel.id, + sourceMessageId: archiveSet.parts[0].id, + sourceTopicId, + destChannelId, + destMessageId: destResult.messageId, + isMultipart: + archiveSet.parts.length > 1 || uploadPaths.length > 1, + partCount: uploadPaths.length, + ingestionRunId, + creator, + previewData, + previewMsgId, + files: entries, + }); + + counters.zipsIngested++; + + await updateRunActivity(runId, { + currentActivity: `Ingested ${archiveName} (${entries.length} files indexed)`, + currentStep: "complete", + currentChannel: channelTitle, + currentFile: archiveName, + currentFileNum: setIdx + 1, + totalFiles: totalSets, + zipsIngested: counters.zipsIngested, + }); + + accountLog.info( + { fileName: archiveName, contentHash, fileCount: entries.length, creator }, + "Archive ingested" + ); + } finally { + // ALWAYS delete temp files + await deleteFiles([...tempPaths, ...splitPaths]); + } +} + +async function deleteFiles(paths: string[]): Promise { + for (const p of paths) { + try { + await unlink(p); + } catch { + // File may already be deleted or never created + } + } +} + +/** + * Clean up any leftover temp files from previous runs. + */ +export async function cleanupTempDir(): Promise { + try { + const files = await readdir(config.tempDir); + for (const file of files) { + await unlink(path.join(config.tempDir, file)).catch(() => {}); + } + if (files.length > 0) { + log.info({ count: files.length }, "Cleaned up stale temp files"); + } + } catch { + // Directory might not exist yet + } +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..18139e3 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}