25 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
22da4dfad2 Fix messagesScanned consistency: use totalScanned from scan results, remove double-counting
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-05 08:36:40 +00:00
copilot-swe-agent[bot]
22bcacf3bd Add live message scanning progress, channel/topic counters to worker activity
- Add progress callbacks to getChannelMessages and getTopicMessages that
  fire after each page of messages is fetched
- Worker now shows channel progress (e.g. "[2/5] Channel Name") when
  processing multiple source channels
- Worker now shows topic progress (e.g. "topic 3/12") when scanning forums
- Worker now shows live message scanning count during channel/topic scans
  (e.g. "Scanning Channel — 300 messages scanned")
- UI stats line now always shows messagesScanned count
- messagesScanned counter now increments during the scanning phase, not
  just during archive processing

Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-05 08:33:26 +00:00
copilot-swe-agent[bot]
15da57b8c0 Fix worker stuck on "Working..." and default channels to disabled
1. Worker trigger: Add ingestion_trigger pg_notify listener so the worker
   picks up on-demand triggers from the UI and runs an immediate cycle with
   full activity tracking (currentActivity, currentStep, etc).

2. Remove orphaned IngestionRun creation from triggerIngestion server action.
   Previously the UI created RUNNING runs without activity fields, causing
   the UI to show "Working..." with no details. Now only the worker creates
   runs with proper activity tracking.

3. Default channels to disabled (isActive: false) in schema and all creation
   paths. Destination channels are explicitly set to active since they must
   receive uploads. Includes Prisma migration.

Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-05 08:27:37 +00:00
copilot-swe-agent[bot]
8f1a912ccb Initial plan 2026-03-05 08:11:20 +00:00
xCyanGrizzly
81b65912aa Merge pull request #11 from xCyanGrizzly/copilot/fix-nextjs-deployment-issues
Fix Docker deployment: file permissions, missing env vars, healthcheck timing
2026-03-04 23:24:05 +01:00
copilot-swe-agent[bot]
5eb2cf05b9 Fix Docker deployment: file permissions, missing env vars, healthcheck timing, error handling
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 22:20:49 +00:00
copilot-swe-agent[bot]
f73d06b3d9 Initial plan 2026-03-04 22:06:36 +00:00
xCyanGrizzly
cac3d518e1 Merge pull request #10 from xCyanGrizzly/copilot/debug-docker-compose-worker
Enable worker service by default in docker-compose
2026-03-04 22:49:52 +01:00
copilot-swe-agent[bot]
987167de0c Enable worker service by default in docker-compose
Remove profiles from worker service in both docker-compose.yml and
docker-compose.dev.yml so the worker starts automatically with
`docker compose up`. This fixes the issue where verification SMS and
the scheduler timer were not working because the worker was never
started. The bot remains as an optional profile.

Update README to reflect the change.

Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 21:13:00 +00:00
copilot-swe-agent[bot]
4f331d5411 Initial plan 2026-03-04 21:09:51 +00:00
xCyanGrizzly
8088a86feb Merge pull request #9 from xCyanGrizzly/copilot/fix-admin-access-issue
Make all users admins in self-hosted deployment
2026-03-04 21:58:04 +01:00
copilot-swe-agent[bot]
b53934ebf2 Make all users admins: update schema default, add migration, simplify registration and OAuth flows
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 20:23:54 +00:00
copilot-swe-agent[bot]
464c86b32a Initial plan 2026-03-04 20:16:22 +00:00
xCyanGrizzly
fc00fb6f2e Merge pull request #8 from xCyanGrizzly/copilot/fix-admin-account-login
Fix first user not getting ADMIN role when signing up via OAuth
2026-03-04 20:24:38 +01:00
copilot-swe-agent[bot]
0c0c9c7f23 Fix first user not getting ADMIN role when signing up via OAuth
The createUser event in auth.ts now promotes the first user to ADMIN
if no admin exists yet. The JWT callback also fetches the role from the
database on sign-in to pick up the freshly assigned ADMIN role.

Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 19:21:25 +00:00
copilot-swe-agent[bot]
82d5fc1812 Initial plan 2026-03-04 19:15:27 +00:00
xCyanGrizzly
9120f0fb5d Merge pull request #7 from xCyanGrizzly/copilot/fix-telegram-page-redirect
Fix telegram page redirect: auto-admin first user, hide admin-only nav
2026-03-04 20:06:12 +01:00
copilot-swe-agent[bot]
5d88f9beb3 Wrap first-user admin check in transaction to prevent race condition
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 18:55:41 +00:00
copilot-swe-agent[bot]
3704708970 Fix telegram page redirect: make first user admin and hide admin-only nav items from non-admins
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 18:55:02 +00:00
copilot-swe-agent[bot]
0c789eabd6 Initial plan 2026-03-04 18:24:56 +00:00
xCyanGrizzly
9a88914f11 Merge pull request #6 from xCyanGrizzly/copilot/fix-module-not-found-error
Fix: replace selective node_modules allowlist with full copy to prevent missing Prisma CLI deps
2026-03-04 17:51:05 +01:00
copilot-swe-agent[bot]
6cc8e1185a Fix: Copy full node_modules to production image to prevent missing module errors
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 15:31:01 +00:00
copilot-swe-agent[bot]
066fb5a046 Fix: Copy valibot to production Docker image for Prisma CLI
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
2026-03-04 15:22:59 +00:00
copilot-swe-agent[bot]
bed99f8167 Initial plan 2026-03-04 15:20:17 +00:00
xCyanGrizzly
80a8833f2c Merge pull request #5 from xCyanGrizzly/copilot/fix-prisma-schema-error
Fix ENOENT for prisma_schema_build_bg.wasm in production Docker image
2026-03-04 16:17:55 +01:00
21 changed files with 198 additions and 103 deletions

