diff --git a/package-lock.json b/package-lock.json index 2aaa6a1..1e0f8ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "ts-node": "^10.9.2", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "5.9.3" } }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index d09d995..76a0a17 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,6 @@ "ts-node": "^10.9.2", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "5.9.3" } } diff --git a/src/app/(app)/telegram/_components/channel-columns.tsx b/src/app/(app)/telegram/_components/channel-columns.tsx index 98bdfca..e5f44b3 100644 --- a/src/app/(app)/telegram/_components/channel-columns.tsx +++ b/src/app/(app)/telegram/_components/channel-columns.tsx @@ -7,6 +7,7 @@ import { Power, ArrowDownToLine, ArrowUpFromLine, + RefreshCcw, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -23,12 +24,14 @@ interface ChannelColumnsProps { onToggleActive: (id: string) => void; onDelete: (id: string) => void; onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void; + onRescan: (id: string) => void; } export function getChannelColumns({ onToggleActive, onDelete, onSetType, + onRescan, }: ChannelColumnsProps): ColumnDef[] { return [ { @@ -121,6 +124,14 @@ export function getChannelColumns({ Set as Source )} + {row.original.type === "SOURCE" && ( + onRescan(row.original.id)} + > + + Rescan Channel + + )} onToggleActive(row.original.id)} > diff --git a/src/app/(app)/telegram/_components/channels-tab.tsx b/src/app/(app)/telegram/_components/channels-tab.tsx index 0c12b23..042216a 100644 --- a/src/app/(app)/telegram/_components/channels-tab.tsx +++ b/src/app/(app)/telegram/_components/channels-tab.tsx @@ -8,6 +8,7 @@ import { deleteChannel, toggleChannelActive, setChannelType, + rescanChannel, } from "../actions"; import { DataTable } from "@/components/shared/data-table"; import { DeleteDialog } from "@/components/shared/delete-dialog"; @@ -22,6 +23,7 @@ interface ChannelsTabProps { export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) { const [isPending, startTransition] = useTransition(); const [deleteId, setDeleteId] = useState(null); + const [rescanId, setRescanId] = useState(null); const columns = getChannelColumns({ onToggleActive: (id) => { @@ -39,6 +41,7 @@ export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) { else toast.error(result.error); }); }, + onRescan: (id) => setRescanId(id), }); const { table } = useDataTable({ @@ -60,6 +63,19 @@ export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) { }); }; + const handleRescan = () => { + if (!rescanId) return; + startTransition(async () => { + const result = await rescanChannel(rescanId); + if (result.success) { + toast.success("Channel scan progress reset — it will be fully rescanned on the next sync"); + setRescanId(null); + } else { + toast.error(result.error); + } + }); + }; + return (
@@ -83,6 +99,16 @@ export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) { onConfirm={handleDelete} isLoading={isPending} /> + + !open && setRescanId(null)} + title="Rescan Channel" + description="This will reset all scan progress for this channel. On the next sync the worker will re-process every message from the beginning. Packages that are already in the library will be skipped (deduplication by hash), but any missing files will be re-downloaded and re-uploaded. This may take a long time for large channels." + confirmLabel="Rescan" + onConfirm={handleRescan} + isLoading={isPending} + />
); } diff --git a/src/app/(app)/telegram/actions.ts b/src/app/(app)/telegram/actions.ts index 67acf2e..4e25d11 100644 --- a/src/app/(app)/telegram/actions.ts +++ b/src/app/(app)/telegram/actions.ts @@ -297,6 +297,52 @@ export async function triggerChannelSync(): Promise { } } +/** + * Reset all scan progress for a channel so the worker will re-process it + * from the very beginning on the next ingestion cycle. + * + * This clears: + * - `lastProcessedMessageId` on every AccountChannelMap linked to this channel + * - All TopicProgress records for those maps (for forum channels) + */ +export async function rescanChannel(channelId: string): Promise { + const admin = await requireAdmin(); + if (!admin.success) return admin; + + const channel = await prisma.telegramChannel.findUnique({ + where: { id: channelId }, + }); + if (!channel) return { success: false, error: "Channel not found" }; + + try { + // Find all account-channel maps for this channel + const maps = await prisma.accountChannelMap.findMany({ + where: { channelId }, + select: { id: true }, + }); + + const mapIds = maps.map((m) => m.id); + + // Delete all topic progress records for these maps (forum channels) + if (mapIds.length > 0) { + await prisma.topicProgress.deleteMany({ + where: { accountChannelMapId: { in: mapIds } }, + }); + } + + // Reset the scan cursor so the worker re-processes from the start + await prisma.accountChannelMap.updateMany({ + where: { channelId }, + data: { lastProcessedMessageId: null }, + }); + + revalidatePath(REVALIDATE_PATH); + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to reset channel scan progress" }; + } +} + // ── Account-Channel link actions ── export async function linkChannel( @@ -377,7 +423,7 @@ export async function triggerIngestion( try { await prisma.$queryRawUnsafe( `SELECT pg_notify('ingestion_trigger', $1)`, - accounts.map((a) => a.id).join(",") + accounts.map((a: { id: string }) => a.id).join(",") ); } catch { // Best-effort diff --git a/src/app/(app)/telegram/page.tsx b/src/app/(app)/telegram/page.tsx index 2100d40..55247c5 100644 --- a/src/app/(app)/telegram/page.tsx +++ b/src/app/(app)/telegram/page.tsx @@ -25,7 +25,7 @@ export default async function TelegramPage() { }), ]); - const serializedHistory = sendHistory.map((r) => ({ + const serializedHistory = sendHistory.map((r: typeof sendHistory[number]) => ({ id: r.id, packageName: r.package.fileName, recipientName: r.telegramLink.telegramName, diff --git a/src/app/api/ingestion/trigger/route.ts b/src/app/api/ingestion/trigger/route.ts index 5586b6b..0cb3449 100644 --- a/src/app/api/ingestion/trigger/route.ts +++ b/src/app/api/ingestion/trigger/route.ts @@ -50,7 +50,7 @@ export async function POST(request: Request) { try { await prisma.$queryRawUnsafe( `SELECT pg_notify('ingestion_trigger', $1)`, - accounts.map((a) => a.id).join(",") + accounts.map((a: { id: string }) => a.id).join(",") ); } catch { // pg_notify is best-effort — worker will pick up on next scheduled cycle anyway @@ -58,7 +58,7 @@ export async function POST(request: Request) { return NextResponse.json({ triggered: true, - accountIds: accounts.map((a) => a.id), + accountIds: accounts.map((a: { id: string }) => a.id), message: `Ingestion triggered for ${accounts.length} account(s)`, }); } diff --git a/src/components/shared/delete-dialog.tsx b/src/components/shared/delete-dialog.tsx index be146f7..4ecee82 100644 --- a/src/components/shared/delete-dialog.tsx +++ b/src/components/shared/delete-dialog.tsx @@ -18,6 +18,8 @@ interface DeleteDialogProps { description?: string; onConfirm: () => void; isLoading?: boolean; + confirmLabel?: string; + confirmLoadingLabel?: string; } export function DeleteDialog({ @@ -27,6 +29,8 @@ export function DeleteDialog({ description = "This action cannot be undone.", onConfirm, isLoading, + confirmLabel = "Delete", + confirmLoadingLabel, }: DeleteDialogProps) { return ( @@ -42,7 +46,7 @@ export function DeleteDialog({ disabled={isLoading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isLoading ? "Deleting..." : "Delete"} + {isLoading ? (confirmLoadingLabel ?? `${confirmLabel}...`) : confirmLabel}