12 Commits

Author SHA1 Message Date
8b500a1610 feat(stls): enlarge candidate images in preview picker
All checks were successful
continuous-integration/drone/push Build is passing
Each loaded candidate gets an enlarge button that opens the ImageLightbox
without changing selection, so the right preview is easier to choose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:57:40 +02:00
f0a9d3b4da feat(stls): click-to-enlarge preview in package files drawer
The drawer preview image opens the ImageLightbox; replacing the image
stays available via the existing Upload/Pick Preview buttons. No-preview
upload affordance unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:48:13 +02:00
b90317c007 feat(stls): click-to-enlarge package preview thumbnail in table
Package preview thumbnails open the ImageLightbox on click (hover shows a
maximize affordance). No-preview cells unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:44:52 +02:00
6f8ddcca81 feat(stls): add reusable ImageLightbox for full-size preview viewing
Radix Dialog-based lightbox (Esc / overlay / close button to dismiss),
image shown object-contain capped to the viewport. No new dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:17:42 +02:00
25ff067ea0 feat(telegram): add Topics action to forum channels in Channels tab
Forum source channels get a Topics menu item that opens the TopicsDrawer.
Non-forum channels are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:15:46 +02:00
7e58cc29fa feat(telegram): add TopicsDrawer for per-topic fetch toggles
Lists a channel's topics with an enable/disable Switch each (optimistic,
revert on failure). Empty state explains topics appear after the next scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:24:57 +02:00
5a4e358eee feat(telegram): add setTopicFetchEnabled server action
Admin-guarded toggle of TopicProgress.fetchEnabled by row id. Persists
immediately; the worker honours it on its next live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:22:45 +02:00
c31afc5b92 feat(telegram): add GET /api/telegram/channels/[channelId]/topics
Returns per-topic fetch state for a channel, mirroring the existing
account-links route auth pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:19:46 +02:00
6324d64870 feat(telegram): expose isForum on ChannelRow and add listChannelTopics
ChannelRow now carries isForum so the UI can show the Topics action for
forum channels only. listChannelTopics returns per-topic fetch state for
a channel from TopicProgress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:16:42 +02:00
974769350b feat(worker): honour per-topic fetch toggle live in scan loop
Eager-persist a TopicProgress row for every discovered topic at run
start, and read the live fetchEnabled flag per topic so a mid-run
disable skips topics not yet started. Disabled topics are skipped
before any TDLib scan or fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:13:10 +02:00
f9b82f1654 feat(worker): add ensureTopicProgressRows + isTopicFetchEnabled helpers
ensureTopicProgressRows inserts missing topic rows only (skipDuplicates),
leaving watermarks/scan-state/fetchEnabled untouched. isTopicFetchEnabled
reads the live per-topic flag for mid-run skip decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:27:24 +02:00
7146a5cf0d feat(db): add fetchEnabled flag to TopicProgress
Additive Postgres migration (default true) that backs the per-topic
fetch enable/disable toggle. Existing rows backfill to enabled, so
current behaviour is unchanged. Migration applied to the live DB via
CI prisma migrate deploy (deferred, per environment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:25:21 +02:00
14 changed files with 468 additions and 77 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable: per-topic user-controlled fetch toggle (forum channels)
-- Additive, safe default (true) so existing rows backfill to "enabled" and
-- current behaviour is unchanged. Disabling is non-destructive.
ALTER TABLE "topic_progress"
ADD COLUMN "fetchEnabled" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -597,6 +597,11 @@ model TopicProgress {
accountChannelMapId String
topicId BigInt
topicName String?
/// User-controlled fetch toggle. When false, the worker skips this topic
/// (no scanning, no fetching/transfer). Defaults true so every existing and
/// newly-discovered topic is fetched unless explicitly disabled. Disabling
/// is non-destructive — already-fetched packages are kept.
fetchEnabled Boolean @default(true)
lastProcessedMessageId BigInt?
/// When this topic was last scanned (any reason). Used by recency-skip.
lastScannedAt DateTime?

View File

@@ -7,6 +7,7 @@ import {
Check,
AlertCircle,
ImageOff,
Maximize2,
} from "lucide-react";
import {
Dialog,
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { setPreviewFromExtract } from "../actions";
import { ImageLightbox } from "./image-lightbox";
interface ArchiveImage {
id: string;
@@ -65,6 +67,7 @@ export function ArchivePreviewPicker({
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
// Track which paths have already been requested to avoid re-requesting
const requestedPaths = useRef<Set<string>>(new Set());
@@ -290,12 +293,25 @@ export function ArchivePreviewPicker({
const isFailed = thumbState?.status === "failed";
return (
<div key={img.id} className="group relative">
{isLoaded && thumbState?.imageUrl && (
<button
type="button"
className="absolute top-1.5 left-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setLightboxSrc(thumbState.imageUrl!);
}}
title="Enlarge"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
)}
<button
key={img.id}
type="button"
className={cn(
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all",
"hover:border-primary/50 cursor-pointer group",
"relative aspect-square w-full rounded-lg overflow-hidden border-2 transition-all",
"hover:border-primary/50 cursor-pointer",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-border",
@@ -355,6 +371,7 @@ export function ArchivePreviewPicker({
</p>
</div>
</button>
</div>
);
})}
</div>
@@ -394,6 +411,13 @@ export function ArchivePreviewPicker({
</div>
)}
</DialogContent>
<ImageLightbox
src={lightboxSrc}
open={!!lightboxSrc}
onOpenChange={(open) => {
if (!open) setLightboxSrc(null);
}}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
interface ImageLightboxProps {
src: string | null;
alt?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Full-size, in-page preview viewer. Renders the image at native size capped
* to the viewport (object-contain). Dismiss via the close button, Esc, or by
* clicking the overlay.
*/
export function ImageLightbox({
src,
alt = "",
open,
onOpenChange,
}: ImageLightboxProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-auto max-w-[95vw] border-0 bg-transparent p-0 shadow-none sm:max-w-[90vw]">
<DialogTitle className="sr-only">Enlarged preview image</DialogTitle>
{src && (
<img
src={src}
alt={alt}
className="mx-auto max-h-[88vh] w-auto max-w-full rounded-lg object-contain"
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,12 +1,14 @@
"use client";
import { useState } from "react";
import { type ColumnDef } from "@tanstack/react-table";
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge } from "lucide-react";
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge, Maximize2 } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { SendToTelegramButton } from "./send-to-telegram-button";
import { ImageLightbox } from "./image-lightbox";
export interface PackageRow {
id: string;
@@ -84,14 +86,30 @@ export function formatBytes(bytesStr: string): string {
}
function PreviewCell({ pkg }: { pkg: PackageRow }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
if (pkg.hasPreview) {
const src = `/api/zips/${pkg.id}/preview`;
return (
<>
<button
type="button"
className="group/preview relative h-9 w-9 overflow-hidden rounded-md bg-muted"
onClick={() => setLightboxOpen(true)}
title="Click to enlarge"
>
<img
src={`/api/zips/${pkg.id}/preview`}
src={src}
alt=""
className="h-9 w-9 rounded-md object-cover bg-muted"
className="h-9 w-9 object-cover"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/50 opacity-0 transition-opacity group-hover/preview:opacity-100">
<Maximize2 className="h-3.5 w-3.5 text-white" />
</div>
</button>
<ImageLightbox src={src} open={lightboxOpen} onOpenChange={setLightboxOpen} />
</>
);
}
return (

View File

@@ -13,6 +13,7 @@ import {
Upload,
ImagePlus,
Images,
Maximize2,
} from "lucide-react";
import {
Dialog,
@@ -30,6 +31,7 @@ import type { PackageRow } from "./package-columns";
import { SendToTelegramButton } from "./send-to-telegram-button";
import { uploadPackagePreview } from "../actions";
import { ArchivePreviewPicker } from "./archive-preview-picker";
import { ImageLightbox } from "./image-lightbox";
interface FileItem {
id: string;
@@ -264,6 +266,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
const [uploading, setUploading] = useState(false);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
const [previewLightboxOpen, setPreviewLightboxOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handlePreviewUpload = useCallback(
@@ -384,9 +387,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
<button
type="button"
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title="Click to replace preview image"
onClick={() => setPreviewLightboxOpen(true)}
title="Click to enlarge"
>
<img
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
@@ -394,11 +396,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploading ? (
<Loader2 className="h-5 w-5 text-white animate-spin" />
) : (
<Upload className="h-5 w-5 text-white" />
)}
<Maximize2 className="h-5 w-5 text-white" />
</div>
</button>
) : (
@@ -582,6 +580,11 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
}}
/>
)}
<ImageLightbox
src={pkg ? (localPreviewUrl ?? `/api/zips/${pkg.id}/preview`) : null}
open={previewLightboxOpen}
onOpenChange={setPreviewLightboxOpen}
/>
</>
);
}

View File

@@ -9,6 +9,7 @@ import {
ArrowUpFromLine,
RefreshCcw,
Tag,
MessagesSquare,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -27,6 +28,7 @@ interface ChannelColumnsProps {
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
onRescan: (id: string) => void;
onSetCategory: (id: string, category: string | null) => void;
onManageTopics: (id: string) => void;
}
export function getChannelColumns({
@@ -35,6 +37,7 @@ export function getChannelColumns({
onSetType,
onRescan,
onSetCategory,
onManageTopics,
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
return [
{
@@ -147,6 +150,14 @@ export function getChannelColumns({
Rescan Channel
</DropdownMenuItem>
)}
{row.original.type === "SOURCE" && row.original.isForum && (
<DropdownMenuItem
onClick={() => onManageTopics(row.original.id)}
>
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
Topics
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");

View File

@@ -7,6 +7,7 @@ import { getChannelColumns } from "./channel-columns";
import { DestinationCard } from "./destination-card";
import { ChannelPickerDialog } from "./channel-picker-dialog";
import { JoinChannelDialog } from "./join-channel-dialog";
import { TopicsDrawer } from "./topics-drawer";
import {
deleteChannel,
toggleChannelActive,
@@ -32,6 +33,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
const [rescanId, setRescanId] = useState<string | null>(null);
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
const [topicsChannelId, setTopicsChannelId] = useState<string | null>(null);
// Find the first authenticated account for "Fetch Channels"
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
@@ -60,6 +62,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
else toast.error(result.error);
});
},
onManageTopics: (id) => setTopicsChannelId(id),
});
const { table } = useDataTable({
@@ -167,6 +170,17 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
open={joinDialogOpen}
onOpenChange={setJoinDialogOpen}
/>
<TopicsDrawer
channelId={topicsChannelId}
channelTitle={
channels.find((c) => c.id === topicsChannelId)?.title
}
open={!!topicsChannelId}
onOpenChange={(open) => {
if (!open) setTopicsChannelId(null);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect, useCallback, useTransition } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { setTopicFetchEnabled } from "../actions";
interface TopicRow {
id: string;
topicId: string;
topicName: string | null;
fetchEnabled: boolean;
lastScannedAt: string | null;
}
interface TopicsDrawerProps {
channelId: string | null;
channelTitle?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TopicsDrawer({
channelId,
channelTitle,
open,
onOpenChange,
}: TopicsDrawerProps) {
const [topics, setTopics] = useState<TopicRow[]>([]);
const [loading, setLoading] = useState(false);
const [, startTransition] = useTransition();
const [pendingId, setPendingId] = useState<string | null>(null);
const fetchTopics = useCallback(async () => {
if (!channelId) return;
setLoading(true);
try {
const res = await fetch(`/api/telegram/channels/${channelId}/topics`);
if (res.ok) setTopics(await res.json());
else toast.error("Failed to load topics");
} catch {
toast.error("Failed to load topics");
}
setLoading(false);
}, [channelId]);
useEffect(() => {
if (open && channelId) fetchTopics();
}, [open, channelId, fetchTopics]);
const handleToggle = (topic: TopicRow, enabled: boolean) => {
// Optimistic update
setTopics((prev) =>
prev.map((t) => (t.id === topic.id ? { ...t, fetchEnabled: enabled } : t))
);
setPendingId(topic.id);
startTransition(async () => {
const result = await setTopicFetchEnabled(topic.id, enabled);
if (!result.success) {
toast.error(result.error);
// Revert on failure
setTopics((prev) =>
prev.map((t) =>
t.id === topic.id ? { ...t, fetchEnabled: !enabled } : t
)
);
}
setPendingId(null);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-1">
<DialogTitle className="truncate pr-8">
Topics{channelTitle ? `: ${channelTitle}` : ""}
</DialogTitle>
<DialogDescription>
Enabled topics are scanned and their files fetched. Disable a topic to
stop fetching new files from it already-fetched files are kept.
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-4 space-y-2">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading topics...
</span>
</div>
) : topics.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No topics discovered yet they&apos;ll appear here after the next
scan.
</p>
) : (
topics.map((topic) => (
<div
key={topic.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{topic.topicName ?? `Topic ${topic.topicId}`}
</p>
<p className="text-xs text-muted-foreground">
{topic.fetchEnabled ? "Fetching enabled" : "Fetching disabled"}
{topic.lastScannedAt
? ` · last scanned ${new Date(
topic.lastScannedAt
).toLocaleDateString()}`
: " · not scanned yet"}
</p>
</div>
<Switch
checked={topic.fetchEnabled}
disabled={pendingId === topic.id}
onCheckedChange={(checked) => handleToggle(topic, checked)}
aria-label={`Toggle fetching for ${
topic.topicName ?? topic.topicId
}`}
/>
</div>
))
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -436,6 +436,32 @@ export async function rescanChannel(channelId: string): Promise<ActionResult> {
}
}
// ── Topic fetch toggle ──
export async function setTopicFetchEnabled(
topicProgressId: string,
enabled: boolean
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.topicProgress.findUnique({
where: { id: topicProgressId },
});
if (!existing) return { success: false, error: "Topic not found" };
try {
await prisma.topicProgress.update({
where: { id: topicProgressId },
data: { fetchEnabled: enabled },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update topic" };
}
}
// ── Account-Channel link actions ──
export async function linkChannel(

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
import { listChannelTopics } from "@/lib/telegram/admin-queries";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ channelId: string }> }
) {
const authResult = await authenticateApiRequest(request, true);
if ("error" in authResult) return authResult.error;
const { channelId } = await params;
const topics = await listChannelTopics(channelId);
return NextResponse.json(topics);
}

View File

@@ -41,6 +41,7 @@ export async function listChannels() {
telegramId: c.telegramId.toString(),
title: c.title,
type: c.type,
isForum: c.isForum,
isActive: c.isActive,
category: c.category,
createdAt: c.createdAt.toISOString(),
@@ -140,3 +141,24 @@ export async function getUnlinkedChannels(accountId: string) {
telegramId: c.telegramId.toString(),
}));
}
// ── Channel topic queries ──
export async function listChannelTopics(channelId: string) {
const rows = await prisma.topicProgress.findMany({
where: { accountChannelMap: { channelId } },
orderBy: [{ fetchEnabled: "desc" }, { topicName: "asc" }],
});
return rows.map((r) => ({
id: r.id,
topicId: r.topicId.toString(),
topicName: r.topicName,
fetchEnabled: r.fetchEnabled,
lastScannedAt: r.lastScannedAt?.toISOString() ?? null,
}));
}
export type ChannelTopicRow = Awaited<
ReturnType<typeof listChannelTopics>
>[number];

View File

@@ -659,6 +659,45 @@ export async function upsertTopicProgress(
});
}
/**
* Ensure a TopicProgress row exists for every discovered topic so the admin
* UI can list and toggle them — including brand-new topics. Inserts missing
* rows only (fetchEnabled defaults to true); never overwrites watermarks,
* scan-state, or the user's fetchEnabled choice on rows that already exist.
*/
export async function ensureTopicProgressRows(
mappingId: string,
topics: { topicId: bigint; name: string | null }[]
): Promise<void> {
if (topics.length === 0) return;
await db.topicProgress.createMany({
data: topics.map((t) => ({
accountChannelMapId: mappingId,
topicId: t.topicId,
topicName: t.name,
})),
skipDuplicates: true,
});
}
/**
* Read the CURRENT per-topic fetch flag straight from the DB. Returns true
* when no row exists yet (default = enabled). Called fresh inside the topic
* loop so a mid-run toggle is honoured for topics not yet started.
*/
export async function isTopicFetchEnabled(
mappingId: string,
topicId: bigint
): Promise<boolean> {
const row = await db.topicProgress.findUnique({
where: {
accountChannelMapId_topicId: { accountChannelMapId: mappingId, topicId },
},
select: { fetchEnabled: true },
});
return row?.fetchEnabled ?? true;
}
// ── Channel fetch requests (DB-mediated communication with web app) ──
export async function getChannelFetchRequest(requestId: string) {

View File

@@ -37,6 +37,8 @@ import {
updatePackageTopicContext,
upsertChannelScanState,
upsertTopicScanState,
ensureTopicProgressRows,
isTopicFetchEnabled,
} from "./db/queries.js";
import type { ActivityUpdate } from "./db/queries.js";
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
@@ -548,6 +550,15 @@ export async function runWorkerForAccount(
const rawTopics = await getForumTopicList(client, channel.telegramId);
const topicProgressList = await getTopicProgress(mapping.id);
// Persist a TopicProgress row for every discovered topic so the
// admin UI can list and toggle them — including brand-new topics.
// Inserts missing rows only; existing watermarks / scan-state /
// fetchEnabled choices are left untouched.
await ensureTopicProgressRows(
mapping.id,
rawTopics.map((t) => ({ topicId: t.topicId, name: t.name }))
);
// Process more-specific topics BEFORE "General" so the first
// encounter of any file is in its most specific context. This makes
// newly-created Packages carry useful topic info (e.g., a campaign
@@ -567,6 +578,18 @@ export async function runWorkerForAccount(
for (let tIdx = 0; tIdx < topics.length; tIdx++) {
const topic = topics[tIdx];
try {
// ── Per-topic fetch toggle (live, mid-run honouring) ──
// Read the CURRENT enabled flag straight from the DB (not the
// run-start `topicProgressList` snapshot) so disabling a topic
// mid-run skips it for the remainder of this run.
if (!(await isTopicFetchEnabled(mapping.id, topic.topicId))) {
accountLog.info(
{ channel: channel.title, topic: topic.name },
"Topic fetch disabled by user — skipping"
);
continue;
}
let progress = topicProgressList.find(
(tp) => tp.topicId === topic.topicId
);