View File

@@ -30,22 +30,20 @@ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
# Copy public assets # Copy public assets
COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Copy prisma schema + migrations for runtime migrate deploy
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
# Copy standalone build output # Copy standalone build output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy node_modules for prisma CLI (needed for migrate deploy at startup) # Copy prisma schema + migrations for runtime migrate deploy
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/dotenv ./node_modules/dotenv # Copy node_modules for prisma CLI (needed for migrate deploy at startup).
# Create the .bin/prisma symlink so Node resolves __dirname to prisma/build/, # Copying the full directory ensures all transitive dependencies are present.
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Recreate the .bin/prisma symlink so Node resolves __dirname to prisma/build/,
# where the WASM files live (COPY dereferences symlinks, breaking WASM resolution) # where the WASM files live (COPY dereferences symlinks, breaking WASM resolution)
RUN mkdir -p ./node_modules/.bin && \ RUN mkdir -p ./node_modules/.bin && \
ln -sf ../prisma/build/index.js ./node_modules/.bin/prisma ln -sf ../prisma/build/index.js ./node_modules/.bin/prisma

View File

@@ -125,18 +125,15 @@ docker compose up -d
The app will be available at [http://localhost:3000](http://localhost:3000). The app will be available at [http://localhost:3000](http://localhost:3000).
### Adding Telegram Services ### Adding the Telegram Bot
The worker and bot run as optional profiles so `docker compose up` works with just the app + database: The worker starts by default with `docker compose up`. The bot runs as an optional profile:
```bash ```bash
# App + DB + Telegram worker (needs TELEGRAM_API_ID + TELEGRAM_API_HASH in .env)
docker compose --profile telegram up -d
# App + DB + Worker + Bot (also needs BOT_TOKEN in .env) # App + DB + Worker + Bot (also needs BOT_TOKEN in .env)
docker compose --profile full up -d docker compose --profile full up -d
# Or just the bot (alongside app + db) # Or just the bot (alongside app + db + worker)
docker compose --profile bot up -d docker compose --profile bot up -d
``` ```

View File

@@ -16,7 +16,6 @@ services:
retries: 5 retries: 5
worker: worker:
profiles: ["telegram", "full"]
build: build:
context: . context: .
dockerfile: worker/Dockerfile dockerfile: worker/Dockerfile

View File

@@ -10,9 +10,13 @@ services:
- DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash} - DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash}
- AUTH_SECRET=${AUTH_SECRET:?Set AUTH_SECRET in .env} - AUTH_SECRET=${AUTH_SECRET:?Set AUTH_SECRET in .env}
- AUTH_TRUST_HOST=true - AUTH_TRUST_HOST=true
- AUTH_GITHUB_ID=${AUTH_GITHUB_ID:-}
- AUTH_GITHUB_SECRET=${AUTH_GITHUB_SECRET:-}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
- TELEGRAM_API_KEY=${TELEGRAM_API_KEY:-} - TELEGRAM_API_KEY=${TELEGRAM_API_KEY:-}
- BOT_TOKEN=${BOT_TOKEN:-} - BOT_TOKEN=${BOT_TOKEN:-}
- BOT_USERNAME=${BOT_USERNAME:-}
- LOG_LEVEL=${LOG_LEVEL:-info}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -21,7 +25,7 @@ services:
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 60s
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
@@ -31,7 +35,6 @@ services:
- frontend - frontend
worker: worker:
profiles: ["telegram", "full"]
build: build:
context: . context: .
dockerfile: worker/Dockerfile dockerfile: worker/Dockerfile

