Run background jobs on a pg-boss queue; add e2e tests, CI and deploy script
Timed jobs move from in-process timers to a job registry run by pg-boss: once per schedule across processes, retries, run history, cron schedules in the church timezone, and an admin Background jobs page with Run now. Playwright end-to-end tests run against their own seeded database. A Gitea Actions workflow runs backend tests, frontend checks and the e2e tests, and deploys v* tags with scripts/deploy.sh once secrets are set. Also fixes the at-the-door check-in count for multi-day tickets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// The end-to-end tests run their own dev server; a separate build folder keeps it
|
||||
// from clashing with one already running.
|
||||
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
},
|
||||
|
||||
@@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -27,6 +28,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "yoco", label: "Yoco", icon: CreditCard },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -705,6 +707,7 @@ function SiteSettingsPageInner() {
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
|
||||
import { todaysSession } from "@/lib/sessions";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
@@ -1045,10 +1046,14 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-day events: check-in is per session, so only today's session's check-ins count.
|
||||
const session = todaysSession(ticketsForEvent[0]?.event);
|
||||
const myTickets = ticketsForEvent
|
||||
.filter(t => t.registrationOption?.registration?.id === registration.id)
|
||||
.map(t => {
|
||||
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (t.usages || [])
|
||||
.filter(u => !session || u.sessionId === session.id)
|
||||
.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (t.quantity || 1) - totalRedeemed;
|
||||
return { ...t, totalRedeemed, remaining };
|
||||
});
|
||||
@@ -1080,7 +1085,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
return (
|
||||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||
|
||||
<div className="text-xl font-semibold mb-4">Check-In</div>
|
||||
<div className="text-xl font-semibold mb-4">Check-In{session ? <span className="ml-2 text-sm font-normal text-gray-500">{session.label || "Today's session"}</span> : null}</div>
|
||||
|
||||
{/* ✅ User */}
|
||||
<div className="mb-4">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Timer, Play, RefreshCw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
|
||||
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
|
||||
type JobsResponse = { mode: "queue" | "timers" | "off"; timezone: string; jobs: JobInfo[] };
|
||||
|
||||
const STATE: Record<string, string> = {
|
||||
completed: "bg-green-50 text-green-800",
|
||||
failed: "bg-red-50 text-red-700",
|
||||
active: "bg-blue-50 text-blue-700",
|
||||
created: "bg-gray-100 text-gray-600",
|
||||
retry: "bg-amber-50 text-amber-800",
|
||||
cancelled: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
|
||||
function describeCron(cron: string): string {
|
||||
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
|
||||
if (m) return `Every ${m[1]} minutes`;
|
||||
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
|
||||
if (d) return `Daily at ${d[2].padStart(2, "0")}:${d[1].padStart(2, "0")}`;
|
||||
return cron;
|
||||
}
|
||||
|
||||
function outputText(run: JobRun): string {
|
||||
if (run.output === null || run.output === undefined) return "";
|
||||
if (typeof run.output === "object" && run.output && "message" in run.output && run.state === "failed") return String((run.output as { message: unknown }).message);
|
||||
try { return JSON.stringify(run.output); } catch { return ""; }
|
||||
}
|
||||
|
||||
// Settings → Background jobs: what runs when, the last runs of each job, and "Run now".
|
||||
export function JobsTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<JobsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, setMessage]);
|
||||
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const runNow = async (job: JobInfo) => {
|
||||
if (!token) return;
|
||||
setRunning(job.name);
|
||||
try {
|
||||
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
|
||||
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
|
||||
setTimeout(load, r.queued ? 4000 : 0);
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Couldn't run the job" });
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2"><Timer className="w-5 h-5 text-brand-600" /> Background jobs</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Tasks the server runs on a schedule ({data?.timezone || "local"} time). Each runs once per schedule, even with more than one server process, and failed runs are retried.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} disabled={loading} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50 shrink-0">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && <div className={`p-3 border rounded text-sm ${message.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
|
||||
{data?.mode === "timers" && (
|
||||
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">The job queue couldn't start, so jobs are running on simple timers in this server process (no retries, history only since the last restart). Check the server log for "[jobs]".</div>
|
||||
)}
|
||||
{data?.mode === "off" && (
|
||||
<div className="p-3 border rounded bg-gray-50 text-gray-700 text-sm">All background jobs are switched off (JOBS_ENABLED=false).</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y border rounded-lg">
|
||||
{(data?.jobs || []).map(job => {
|
||||
const last = job.recent[0];
|
||||
return (
|
||||
<li key={job.name} className="p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 mr-auto">
|
||||
<div className="font-medium text-sm text-gray-900">{job.label}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{job.enabled ? `${describeCron(job.cron)} · next ${when(job.nextRun)}` : `Off${job.envFlag ? ` (${job.envFlag}=false)` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
{last && (
|
||||
<button type="button" onClick={() => setOpen(open === job.name ? null : job.name)} className={`text-xs px-2 py-0.5 rounded ${STATE[last.state] || "bg-gray-100 text-gray-600"}`} title="Show recent runs">
|
||||
{last.state} · {when(last.completedOn || last.startedOn || last.createdOn || null)}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={running !== null || data?.mode === "off"} onClick={() => runNow(job)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
|
||||
<Play className="w-3.5 h-3.5" /> {running === job.name ? "Starting…" : "Run now"}
|
||||
</button>
|
||||
</div>
|
||||
{open === job.name && job.recent.length > 0 && (
|
||||
<table className="mt-2 w-full text-xs">
|
||||
<thead className="text-gray-500"><tr><th className="text-left font-medium py-1">Started</th><th className="text-left font-medium">Finished</th><th className="text-left font-medium">State</th><th className="text-left font-medium">Result</th></tr></thead>
|
||||
<tbody>
|
||||
{job.recent.map((r, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="py-1 pr-2 whitespace-nowrap">{when(r.startedOn)}</td>
|
||||
<td className="pr-2 whitespace-nowrap">{when(r.completedOn)}</td>
|
||||
<td className="pr-2"><span className={`px-1.5 py-0.5 rounded ${STATE[r.state] || ""}`}>{r.state}</span></td>
|
||||
<td className="font-mono text-[11px] text-gray-600 break-all">{outputText(r)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!loading && data && data.jobs.length === 0 && <li className="p-3 text-sm text-gray-500">No jobs.</li>}
|
||||
</ul>
|
||||
<p className="text-xs text-gray-400">Change a schedule with the env var JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*/5 * * * *"), or switch one off with its own flag.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for Site Settings (/dashboard/admin/settings), matching its six tabs. */
|
||||
/** Help content for Site Settings (/dashboard/admin/settings). */
|
||||
export const siteSettingsHelpContent: HelpContent = {
|
||||
title: "Site Settings help",
|
||||
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
|
||||
@@ -96,5 +96,24 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "jobs",
|
||||
label: "Background jobs",
|
||||
icon: Timer,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Tasks the server runs on a schedule: the daily summary email, waitlist offers, feedback forms, session reminders and certificates, consent reminders, analytics, the nightly backup and temp-file cleanup.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Timer} title="Schedule and history" tone="brand">
|
||||
Each job shows when it runs and when it runs next. Click a job's last result to see its last 10 runs — when they started and finished, and what they did (or the error). A failed run is retried twice.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Play} title="Run now" tone="green">
|
||||
Runs a job straight away, e.g. to send a feedback form without waiting. It's recorded in the audit log.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Jobs run once per schedule even when more than one server process is running. Schedules and on/off switches are set by the server's environment variables.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -163,3 +163,12 @@ export function currentSessionLabel(event: Pick<Event, "scheduleType" | "session
|
||||
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
|
||||
return s ? sessionLabel(s, event.sessions) : null;
|
||||
}
|
||||
|
||||
/** The session of a multi-day event open for check-in today (browser's local day), or null. */
|
||||
export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): EventSession | null {
|
||||
if (!event || event.scheduleType !== "sessions") return null;
|
||||
const today = new Date().toDateString();
|
||||
const now = Date.now();
|
||||
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
|
||||
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
|
||||
}
|
||||
|
||||
@@ -528,7 +528,7 @@ export type Ticket = {
|
||||
transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
|
||||
event?: Event;
|
||||
registrationOption?: RegistrationOption;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number; sessionId?: string | null }[];
|
||||
};
|
||||
|
||||
export type Payment = {
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user