feat(admin): add node-cron daily scheduler for reports

- node-cron scheduler runs at 00:00 UTC every day
- Custom server.js auto-starts cron on npm start
- isRunning lock prevents concurrent report runs
- cron-initialize API for manual cron control
- Updated npm start to use server.js entry point
This commit is contained in:
ZhenYi 2026-04-22 10:23:21 +08:00
parent 8cf0e22893
commit 86c7810fd9
5 changed files with 147 additions and 1 deletions

View File

@ -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,

View File

@ -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",

38
admin/server.js Normal file
View File

@ -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}`);
});
});

View File

@ -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" });
}

View File

@ -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<typeof cron.schedule> | 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");
}
}