View File

@@ -10,7 +10,10 @@ if [ "$AUTH_SECRET" = "change-me-to-a-random-secret-in-production" ] || [ -z "$A
fi fi
echo "Running database migrations..." echo "Running database migrations..."
./node_modules/.bin/prisma migrate deploy if ! ./node_modules/.bin/prisma migrate deploy; then
echo "ERROR: Database migration failed. Check DATABASE_URL and database connectivity."
exit 1
fi
if [ "$SEED_DATABASE" = "true" ]; then if [ "$SEED_DATABASE" = "true" ]; then
echo "Seeding database..." echo "Seeding database..."

View File

@@ -0,0 +1,5 @@
-- Promote all existing users to ADMIN (self-hosted: every user is an admin)
UPDATE "User" SET "role" = 'ADMIN' WHERE "role" = 'USER';
-- Change the default role for new users to ADMIN
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'ADMIN';

View File

@@ -0,0 +1,3 @@
-- Change the default for new channels to disabled (isActive = false).
-- Existing channels are not affected — admins can manually enable/disable them.
ALTER TABLE "telegram_channels" ALTER COLUMN "isActive" SET DEFAULT false;

View File

@@ -22,7 +22,7 @@ model User {
emailVerified DateTime? emailVerified DateTime?
image String? image String?
hashedPassword String? hashedPassword String?
role Role @default(USER) role Role @default(ADMIN)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -417,7 +417,7 @@ model TelegramChannel {
title String title String
type ChannelType type ChannelType
isForum Boolean @default(false) isForum Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -233,6 +233,11 @@ function RunningStatus({
</span> </span>
</span> </span>
)} )}
{run.messagesScanned > 0 && (
<span>
<span className="text-foreground tabular-nums">{run.messagesScanned}</span> messages
</span>
)}
{run.zipsIngested > 0 && ( {run.zipsIngested > 0 && (
<span> <span>
<span className="text-foreground tabular-nums">{run.zipsIngested}</span> ingested <span className="text-foreground tabular-nums">{run.zipsIngested}</span> ingested

View File

@@ -173,6 +173,7 @@ export async function createChannel(
telegramId: BigInt(parsed.data.telegramId), telegramId: BigInt(parsed.data.telegramId),
title: parsed.data.title, title: parsed.data.title,
type: parsed.data.type, type: parsed.data.type,
isActive: false,
}, },
}); });
revalidatePath(REVALIDATE_PATH); revalidatePath(REVALIDATE_PATH);
@@ -371,19 +372,8 @@ export async function triggerIngestion(
return { success: false, error: "No eligible accounts found" }; return { success: false, error: "No eligible accounts found" };
} }
// Create ingestion runs — the worker picks these up // Signal the worker to run an immediate ingestion cycle via pg_notify.
for (const account of accounts) { // The worker will create its own IngestionRun records with proper activity tracking.
const existing = await prisma.ingestionRun.findFirst({
where: { accountId: account.id, status: "RUNNING" },
});
if (!existing) {
await prisma.ingestionRun.create({
data: { accountId: account.id, status: "RUNNING" },
});
}
}
// pg_notify for immediate worker pickup
try { try {
await prisma.$queryRawUnsafe( await prisma.$queryRawUnsafe(
`SELECT pg_notify('ingestion_trigger', $1)`, `SELECT pg_notify('ingestion_trigger', $1)`,
@@ -417,7 +407,7 @@ export async function saveChannelSelections(
try { try {
let linked = 0; let linked = 0;
for (const ch of channels) { for (const ch of channels) {
// Upsert the channel record // Upsert the channel record (new channels default to disabled)
const channel = await prisma.telegramChannel.upsert({ const channel = await prisma.telegramChannel.upsert({
where: { telegramId: BigInt(ch.telegramId) }, where: { telegramId: BigInt(ch.telegramId) },
create: { create: {
@@ -425,6 +415,7 @@ export async function saveChannelSelections(
title: ch.title, title: ch.title,
type: "SOURCE", type: "SOURCE",
isForum: ch.isForum, isForum: ch.isForum,
isActive: false,
}, },
update: { update: {
title: ch.title, title: ch.title,
@@ -467,10 +458,10 @@ export async function setGlobalDestination(
if (!channel) return { success: false, error: "Channel not found" }; if (!channel) return { success: false, error: "Channel not found" };
try { try {
// Set the channel type to DESTINATION // Set the channel type to DESTINATION and ensure it's active
await prisma.telegramChannel.update({ await prisma.telegramChannel.update({
where: { id: channelId }, where: { id: channelId },
data: { type: "DESTINATION" }, data: { type: "DESTINATION", isActive: true },
}); });
// Save as global destination // Save as global destination
@@ -521,17 +512,19 @@ export async function createDestinationChannel(
if (!admin.success) return admin; if (!admin.success) return admin;
try { try {
// Create the channel as DESTINATION // Create the channel as DESTINATION (active by default — needed for uploads)
const channel = await prisma.telegramChannel.upsert({ const channel = await prisma.telegramChannel.upsert({
where: { telegramId: BigInt(telegramId) }, where: { telegramId: BigInt(telegramId) },
create: { create: {
telegramId: BigInt(telegramId), telegramId: BigInt(telegramId),
title, title,
type: "DESTINATION", type: "DESTINATION",
isActive: true,
}, },
update: { update: {
title, title,
type: "DESTINATION", type: "DESTINATION",
isActive: true,
}, },
}); });

View File

@@ -21,12 +21,13 @@ export async function registerUser(input: unknown): Promise<ActionResult<{ id: s
const hashedPassword = await bcrypt.hash(parsed.data.password, 10); const hashedPassword = await bcrypt.hash(parsed.data.password, 10);
// Self-hosted: all users are admins
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
name: parsed.data.name, name: parsed.data.name,
email: parsed.data.email, email: parsed.data.email,
hashedPassword, hashedPassword,
role: "USER", role: "ADMIN",
settings: { settings: {
create: { create: {
lowStockThreshold: 10, lowStockThreshold: 10,

View File

@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { import {
LayoutDashboard, LayoutDashboard,
Cylinder, Cylinder,
@@ -17,27 +18,17 @@ import {
Flame, Flame,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { APP_NAME } from "@/lib/constants"; import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
import { SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, FileBox, Send, 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 },
{ label: "Filaments", href: "/filaments", icon: "Cylinder" as const },
{ 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 },
{ label: "Settings", href: "/settings", icon: "Settings" as const },
];
export function MobileSidebar() { export function MobileSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { data: session } = useSession();
const isAdmin = session?.user?.role === "ADMIN";
const visibleItems = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -48,7 +39,7 @@ export function MobileSidebar() {
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<nav className="flex-1 space-y-1 p-2"> <nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => { {visibleItems.map((item) => {
const Icon = icons[item.icon]; const Icon = icons[item.icon];
const isActive = pathname.startsWith(item.href); const isActive = pathname.startsWith(item.href);

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { import {
LayoutDashboard, LayoutDashboard,
Cylinder, Cylinder,
@@ -20,7 +21,7 @@ import {
PanelLeft, PanelLeft,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { APP_NAME } from "@/lib/constants"; import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -38,23 +39,13 @@ const icons = {
Settings, Settings,
} as const; } as const;
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const },
{ label: "Filaments", href: "/filaments", icon: "Cylinder" as const },
{ 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 },
{ label: "Settings", href: "/settings", icon: "Settings" as const },
];
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const { data: session } = useSession();
const isAdmin = session?.user?.role === "ADMIN";
const visibleItems = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin);
return ( return (
<aside <aside
@@ -73,7 +64,7 @@ export function Sidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 space-y-1 p-2"> <nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => { {visibleItems.map((item) => {
const Icon = icons[item.icon]; const Icon = icons[item.icon];
const isActive = pathname.startsWith(item.href); const isActive = pathname.startsWith(item.href);

View File

@@ -18,7 +18,12 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
token.id = user.id!; token.id = user.id!;
token.role = user.role ?? "USER"; // Fetch the role from the database to ensure token reflects current role
const dbUser = await prisma.user.findUnique({
where: { id: user.id! },
select: { role: true },
});
token.role = dbUser?.role ?? user.role ?? "ADMIN";
} }
return token; return token;
}, },
@@ -33,6 +38,12 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
events: { events: {
async createUser({ user }) { async createUser({ user }) {
if (user.id) { if (user.id) {
// Self-hosted: all users are admins
await prisma.user.update({
where: { id: user.id },
data: { role: "ADMIN" },
});
await prisma.userSettings.upsert({ await prisma.userSettings.upsert({
where: { userId: user.id }, where: { userId: user.id },
update: {}, update: {},

View File

@@ -1,17 +1,17 @@
export const APP_NAME = "Dragon's Stash"; export const APP_NAME = "Dragon's Stash";
export const NAV_ITEMS = [ export const NAV_ITEMS = [
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" }, { label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard", adminOnly: false },
{ label: "Filaments", href: "/filaments", icon: "Cylinder" }, { label: "Filaments", href: "/filaments", icon: "Cylinder", adminOnly: false },
{ label: "Resins", href: "/resins", icon: "Droplets" }, { label: "Resins", href: "/resins", icon: "Droplets", adminOnly: false },
{ label: "Paints", href: "/paints", icon: "Paintbrush" }, { label: "Paints", href: "/paints", icon: "Paintbrush", adminOnly: false },
{ label: "Supplies", href: "/supplies", icon: "Gem" }, { label: "Supplies", href: "/supplies", icon: "Gem", adminOnly: false },
{ label: "STL Files", href: "/stls", icon: "FileBox" }, { label: "STL Files", href: "/stls", icon: "FileBox", adminOnly: false },
{ label: "Telegram", href: "/telegram", icon: "Send" }, { label: "Telegram", href: "/telegram", icon: "Send", adminOnly: true },
{ label: "Usage", href: "/usage", icon: "ClipboardList" }, { label: "Usage", href: "/usage", icon: "ClipboardList", adminOnly: false },
{ label: "Vendors", href: "/vendors", icon: "Building2" }, { label: "Vendors", href: "/vendors", icon: "Building2", adminOnly: false },
{ label: "Locations", href: "/locations", icon: "MapPin" }, { label: "Locations", href: "/locations", icon: "MapPin", adminOnly: false },
{ label: "Settings", href: "/settings", icon: "Settings" }, { label: "Settings", href: "/settings", icon: "Settings", adminOnly: false },
] as const; ] as const;
export const MATERIALS = [ export const MATERIALS = [

View File

@@ -302,11 +302,15 @@ export interface UpsertChannelInput {
title: string; title: string;
type: "SOURCE" | "DESTINATION"; type: "SOURCE" | "DESTINATION";
isForum: boolean; isForum: boolean;
isActive?: boolean;
} }
/** /**
* Upsert a channel by telegramId. Returns the channel record. * Upsert a channel by telegramId. Returns the channel record.
* If it already exists, update title and forum status. * If it already exists, update title and forum status.
* New channels default to disabled (isActive: false) so the admin must
* explicitly enable them before the worker processes them.
* Pass isActive: true for DESTINATION channels that must be active immediately.
*/ */
export async function upsertChannel(input: UpsertChannelInput) { export async function upsertChannel(input: UpsertChannelInput) {
return db.telegramChannel.upsert({ return db.telegramChannel.upsert({
@@ -316,6 +320,7 @@ export async function upsertChannel(input: UpsertChannelInput) {
title: input.title, title: input.title,
type: input.type, type: input.type,
isForum: input.isForum, isForum: input.isForum,
isActive: input.isActive ?? false,
}, },
update: { update: {
title: input.title, title: input.title,

View File

@@ -5,6 +5,7 @@ import { withTdlibMutex } from "./util/mutex.js";
import { processFetchRequest } from "./worker.js"; import { processFetchRequest } from "./worker.js";
import { generateInviteLink, createSupergroup } from "./tdlib/chats.js"; import { generateInviteLink, createSupergroup } from "./tdlib/chats.js";
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js"; import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
import { triggerImmediateCycle } from "./scheduler.js";
import { import {
getGlobalDestinationChannel, getGlobalDestinationChannel,
getGlobalSetting, getGlobalSetting,
@@ -25,12 +26,14 @@ let pgClient: pg.PoolClient | null = null;
* - `channel_fetch` — payload = requestId → fetch channels for an account * - `channel_fetch` — payload = requestId → fetch channels for an account
* - `generate_invite` — payload = channelId → generate invite link for destination * - `generate_invite` — payload = channelId → generate invite link for destination
* - `create_destination` — payload = JSON { requestId, title } → create supergroup via TDLib * - `create_destination` — payload = JSON { requestId, title } → create supergroup via TDLib
* - `ingestion_trigger` — trigger an immediate ingestion cycle
*/ */
export async function startFetchListener(): Promise<void> { export async function startFetchListener(): Promise<void> {
pgClient = await pool.connect(); pgClient = await pool.connect();
await pgClient.query("LISTEN channel_fetch"); await pgClient.query("LISTEN channel_fetch");
await pgClient.query("LISTEN generate_invite"); await pgClient.query("LISTEN generate_invite");
await pgClient.query("LISTEN create_destination"); await pgClient.query("LISTEN create_destination");
await pgClient.query("LISTEN ingestion_trigger");
pgClient.on("notification", (msg) => { pgClient.on("notification", (msg) => {
if (msg.channel === "channel_fetch" && msg.payload) { if (msg.channel === "channel_fetch" && msg.payload) {
@@ -39,10 +42,12 @@ export async function startFetchListener(): Promise<void> {
handleGenerateInvite(msg.payload); handleGenerateInvite(msg.payload);
} else if (msg.channel === "create_destination" && msg.payload) { } else if (msg.channel === "create_destination" && msg.payload) {
handleCreateDestination(msg.payload); handleCreateDestination(msg.payload);
} else if (msg.channel === "ingestion_trigger") {
handleIngestionTrigger();
} }
}); });
log.info("Fetch listener started (channel_fetch, generate_invite, create_destination)"); log.info("Fetch listener started (channel_fetch, generate_invite, create_destination, ingestion_trigger)");
} }
export function stopFetchListener(): void { export function stopFetchListener(): void {
@@ -138,12 +143,13 @@ function handleCreateDestination(payload: string): void {
const result = await createSupergroup(client, parsed.title); const result = await createSupergroup(client, parsed.title);
log.info({ chatId: result.chatId.toString(), title: result.title }, "Supergroup created"); log.info({ chatId: result.chatId.toString(), title: result.title }, "Supergroup created");
// Upsert it as a DESTINATION channel in the DB // Upsert it as a DESTINATION channel in the DB (active by default)
const channel = await upsertChannel({ const channel = await upsertChannel({
telegramId: result.chatId, telegramId: result.chatId,
title: result.title, title: result.title,
type: "DESTINATION", type: "DESTINATION",
isForum: false, isForum: false,
isActive: true,
}); });
// Set as global destination // Set as global destination
@@ -204,3 +210,16 @@ function handleCreateDestination(payload: string): void {
} }
}); });
} }
// ── Ingestion trigger handler ──
function handleIngestionTrigger(): void {
fetchQueue = fetchQueue.then(async () => {
try {
log.info("Ingestion trigger received from UI");
await triggerImmediateCycle();
} catch (err) {
log.error({ err }, "Failed to trigger immediate ingestion cycle");
}
});
}

View File

@@ -105,6 +105,19 @@ export async function startScheduler(): Promise<void> {
scheduleNext(); scheduleNext();
} }
/**
* Trigger an immediate ingestion cycle (e.g. from the admin UI).
* If a cycle is already running, this is a no-op.
*/
export async function triggerImmediateCycle(): Promise<void> {
if (running) {
log.info("Cycle already running, ignoring trigger");
return;
}
log.info("Immediate cycle triggered via UI");
await runCycle();
}
/** /**
* Stop the scheduler gracefully. * Stop the scheduler gracefully.
*/ */

View File

@@ -66,8 +66,11 @@ interface TdFile {
export interface ChannelScanResult { export interface ChannelScanResult {
archives: TelegramMessage[]; archives: TelegramMessage[];
photos: TelegramPhoto[]; photos: TelegramPhoto[];
totalScanned: number;
} }
export type ScanProgressCallback = (messagesScanned: number) => void;
/** /**
* Fetch messages from a channel, stopping once we've scanned past the * Fetch messages from a channel, stopping once we've scanned past the
* last-processed boundary (with one page of lookback for multipart safety). * last-processed boundary (with one page of lookback for multipart safety).
@@ -82,13 +85,15 @@ export async function getChannelMessages(
client: Client, client: Client,
chatId: bigint, chatId: bigint,
lastProcessedMessageId?: bigint | null, lastProcessedMessageId?: bigint | null,
limit = 100 limit = 100,
onProgress?: ScanProgressCallback
): Promise<ChannelScanResult> { ): Promise<ChannelScanResult> {
const archives: TelegramMessage[] = []; const archives: TelegramMessage[] = [];
const photos: TelegramPhoto[] = []; const photos: TelegramPhoto[] = [];
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null; const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
let currentFromId = 0; let currentFromId = 0;
let totalScanned = 0;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
@@ -103,6 +108,8 @@ export async function getChannelMessages(
if (!result.messages || result.messages.length === 0) break; if (!result.messages || result.messages.length === 0) break;
totalScanned += result.messages.length;
for (const msg of result.messages) { for (const msg of result.messages) {
// Check for archive documents // Check for archive documents
const doc = msg.content?.document; const doc = msg.content?.document;
@@ -132,6 +139,9 @@ export async function getChannelMessages(
} }
} }
// Report scanning progress after each page
onProgress?.(totalScanned);
currentFromId = result.messages[result.messages.length - 1].id; currentFromId = result.messages[result.messages.length - 1].id;
// Stop scanning once we've gone past the boundary (this page is the lookback) // Stop scanning once we've gone past the boundary (this page is the lookback)
@@ -144,7 +154,7 @@ export async function getChannelMessages(
} }
log.info( log.info(
{ chatId: chatId.toString(), archives: archives.length, photos: photos.length }, { chatId: chatId.toString(), archives: archives.length, photos: photos.length, totalScanned },
"Channel scan complete" "Channel scan complete"
); );
@@ -152,6 +162,7 @@ export async function getChannelMessages(
return { return {
archives: archives.reverse(), archives: archives.reverse(),
photos: photos.reverse(), photos: photos.reverse(),
totalScanned,
}; };
} }

View File

@@ -4,7 +4,7 @@ import { childLogger } from "../util/logger.js";
import { isArchiveAttachment } from "../archive/detect.js"; import { isArchiveAttachment } from "../archive/detect.js";
import type { TelegramMessage } from "../archive/multipart.js"; import type { TelegramMessage } from "../archive/multipart.js";
import type { TelegramPhoto } from "../preview/match.js"; import type { TelegramPhoto } from "../preview/match.js";
import type { ChannelScanResult } from "./download.js"; import type { ChannelScanResult, ScanProgressCallback } from "./download.js";
const log = childLogger("topics"); const log = childLogger("topics");
@@ -140,13 +140,15 @@ export async function getTopicMessages(
chatId: bigint, chatId: bigint,
topicId: bigint, topicId: bigint,
lastProcessedMessageId?: bigint | null, lastProcessedMessageId?: bigint | null,
limit = 100 limit = 100,
onProgress?: ScanProgressCallback
): Promise<ChannelScanResult> { ): Promise<ChannelScanResult> {
const archives: TelegramMessage[] = []; const archives: TelegramMessage[] = [];
const photos: TelegramPhoto[] = []; const photos: TelegramPhoto[] = [];
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null; const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
let currentFromId = 0; let currentFromId = 0;
let totalScanned = 0;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
@@ -190,6 +192,8 @@ export async function getTopicMessages(
if (!result.messages || result.messages.length === 0) break; if (!result.messages || result.messages.length === 0) break;
totalScanned += result.messages.length;
for (const msg of result.messages) { for (const msg of result.messages) {
// Check for archive documents // Check for archive documents
const doc = msg.content?.document; const doc = msg.content?.document;
@@ -219,6 +223,9 @@ export async function getTopicMessages(
} }
} }
// Report scanning progress after each page
onProgress?.(totalScanned);
currentFromId = result.messages[result.messages.length - 1].id; currentFromId = result.messages[result.messages.length - 1].id;
// Stop scanning once we've gone past the boundary (this page is the lookback) // Stop scanning once we've gone past the boundary (this page is the lookback)
@@ -230,7 +237,7 @@ export async function getTopicMessages(
} }
log.info( log.info(
{ chatId: chatId.toString(), topicId: topicId.toString(), archives: archives.length, photos: photos.length }, { chatId: chatId.toString(), topicId: topicId.toString(), archives: archives.length, photos: photos.length, totalScanned },
"Topic scan complete" "Topic scan complete"
); );
@@ -238,6 +245,7 @@ export async function getTopicMessages(
return { return {
archives: archives.reverse(), archives: archives.reverse(),
photos: photos.reverse(), photos: photos.reverse(),
totalScanned,
}; };
} }

View File

@@ -349,8 +349,14 @@ export async function runWorkerForAccount(
throw new Error("No global destination channel configured — set one in the admin UI"); throw new Error("No global destination channel configured — set one in the admin UI");
} }
for (const mapping of channelMappings) { const totalChannels = channelMappings.length;
for (let chIdx = 0; chIdx < channelMappings.length; chIdx++) {
const mapping = channelMappings[chIdx];
const channel = mapping.channel; const channel = mapping.channel;
const channelLabel = totalChannels > 1
? `[${chIdx + 1}/${totalChannels}] ${channel.title}`
: channel.title;
try { try {
// ── Check if channel is a forum ── // ── Check if channel is a forum ──
@@ -380,15 +386,16 @@ export async function runWorkerForAccount(
if (forum) { if (forum) {
// ── Forum channel: scan per-topic ── // ── Forum channel: scan per-topic ──
await updateRunActivity(activeRunId, { await updateRunActivity(activeRunId, {
currentActivity: `Enumerating topics in "${channel.title}"`, currentActivity: `Enumerating topics in "${channelLabel}"`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channel.title, currentChannel: channelLabel,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
downloadPercent: null, downloadPercent: null,
messagesScanned: counters.messagesScanned,
}); });
const topics = await getForumTopicList(client, channel.telegramId); const topics = await getForumTopicList(client, channel.telegramId);
@@ -399,31 +406,50 @@ export async function runWorkerForAccount(
"Scanning forum channel by topic" "Scanning forum channel by topic"
); );
for (const topic of topics) { for (let tIdx = 0; tIdx < topics.length; tIdx++) {
const topic = topics[tIdx];
try { try {
const progress = topicProgressList.find( const progress = topicProgressList.find(
(tp) => tp.topicId === topic.topicId (tp) => tp.topicId === topic.topicId
); );
const topicLabel = `${channel.title} ${topic.name}`;
const topicProgress = topics.length > 1
? ` (topic ${tIdx + 1}/${topics.length})`
: "";
await updateRunActivity(activeRunId, { await updateRunActivity(activeRunId, {
currentActivity: `Scanning topic "${topic.name}" in "${channel.title}"`, currentActivity: `Scanning "${topicLabel}"${topicProgress}`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: `${channel.title} ${topic.name}`, currentChannel: channelLabel,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
downloadPercent: null, downloadPercent: null,
messagesScanned: counters.messagesScanned,
}); });
const scanResult = await getTopicMessages( const scanResult = await getTopicMessages(
client, client,
channel.telegramId, channel.telegramId,
topic.topicId, topic.topicId,
progress?.lastProcessedMessageId progress?.lastProcessedMessageId,
100,
(scanned) => {
throttled.update({
currentActivity: `Scanning "${topicLabel}"${topicProgress}${scanned} messages scanned`,
currentStep: "scanning",
currentChannel: channelLabel,
messagesScanned: counters.messagesScanned + scanned,
});
}
); );
// Add scanned messages to global counter
counters.messagesScanned += scanResult.totalScanned;
if (scanResult.archives.length === 0) { if (scanResult.archives.length === 0) {
accountLog.debug( accountLog.debug(
{ channelId: channel.id, topic: topic.name }, { channelId: channel.id, topic: topic.name },
@@ -463,15 +489,16 @@ export async function runWorkerForAccount(
} else { } else {
// ── Non-forum channel: flat scan (existing behavior) ── // ── Non-forum channel: flat scan (existing behavior) ──
await updateRunActivity(activeRunId, { await updateRunActivity(activeRunId, {
currentActivity: `Scanning "${channel.title}" for new archives`, currentActivity: `Scanning "${channelLabel}" for new archives`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channel.title, currentChannel: channelLabel,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
downloadPercent: null, downloadPercent: null,
messagesScanned: counters.messagesScanned,
}); });
accountLog.info( accountLog.info(
@@ -482,9 +509,21 @@ export async function runWorkerForAccount(
const scanResult = await getChannelMessages( const scanResult = await getChannelMessages(
client, client,
channel.telegramId, channel.telegramId,
mapping.lastProcessedMessageId mapping.lastProcessedMessageId,
100,
(scanned) => {
throttled.update({
currentActivity: `Scanning "${channelLabel}" — ${scanned} messages scanned`,
currentStep: "scanning",
currentChannel: channelLabel,
messagesScanned: counters.messagesScanned + scanned,
});
}
); );
// Add scanned messages to global counter
counters.messagesScanned += scanResult.totalScanned;
if (scanResult.archives.length === 0) { if (scanResult.archives.length === 0) {
accountLog.debug({ channelId: channel.id }, "No new archives"); accountLog.debug({ channelId: channel.id }, "No new archives");
continue; continue;
@@ -593,6 +632,7 @@ async function processArchiveSets(
currentChannel: channelTitle, currentChannel: channelTitle,
totalFiles: archiveSets.length, totalFiles: archiveSets.length,
zipsFound: counters.zipsFound, zipsFound: counters.zipsFound,
messagesScanned: counters.messagesScanned,
}); });
// Track the highest message ID that was successfully processed // Track the highest message ID that was successfully processed
@@ -646,7 +686,6 @@ async function processOneArchiveSet(
throttled, counters, topicCreator, sourceTopicId, accountLog, throttled, counters, topicCreator, sourceTopicId, accountLog,
} = ctx; } = ctx;
counters.messagesScanned += archiveSet.parts.length;
const archiveName = archiveSet.parts[0].fileName; const archiveName = archiveSet.parts[0].fileName;
// ── Early skip: check if this archive set was already ingested ── // ── Early skip: check if this archive set was already ingested ──