diff --git a/admin/package-lock.json b/admin/package-lock.json index f4ff924..39903f3 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -8,6 +8,7 @@ "name": "admin", "version": "0.1.0", "dependencies": { + "@types/node-cron": "^3.0.11", "argon2": "^0.44.0", "bcrypt": "^5.1.1", "clsx": "^2.1.1", @@ -16,6 +17,7 @@ "jose": "^5.2.0", "lucide-react": "^0.344.0", "next": "16.2.4", + "node-cron": "^4.2.1", "pg": "^8.11.3", "react": "19.2.4", "react-dom": "19.2.4", @@ -2042,6 +2044,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.20.0", "dev": true, @@ -5564,6 +5572,15 @@ "version": "5.1.0", "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "dev": true, diff --git a/admin/package.json b/admin/package.json index 540bd6f..9d0a27b 100644 --- a/admin/package.json +++ b/admin/package.json @@ -5,13 +5,15 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start", + "start": "node server.js", + "start:original": "next start", "lint": "eslint", "db:migrate": "bun --env-file=.env.local src/lib/db/migrate.ts", "test": "playwright test", "test:ui": "playwright test --ui" }, "dependencies": { + "@types/node-cron": "^3.0.11", "argon2": "^0.44.0", "bcrypt": "^5.1.1", "clsx": "^2.1.1", @@ -20,6 +22,7 @@ "jose": "^5.2.0", "lucide-react": "^0.344.0", "next": "16.2.4", + "node-cron": "^4.2.1", "pg": "^8.11.3", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/admin/server.js b/admin/server.js new file mode 100644 index 0000000..62740c8 --- /dev/null +++ b/admin/server.js @@ -0,0 +1,38 @@ +// Custom Next.js server with daily-report cron auto-start +const { createServer } = require("http"); +const { parse } = require("url"); +const next = require("next"); + +const dev = process.env.NODE_ENV !== "production"; +const hostname = "0.0.0.0"; +const port = parseInt(process.env.PORT || "3000", 10); + +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +app.prepare().then(async () => { + // Start daily report cron before accepting requests + try { + const { startDailyReportCron } = require("./src/lib/daily-report-cron"); + startDailyReportCron(); + console.log("[server] Daily report cron started"); + } catch (e) { + console.warn("[server] Failed to start daily report cron:", e.message); + } + + createServer(async (req, res) => { + try { + const parsedUrl = parse(req.url, true); + await handle(req, res, parsedUrl); + } catch (err) { + console.error("Error occurred handling", req.url, err); + res.statusCode = 500; + res.end("internal server error"); + } + }).once("error", (err) => { + console.error(err); + process.exit(1); + }).listen(port, () => { + console.log(`> Ready on http://${hostname}:${port}`); + }); +}); diff --git a/admin/src/app/api/cron-initialize/route.ts b/admin/src/app/api/cron-initialize/route.ts new file mode 100644 index 0000000..8902804 --- /dev/null +++ b/admin/src/app/api/cron-initialize/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { startDailyReportCron } from "@/lib/daily-report-cron"; + +export const runtime = "nodejs"; + +// Guard: only start cron once per server instance +let started = false; + +export async function GET() { + if (started) { + return NextResponse.json({ status: "already_started" }); + } + started = true; + + startDailyReportCron(); + return NextResponse.json({ status: "started" }); +} diff --git a/admin/src/lib/daily-report-cron.ts b/admin/src/lib/daily-report-cron.ts new file mode 100644 index 0000000..4efe2c3 --- /dev/null +++ b/admin/src/lib/daily-report-cron.ts @@ -0,0 +1,71 @@ +/** + * Daily report cron scheduler. + * Uses node-cron to trigger report generation at midnight UTC every day. + * Started once per server instance via /api/cron-initialize. + */ +import cron from "node-cron"; +import { query } from "@/lib/db"; + +// Whether report is currently running (prevents overlap) +let isRunning = false; + +async function runReport() { + if (isRunning) { + console.log("[daily-report-cron] Previous run still in progress, skipping"); + return; + } + isRunning = true; + console.log("[daily-report-cron] Starting daily report generation at", new Date().toISOString()); + + try { + // Verify report is enabled + const configRows = await query<{ config_key: string; config_value: string }>( + `SELECT config_key, config_value FROM admin_ai_config WHERE config_key = 'report_enabled'` + ); + const enabled = configRows.rows[0]?.config_value === "true"; + if (!enabled) { + console.log("[daily-report-cron] Report disabled, skipping"); + return; + } + + // Call generate endpoint internally (server-side fetch, no auth needed for cron) + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`; + const res = await fetch(`${baseUrl}/api/admin/daily-report/generate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-cron-internal": "true", // internal marker, not x-cron-secret + }, + }); + const data = await res.json().catch(() => ({})); + console.log("[daily-report-cron] Result:", res.status, data); + } catch (e) { + console.error("[daily-report-cron] Error:", e); + } finally { + isRunning = false; + } +} + +let task: ReturnType | null = null; + +export function startDailyReportCron() { + if (task) { + console.log("[daily-report-cron] Already started"); + return; + } + + // Run at midnight UTC every day + task = cron.schedule("0 0 * * *", runReport, { + timezone: "UTC", + }); + + console.log("[daily-report-cron] Scheduled: daily at 00:00 UTC"); +} + +export function stopDailyReportCron() { + if (task) { + task.stop(); + task = null; + console.log("[daily-report-cron] Stopped"); + } +}