feat: opinionated public DEMO_MODE with simulated/blocked actions, resets, and demo toasts (#87)

* feat(demo): add DEMO_MODE runtime helpers and /api/demo/info endpoint

* feat(demo): enforce simulated and blocked API actions under DEMO_MODE

* feat(demo): add deterministic seed dataset and 6-hour auto-reset

* feat(demo-ui): add demo banner and custom sonner toasts for simulated/blocked actions

* test+docs(demo): add demo mode coverage, behavior matrix, and operator docs

* formatting

* tests

* feat(demo): seed resets from typed baseline defaults

* formatting

* feat(demo): enrich baseline seed data and demo project catalog

* feat(demo): expand seeded applications and chart time ranges

* refactor(demo): split demo seed data from generation logic

* feat(demo): cap generated application history to 30 days

* feat(demo): rebalance generated job status distribution

* feat(demo-ui): make demo banner fixed and topmost

* minor fixes

* formatting

* duration revert

* durations

* feat(demo): share demo info hook, brighten demo toasts, and enforce webhook auth

* comment explaning

* formatting

* comments

* deadline builder comment
This commit is contained in:
Shaheer Sarfaraz 2026-02-05 16:04:04 +00:00 committed by GitHub
parent d18464548e
commit c4749b4211
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2301 additions and 34 deletions

View File

@ -50,6 +50,19 @@ After this, all write actions (POST/PATCH/DELETE) require Basic Auth; browsing a
Persistent data lives in `./data` (bind-mounted into the container).
## Public Demo Mode
Set `DEMO_MODE=true` to run an opinionated public demo experience.
- Works: browsing jobs, filtering, stage updates, and local demo DB changes.
- Simulated: pipeline run, summarize/process/rescore/pdf/apply flows, onboarding validation.
- Blocked: settings writes, database clear, backup create/delete, and status bulk-delete.
- Reset policy: demo dataset automatically resets every 6 hours.
Demo responses include request metadata and may include:
- `meta.simulated=true` for simulated actions
- `meta.blockedReason` for blocked actions
## Running (local dev)
Prereqs: Node 20+, Python 3.10+, Playwright browsers (Firefox).

View File

@ -40,6 +40,16 @@ Upgrade note: `OPENROUTER_API_KEY` is deprecated. Existing OpenRouter keys are a
- Generated PDFs: `data/pdfs/`
- Template resume selection: Stored internally after selection.
## Public demo deployment (`DEMO_MODE=true`)
For a public sandbox website, set `DEMO_MODE=true` on the container.
Behavior in demo mode:
- **Works (local demo DB):** browsing, filtering, job status updates, timeline edits.
- **Simulated (no external side effects):** pipeline run, job summarize/process/rescore/pdf/apply, onboarding validations.
- **Blocked:** settings writes, database clear, backup create/delete, status bulk deletes.
- **Auto-reset:** seeded demo data is reset every 6 hours.
## Updating
```bash

View File

@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner";
import { OnboardingGate } from "./components/OnboardingGate";
import { useDemoInfo } from "./hooks/useDemoInfo";
import { HomePage } from "./pages/HomePage";
import { JobPage } from "./pages/JobPage";
import { OrchestratorPage } from "./pages/OrchestratorPage";
@ -18,6 +19,7 @@ import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
export const App: React.FC = () => {
const location = useLocation();
const nodeRef = useRef<HTMLDivElement>(null);
const demoInfo = useDemoInfo();
// Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs
const pageKey = React.useMemo(() => {
@ -31,28 +33,36 @@ export const App: React.FC = () => {
return (
<>
<OnboardingGate />
<SwitchTransition mode="out-in">
<CSSTransition
key={pageKey}
nodeRef={nodeRef}
timeout={100}
classNames="page"
unmountOnExit
>
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/home" element={<HomePage />} />
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</div>
</CSSTransition>
</SwitchTransition>
{demoInfo?.demoMode && (
<div className="fixed inset-x-0 top-0 z-[2147483647] border-b border-amber-400/50 bg-amber-500/20 px-4 py-2 text-center text-xs text-amber-100 backdrop-blur">
Demo mode: integrations are simulated and data resets every{" "}
{demoInfo.resetCadenceHours} hours.
</div>
)}
<div>
<SwitchTransition mode="out-in">
<CSSTransition
key={pageKey}
nodeRef={nodeRef}
timeout={100}
classNames="page"
unmountOnExit
>
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/home" element={<HomePage />} />
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</div>
</CSSTransition>
</SwitchTransition>
</div>
<Toaster position="bottom-right" richColors closeButton />
</>

View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "./client";
const customToast = vi.fn();
vi.mock("sonner", () => ({
toast: {
custom: (...args: unknown[]) => customToast(...args),
},
}));
describe("API client demo toasts", () => {
beforeEach(() => {
customToast.mockClear();
vi.restoreAllMocks();
});
it("shows simulated toast when response meta.simulated is true", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
status: 200,
text: async () =>
JSON.stringify({
ok: true,
data: { message: "ok" },
meta: { requestId: "req-1", simulated: true },
}),
} as Response);
await api.runPipeline();
expect(customToast).toHaveBeenCalledTimes(1);
});
it("shows blocked toast when response meta.blockedReason is present", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
status: 403,
text: async () =>
JSON.stringify({
ok: false,
error: { code: "FORBIDDEN", message: "Blocked" },
meta: { requestId: "req-2", blockedReason: "Disabled in demo" },
}),
} as Response);
await expect(
api.updateSettings({ llmProvider: "openrouter" }),
).rejects.toThrow("Blocked");
expect(customToast).toHaveBeenCalledTimes(1);
});
});

View File

@ -9,6 +9,7 @@ import type {
AppSettings,
BackupInfo,
CreateJobInput,
DemoInfoResponse,
Job,
JobOutcome,
JobSource,
@ -32,6 +33,7 @@ import type {
VisaSponsorStatusResponse,
} from "@shared/types";
import { trackEvent } from "@/lib/analytics";
import { showDemoBlockedToast, showDemoSimulatedToast } from "@/lib/demo-toast";
const API_BASE = "/api";
@ -74,6 +76,33 @@ function normalizeApiResponse<T>(
throw new ApiClientError("API request failed: unexpected response shape");
}
function describeAction(endpoint: string, method?: string): string {
const verb = (method || "GET").toUpperCase();
const normalized = endpoint.split("?")[0] || endpoint;
if (verb === "POST" && normalized === "/pipeline/run") {
return "Pipeline run used demo simulation.";
}
if (verb === "POST" && normalized.endsWith("/process")) {
return "Job processing used demo simulation.";
}
if (verb === "POST" && normalized.endsWith("/summarize")) {
return "Summary generation used demo simulation.";
}
if (verb === "POST" && normalized.endsWith("/generate-pdf")) {
return "PDF generation used demo simulation.";
}
if (verb === "POST" && normalized.endsWith("/rescore")) {
return "Suitability rescoring used demo simulation.";
}
if (verb === "POST" && normalized.endsWith("/apply")) {
return "Apply flow used demo simulation and no external sync.";
}
if (normalized.startsWith("/onboarding/validate")) {
return "Credential validation is simulated in demo mode.";
}
return "This action ran in demo simulation mode.";
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit,
@ -102,11 +131,17 @@ async function fetchApi<T>(
if ("ok" in parsed) {
if (!parsed.ok) {
if (parsed.meta?.blockedReason) {
showDemoBlockedToast(parsed.meta.blockedReason);
}
throw new ApiClientError(
parsed.error.message || "API request failed",
parsed.meta?.requestId,
);
}
if (parsed.meta?.simulated) {
showDemoSimulatedToast(describeAction(endpoint, options?.method));
}
return parsed.data as T;
}
@ -273,6 +308,10 @@ export async function runPipeline(config?: {
});
}
export async function getDemoInfo(): Promise<DemoInfoResponse> {
return fetchApi<DemoInfoResponse>("/demo/info");
}
// UK Visa Jobs API
export async function searchUkVisaJobs(input: {
searchTerm?: string;

View File

@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingGate } from "./OnboardingGate";
vi.mock("@client/api", () => ({
getDemoInfo: vi.fn(),
validateLlm: vi.fn(),
validateRxresume: vi.fn(),
validateResumeConfig: vi.fn(),
@ -101,6 +102,14 @@ const settingsResponse = {
describe("OnboardingGate", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getDemoInfo).mockResolvedValue({
demoMode: false,
resetCadenceHours: 6,
lastResetAt: null,
nextResetAt: null,
baselineVersion: null,
baselineName: null,
});
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
});
@ -121,7 +130,9 @@ describe("OnboardingGate", () => {
render(<OnboardingGate />);
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
});
it("hides the gate when all validations succeed", async () => {

View File

@ -1,4 +1,5 @@
import * as api from "@client/api";
import { useDemoInfo } from "@client/hooks/useDemoInfo";
import { useSettings } from "@client/hooks/useSettings";
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
@ -82,6 +83,8 @@ export const OnboardingGate: React.FC = () => {
checked: false,
});
const [currentStep, setCurrentStep] = useState<string | null>(null);
const demoInfo = useDemoInfo();
const demoMode = demoInfo?.demoMode ?? false;
const { control, watch, getValues, reset, setValue } =
useForm<OnboardingFormData>({
@ -190,6 +193,7 @@ export const OnboardingGate: React.FC = () => {
baseResumeValidation.checked;
const llmValidated = requiresLlmKey ? llmValidation.valid : true;
const shouldOpen =
!demoMode &&
Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
@ -294,6 +298,7 @@ export const OnboardingGate: React.FC = () => {
// Run validations on mount when needed
useEffect(() => {
if (demoMode) return;
if (!settings || settingsLoading) return;
const needsValidation =
(requiresLlmKey ? !llmValidation.checked : false) ||
@ -309,6 +314,7 @@ export const OnboardingGate: React.FC = () => {
rxresumeValidation.checked,
baseResumeValidation.checked,
runAllValidations,
demoMode,
]);
const handleRefresh = async () => {

View File

@ -0,0 +1,30 @@
import * as api from "@client/api";
import type { DemoInfoResponse } from "@shared/types";
import { useEffect, useState } from "react";
export function useDemoInfo() {
const [demoInfo, setDemoInfo] = useState<DemoInfoResponse | null>(null);
useEffect(() => {
let isCancelled = false;
void api
.getDemoInfo()
.then((info) => {
if (!isCancelled) {
setDemoInfo(info);
}
})
.catch(() => {
if (!isCancelled) {
setDemoInfo(null);
}
});
return () => {
isCancelled = true;
};
}, []);
return demoInfo;
}

View File

@ -1,4 +1,4 @@
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { _resetSettingsCache, useSettings } from "./useSettings";
@ -55,12 +55,15 @@ describe("useSettings", () => {
});
let refreshed: any;
await waitFor(async () => {
await act(async () => {
refreshed = await result.current.refreshSettings();
});
await waitFor(() => {
expect(result.current.settings).toEqual(updatedSettings);
});
expect(refreshed).toEqual(updatedSettings);
expect(result.current.settings).toEqual(updatedSettings);
expect(result.current.showSponsorInfo).toBe(false);
});

View File

@ -0,0 +1,51 @@
import { FlaskConical, ShieldBan } from "lucide-react";
import type React from "react";
import { toast } from "sonner";
function DemoToastCard({
title,
description,
icon,
}: {
title: string;
description: string;
icon: React.ReactNode;
}) {
return (
<div className="pointer-events-auto flex w-[360px] items-start gap-3 rounded-lg border border-amber-300/70 bg-gradient-to-br from-amber-200/95 via-amber-300/95 to-amber-400/95 p-3 text-amber-950 shadow-[0_8px_24px_rgba(245,158,11,0.35)]">
<div className="mt-0.5 text-amber-900">{icon}</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-tight text-amber-950">
{title}
</p>
<p className="text-xs text-amber-900/90">{description}</p>
</div>
</div>
);
}
export function showDemoSimulatedToast(description: string): void {
toast.custom(
() => (
<DemoToastCard
title="Simulated in Demo Mode"
description={description}
icon={<FlaskConical className="h-4 w-4" />}
/>
),
{ duration: 3600 },
);
}
export function showDemoBlockedToast(description: string): void {
toast.custom(
() => (
<DemoToastCard
title="Blocked in Public Demo"
description={description}
icon={<ShieldBan className="h-4 w-4" />}
/>
),
{ duration: 4200 },
);
}

View File

@ -5,6 +5,7 @@
import { Router } from "express";
import { backupRouter } from "./routes/backup";
import { databaseRouter } from "./routes/database";
import { demoRouter } from "./routes/demo";
import { jobsRouter } from "./routes/jobs";
import { manualJobsRouter } from "./routes/manual-jobs";
import { onboardingRouter } from "./routes/onboarding";
@ -18,6 +19,7 @@ import { webhookRouter } from "./routes/webhook";
export const apiRouter = Router();
apiRouter.use("/jobs", jobsRouter);
apiRouter.use("/demo", demoRouter);
apiRouter.use("/settings", settingsRouter);
apiRouter.use("/pipeline", pipelineRouter);
apiRouter.use("/manual-jobs", manualJobsRouter);

View File

@ -6,6 +6,7 @@ import {
listBackups,
} from "@server/services/backup/index";
import { type Request, type Response, Router } from "express";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
export const backupRouter = Router();
@ -36,6 +37,14 @@ backupRouter.get("/", async (_req: Request, res: Response) => {
*/
backupRouter.post("/", async (_req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Manual backup creation is disabled in the public demo.",
{ route: "POST /api/backups" },
);
}
const filename = await createBackup("manual");
const backups = await listBackups();
const backup = backups.find((b) => b.filename === filename);
@ -60,6 +69,17 @@ backupRouter.post("/", async (_req: Request, res: Response) => {
*/
backupRouter.delete("/:filename", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Deleting backups is disabled in the public demo.",
{
route: "DELETE /api/backups/:filename",
filename: req.params.filename,
},
);
}
const { filename } = req.params;
if (!filename) {

View File

@ -1,4 +1,5 @@
import { type Request, type Response, Router } from "express";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
import { clearDatabase } from "../../db/clear";
export const databaseRouter = Router();
@ -8,6 +9,14 @@ export const databaseRouter = Router();
*/
databaseRouter.delete("/", async (_req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Clearing the database is disabled in the public demo.",
{ route: "DELETE /api/database" },
);
}
const result = clearDatabase();
res.json({

View File

@ -0,0 +1,132 @@
import {
DEMO_BASELINE_NAME,
DEMO_BASELINE_VERSION,
} from "@server/config/demo-defaults";
import { describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils";
describe.sequential("Demo mode API behavior", () => {
it("returns demo info when demo mode is disabled", async () => {
const { server, baseUrl, closeDb, tempDir } = await startServer();
try {
const response = await fetch(`${baseUrl}/api/demo/info`);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(body.data.demoMode).toBe(false);
expect(body.data.resetCadenceHours).toBe(6);
expect(body.data.baselineVersion).toBe(null);
expect(body.data.baselineName).toBe(null);
} finally {
await stopServer({ server, closeDb, tempDir });
}
});
it("returns demo info when demo mode is enabled", async () => {
const { server, baseUrl, closeDb, tempDir } = await startServer({
env: {
DEMO_MODE: "true",
BASIC_AUTH_USER: "",
BASIC_AUTH_PASSWORD: "",
},
});
try {
const response = await fetch(`${baseUrl}/api/demo/info`);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(body.data.demoMode).toBe(true);
expect(body.data.resetCadenceHours).toBe(6);
expect(body.data.baselineVersion).toBe(DEMO_BASELINE_VERSION);
expect(body.data.baselineName).toBe(DEMO_BASELINE_NAME);
} finally {
await stopServer({ server, closeDb, tempDir });
}
});
it("simulates pipeline runs in demo mode", async () => {
const { server, baseUrl, closeDb, tempDir } = await startServer({
env: {
DEMO_MODE: "true",
BASIC_AUTH_USER: "",
BASIC_AUTH_PASSWORD: "",
},
});
try {
const response = await fetch(`${baseUrl}/api/pipeline/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sources: ["linkedin"] }),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(body.meta?.simulated).toBe(true);
expect(body.data.message).toContain("simulated");
} finally {
await stopServer({ server, closeDb, tempDir });
}
});
it("blocks settings writes in demo mode with blocked reason metadata", async () => {
const { server, baseUrl, closeDb, tempDir } = await startServer({
env: {
DEMO_MODE: "true",
BASIC_AUTH_USER: "",
BASIC_AUTH_PASSWORD: "",
},
});
try {
const response = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ llmProvider: "openrouter" }),
});
expect(response.status).toBe(403);
const body = await response.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("FORBIDDEN");
expect(typeof body.meta?.blockedReason).toBe("string");
expect(body.meta?.blockedReason).toContain("disabled");
} finally {
await stopServer({ server, closeDb, tempDir });
}
});
it("simulates apply and does not call Notion in demo mode", async () => {
const { server, baseUrl, closeDb, tempDir } = await startServer({
env: { DEMO_MODE: "true" },
});
try {
const imported = await fetch(`${baseUrl}/api/manual-jobs/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
job: {
title: "Demo Imported Job",
employer: "Demo Corp",
jobDescription: "Demo description",
jobUrl: "https://demo.job-ops.local/jobs/imported",
},
}),
});
const importedBody = await imported.json();
expect(importedBody.ok).toBe(true);
const jobId = importedBody.data.id as string;
const response = await fetch(`${baseUrl}/api/jobs/${jobId}/apply`, {
method: "POST",
});
const body = await response.json();
expect(response.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.meta?.simulated).toBe(true);
expect(body.data.status).toBe("applied");
expect(String(body.data.notionPageId)).toMatch(/^demo-notion-/);
} finally {
await stopServer({ server, closeDb, tempDir });
}
});
});

View File

@ -0,0 +1,9 @@
import { ok } from "@infra/http";
import { type Request, type Response, Router } from "express";
import { getDemoInfo } from "../../config/demo";
export const demoRouter = Router();
demoRouter.get("/info", (_req: Request, res: Response) => {
ok(res, getDemoInfo());
});

View File

@ -1,3 +1,4 @@
import { okWithMeta } from "@infra/http";
import { logger } from "@infra/logger";
import { sanitizeWebhookPayload } from "@infra/sanitize";
import {
@ -10,6 +11,7 @@ import {
} from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
import {
generateFinalPdf,
processJob,
@ -25,6 +27,13 @@ import {
transitionStage,
updateStageEvent,
} from "../../services/applicationTracking";
import {
simulateApplyJob,
simulateGeneratePdf,
simulateProcessJob,
simulateRescoreJob,
simulateSummarizeJob,
} from "../../services/demo-simulator";
import { createNotionEntry } from "../../services/notion";
import { getProfile } from "../../services/profile";
import { scoreJobSuitability } from "../../services/scorer";
@ -316,6 +325,18 @@ jobsRouter.post("/:id/summarize", async (req: Request, res: Response) => {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === "1" || forceRaw === "true";
if (isDemoMode()) {
const result = await simulateSummarizeJob(req.params.id, { force });
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: "Job not found" });
}
return okWithMeta(res, job, { simulated: true });
}
const result = await summarizeJob(req.params.id, { force });
if (!result.success) {
@ -335,6 +356,11 @@ jobsRouter.post("/:id/summarize", async (req: Request, res: Response) => {
*/
jobsRouter.post("/:id/rescore", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
const simulatedJob = await simulateRescoreJob(req.params.id);
return okWithMeta(res, simulatedJob, { simulated: true });
}
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
@ -424,6 +450,18 @@ jobsRouter.post("/:id/check-sponsor", async (req: Request, res: Response) => {
*/
jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
const result = await simulateGeneratePdf(req.params.id);
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: "Job not found" });
}
return okWithMeta(res, job, { simulated: true });
}
const result = await generateFinalPdf(req.params.id);
if (!result.success) {
@ -446,6 +484,18 @@ jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === "1" || forceRaw === "true";
if (isDemoMode()) {
const result = await simulateProcessJob(req.params.id, { force });
if (!result.success) {
return res.status(400).json({ success: false, error: result.error });
}
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: "Job not found" });
}
return okWithMeta(res, job, { simulated: true });
}
const result = await processJob(req.params.id, { force });
if (!result.success) {
@ -465,6 +515,11 @@ jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
*/
jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
const updatedJob = await simulateApplyJob(req.params.id);
return okWithMeta(res, updatedJob, { simulated: true });
}
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
@ -545,6 +600,14 @@ jobsRouter.post("/:id/skip", async (req: Request, res: Response) => {
*/
jobsRouter.delete("/status/:status", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Clearing jobs by status is disabled to keep the demo stable.",
{ route: "DELETE /api/jobs/status/:status", status: req.params.status },
);
}
const status = req.params.status as JobStatus;
const count = await jobsRepo.deleteJobsByStatus(status);

View File

@ -1,3 +1,4 @@
import { okWithMeta } from "@infra/http";
import { getSetting } from "@server/repositories/settings";
import { LlmService } from "@server/services/llm-service";
import { RxResumeClient } from "@server/services/rxresume-client";
@ -7,6 +8,7 @@ import {
} from "@server/services/rxresume-v4";
import { resumeDataSchema } from "@shared/rxresume-schema";
import { type Request, type Response, Router } from "express";
import { isDemoMode } from "../../config/demo";
export const onboardingRouter = Router();
@ -124,6 +126,18 @@ async function validateRxresume(
onboardingRouter.post(
"/validate/openrouter",
async (req: Request, res: Response) => {
if (isDemoMode()) {
return okWithMeta(
res,
{
valid: true,
message:
"Demo mode: OpenRouter validation is simulated and always succeeds.",
},
{ simulated: true },
);
}
const apiKey =
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
const result = await validateLlm({ apiKey, provider: "openrouter" });
@ -132,6 +146,17 @@ onboardingRouter.post(
);
onboardingRouter.post("/validate/llm", async (req: Request, res: Response) => {
if (isDemoMode()) {
return okWithMeta(
res,
{
valid: true,
message: "Demo mode: LLM validation is simulated.",
},
{ simulated: true },
);
}
const apiKey =
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
const provider =
@ -145,6 +170,17 @@ onboardingRouter.post("/validate/llm", async (req: Request, res: Response) => {
onboardingRouter.post(
"/validate/rxresume",
async (req: Request, res: Response) => {
if (isDemoMode()) {
return okWithMeta(
res,
{
valid: true,
message: "Demo mode: RxResume validation is simulated.",
},
{ simulated: true },
);
}
const email =
typeof req.body?.email === "string" ? req.body.email : undefined;
const password =
@ -157,6 +193,17 @@ onboardingRouter.post(
onboardingRouter.get(
"/validate/resume",
async (_req: Request, res: Response) => {
if (isDemoMode()) {
return okWithMeta(
res,
{
valid: true,
message: "Demo mode: resume validation is simulated.",
},
{ simulated: true },
);
}
const result = await validateResumeConfig();
res.json({ success: true, data: result });
},

View File

@ -1,14 +1,17 @@
import { okWithMeta } from "@infra/http";
import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import type { ApiResponse, PipelineStatusResponse } from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import { isDemoMode } from "../../config/demo";
import {
getPipelineStatus,
runPipeline,
subscribeToProgress,
} from "../../pipeline/index";
import * as pipelineRepo from "../../repositories/pipeline";
import { simulatePipelineRun } from "../../services/demo-simulator";
export const pipelineRouter = Router();
@ -99,6 +102,11 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
try {
const config = runPipelineSchema.parse(req.body);
if (isDemoMode()) {
const simulated = await simulatePipelineRun(config);
return okWithMeta(res, simulated, { simulated: true });
}
// Start pipeline in background
runWithRequestContext({}, () => {
runPipeline(config).catch((error) => {

View File

@ -100,6 +100,35 @@ describe.sequential("Profile API routes", () => {
expect(body.ok).toBe(false);
expect(body.error.message).toContain("Base resume not configured");
});
it("returns demo project catalog in demo mode", async () => {
const demoServer = await startServer({
env: {
DEMO_MODE: "true",
BASIC_AUTH_USER: "",
BASIC_AUTH_PASSWORD: "",
},
});
try {
vi.mocked(getProfile).mockRejectedValue(
new Error("should not be used"),
);
const res = await fetch(`${demoServer.baseUrl}/api/profile/projects`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBeGreaterThan(0);
expect(body.data[0]).toMatchObject({
id: expect.any(String),
name: expect.any(String),
});
} finally {
await stopServer(demoServer);
}
});
});
describe("GET /api/profile", () => {

View File

@ -1,4 +1,6 @@
import { type Request, type Response, Router } from "express";
import { isDemoMode } from "../../config/demo";
import { DEMO_PROJECT_CATALOG } from "../../config/demo-defaults";
import { getSetting } from "../../repositories/settings";
import { clearProfileCache, getProfile } from "../../services/profile";
import { extractProjectsFromProfile } from "../../services/resumeProjects";
@ -14,6 +16,10 @@ export const profileRouter = Router();
*/
profileRouter.get("/projects", async (_req: Request, res: Response) => {
try {
if (isDemoMode()) {
res.json({ success: true, data: DEMO_PROJECT_CATALOG });
return;
}
const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: catalog });

View File

@ -10,6 +10,7 @@ import { getEffectiveSettings } from "@server/services/settings";
import { applySettingsUpdates } from "@server/services/settings-update";
import { updateSettingsSchema } from "@shared/settings-schema";
import { type Request, type Response, Router } from "express";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
export const settingsRouter = Router();
@ -31,6 +32,14 @@ settingsRouter.get("/", async (_req: Request, res: Response) => {
*/
settingsRouter.patch("/", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Saving settings is disabled in the public demo.",
{ route: "PATCH /api/settings" },
);
}
const input = updateSettingsSchema.parse(req.body);
const plan = await applySettingsUpdates(input);

View File

@ -34,6 +34,9 @@ vi.mock("../../pipeline/index", () => {
listener(progress);
return () => {};
}),
progressHelpers: {
complete: vi.fn(),
},
};
});

View File

@ -32,4 +32,37 @@ describe.sequential("Webhook API routes", () => {
expect(goodBody.ok).toBe(true);
expect(goodBody.data.message).toBe("Pipeline triggered");
});
it("enforces webhook auth in demo mode when a secret is configured", async () => {
const demoServer = await startServer({
env: {
DEMO_MODE: "true",
WEBHOOK_SECRET: "secret",
},
});
try {
const unauthorizedRes = await fetch(
`${demoServer.baseUrl}/api/webhook/trigger`,
{
method: "POST",
},
);
expect(unauthorizedRes.status).toBe(401);
const authorizedRes = await fetch(
`${demoServer.baseUrl}/api/webhook/trigger`,
{
method: "POST",
headers: { Authorization: "Bearer secret" },
},
);
expect(authorizedRes.status).toBe(200);
const authorizedBody = await authorizedRes.json();
expect(authorizedBody.ok).toBe(true);
expect(authorizedBody.meta.simulated).toBe(true);
} finally {
await stopServer(demoServer);
}
});
});

View File

@ -1,7 +1,11 @@
import { unauthorized } from "@infra/errors";
import { fail, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import { type Request, type Response, Router } from "express";
import { isDemoMode } from "../../config/demo";
import { runPipeline } from "../../pipeline/index";
import { simulatePipelineRun } from "../../services/demo-simulator";
export const webhookRouter = Router();
@ -9,18 +13,27 @@ export const webhookRouter = Router();
* POST /api/webhook/trigger - Webhook endpoint for n8n to trigger the pipeline
*/
webhookRouter.post("/trigger", async (req: Request, res: Response) => {
// Optional: Add authentication check
const authHeader = req.headers.authorization;
const expectedToken = process.env.WEBHOOK_SECRET;
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
return res.status(401).json({
ok: false,
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
});
return fail(res, unauthorized());
}
try {
if (isDemoMode()) {
const simulated = await simulatePipelineRun();
return okWithMeta(
res,
{
message: "Pipeline trigger simulated in demo mode",
triggeredAt: new Date().toISOString(),
runId: simulated.runId,
},
{ simulated: true },
);
}
// Start pipeline in background
runWithRequestContext({}, () => {
runPipeline().catch((error) => {

View File

@ -0,0 +1,732 @@
import type { SettingKey } from "@server/repositories/settings";
import type {
ApplicationStage,
JobSource,
JobStatus,
ResumeProjectCatalogItem,
StageEventMetadata,
} from "@shared/types";
export const DEMO_BASELINE_VERSION = "2026.02.05.v3";
export const DEMO_BASELINE_NAME = "Public Demo Baseline";
export type DemoDefaultSettings = Partial<Record<SettingKey, string>>;
export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
llmProvider: "openrouter",
model: "google/gemini-3-flash-preview",
searchTerms: JSON.stringify([
"software engineer",
"backend engineer",
"full stack engineer",
]),
showSponsorInfo: "1",
backupEnabled: "0",
backupHour: "2",
backupMaxCount: "5",
jobspyLocation: "United States",
jobspyResultsWanted: "25",
jobspyHoursOld: "72",
jobspyCountryIndeed: "US",
jobspySites: JSON.stringify(["linkedin", "indeed"]),
jobspyLinkedinFetchDescription: "1",
jobspyIsRemote: "0",
resumeProjects: JSON.stringify({
maxProjects: 3,
lockedProjectIds: ["demo-project-1"],
aiSelectableProjectIds: [
"demo-project-2",
"demo-project-3",
"demo-project-4",
"demo-project-5",
],
}),
};
export const DEMO_PROJECT_CATALOG: ResumeProjectCatalogItem[] = [
{
id: "demo-project-1",
name: "Distributed Event Pipeline",
description:
"Built a Kafka + Node.js ingestion pipeline with replay, backfill, and SLA-based alerting.",
date: "2025",
isVisibleInBase: true,
},
{
id: "demo-project-2",
name: "ATS Workflow Automator",
description:
"Automated job ingestion, ranking, and status sync with retries and idempotent transitions.",
date: "2024",
isVisibleInBase: false,
},
{
id: "demo-project-3",
name: "Resume Tailoring Engine",
description:
"Generated role-specific summaries and skill emphasis from job requirements using typed prompts.",
date: "2024",
isVisibleInBase: false,
},
{
id: "demo-project-4",
name: "Observability Dashboard",
description:
"Implemented request tracing, structured logs, and SLO-driven dashboards for pipeline health.",
date: "2023",
isVisibleInBase: false,
},
{
id: "demo-project-5",
name: "Sponsor Match Index",
description:
"Shipped a fuzzy-match sponsor index with explainable scores and cached lookup acceleration.",
date: "2023",
isVisibleInBase: false,
},
];
export interface DemoDefaultPipelineRun {
id: string;
status: "completed" | "failed";
startedOffsetMinutes: number;
completedOffsetMinutes: number;
jobsDiscovered: number;
jobsProcessed: number;
errorMessage?: string;
}
export const DEMO_DEFAULT_PIPELINE_RUNS: DemoDefaultPipelineRun[] = [
{
id: "demo-run-1",
status: "completed",
startedOffsetMinutes: 2400,
completedOffsetMinutes: 2360,
jobsDiscovered: 38,
jobsProcessed: 18,
},
{
id: "demo-run-2",
status: "completed",
startedOffsetMinutes: 1920,
completedOffsetMinutes: 1880,
jobsDiscovered: 31,
jobsProcessed: 16,
},
{
id: "demo-run-3",
status: "failed",
startedOffsetMinutes: 1320,
completedOffsetMinutes: 1290,
jobsDiscovered: 12,
jobsProcessed: 5,
errorMessage: "Rate-limited by upstream source; resumed on next run.",
},
{
id: "demo-run-4",
status: "completed",
startedOffsetMinutes: 780,
completedOffsetMinutes: 740,
jobsDiscovered: 29,
jobsProcessed: 14,
},
{
id: "demo-run-5",
status: "completed",
startedOffsetMinutes: 260,
completedOffsetMinutes: 220,
jobsDiscovered: 26,
jobsProcessed: 11,
},
];
export const COMPANY_PREFIXES = [
"Acme",
"Apex",
"Arbor",
"Atlas",
"Aurora",
"Beacon",
"Bluebird",
"Bright",
"Cascade",
"Cedar",
"Cobalt",
"Crescent",
"Crown",
"Crystal",
"Delta",
"Driftwood",
"Eagle",
"Element",
"Evergreen",
"Fable",
"Falcon",
"Fjord",
"Forge",
"Frontier",
"Fusion",
"Glacier",
"Golden",
"Granite",
"Harbor",
"Helix",
"Horizon",
"Indigo",
"Ironwood",
"Juniper",
"Keystone",
"Lighthouse",
"Maple",
"Meridian",
"Monarch",
"Mosaic",
"Nimbus",
"Northstar",
"Nova",
"Oakstone",
"Onyx",
"Orchard",
"Orbit",
"Palisade",
"Pioneer",
"Praxus",
"Quantum",
"Quarry",
"Radiant",
"Redwood",
"Ridge",
"Riverstone",
"Saffron",
"Sapphire",
"Sequoia",
"Silver",
"Solstice",
"Summit",
"Sunstone",
"Terra",
"Timber",
"Topaz",
"Trident",
"Unity",
"Valley",
"Vanguard",
"Vertex",
"Willow",
"Windward",
"Zenith",
] as const;
export const COMPANY_SUFFIXES = [
"Labs",
"Systems",
"Technologies",
"Solutions",
"Group",
"Holdings",
"Partners",
"Enterprises",
"Industries",
"Works",
"Networks",
"Dynamics",
"Logistics",
"Ventures",
"Analytics",
"Capital",
"Software",
"Consulting",
"Research",
"Manufacturing",
"Energy",
"Health",
"Financial",
"Media",
"Security",
"Foods",
"Pharma",
"Robotics",
"Aerospace",
"Telecom",
] as const;
export const DEMO_SOURCE_BASE_URLS: Record<JobSource, string> = {
linkedin: "https://www.linkedin.com",
indeed: "https://www.indeed.com",
gradcracker: "https://www.gradcracker.com",
ukvisajobs: "https://www.ukvisajobs.com",
manual: "https://example.com",
};
export interface DemoDefaultJob {
id: string;
source: JobSource;
title: string;
employer: string;
jobUrl: string;
applicationLink: string;
location: string;
salary: string;
deadline: string;
jobDescription: string;
status: JobStatus;
discoveredOffsetMinutes: number;
suitabilityScore: number;
suitabilityReason: string;
tailoredSummary?: string;
tailoredHeadline?: string;
tailoredSkills?: string[];
selectedProjectIds?: string;
pdfPath?: string;
notionPageId?: string;
appliedOffsetMinutes?: number;
}
export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
{
id: "demo-job-ready-1",
source: "linkedin",
title: "Software Engineer (Platform)",
employer: "NovaStack",
jobUrl: "https://www.linkedin.com",
applicationLink: "https://www.linkedin.com",
location: "Remote (US)",
salary: "$130,000 - $155,000",
deadline: "2026-03-15",
jobDescription:
"Build backend platform services for workflow orchestration, async job processing, and tenant-safe API integrations. You will own reliability patterns, improve queue throughput, and drive production observability.",
status: "ready",
discoveredOffsetMinutes: 1100,
suitabilityScore: 84,
suitabilityReason:
"Strong fit for backend platform scope: direct overlap in TypeScript services, async orchestration, and production observability. Minor gap is deep Kubernetes networking, but overall impact/ownership expectations align well.",
tailoredSummary:
"Backend-focused engineer with a track record of shipping resilient TypeScript services, improving queue-driven processing latency, and hardening production systems with structured tracing and clear SLOs.",
tailoredHeadline: "Software Engineer focused on platform reliability",
tailoredSkills: ["TypeScript", "Node.js", "Kafka", "Observability"],
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-2",
pdfPath: "/pdfs/demo-job-ready-1.pdf",
},
{
id: "demo-job-ready-2",
source: "indeed",
title: "Backend Engineer (Integrations)",
employer: "SignalForge",
jobUrl: "https://www.indeed.com",
applicationLink: "https://www.indeed.com",
location: "Chicago, IL",
salary: "$125,000 - $148,000",
deadline: "2026-03-18",
jobDescription:
"Design integration services with strict API contracts, webhook safety, and robust retry semantics. Partner with product to convert integration requirements into maintainable service boundaries.",
status: "ready",
discoveredOffsetMinutes: 940,
suitabilityScore: 82,
suitabilityReason:
"Good systems fit with strong contract discipline and webhook experience. Domain expectations around idempotent retries and payload sanitization match prior delivery history.",
tailoredSummary:
"Integration-minded backend engineer who builds reliable API surfaces, enforces request contracts, and protects systems with structured logging and redaction-first payload handling.",
tailoredHeadline: "Backend Engineer for API and webhook integrations",
tailoredSkills: ["API Design", "Webhooks", "Reliability", "TypeScript"],
selectedProjectIds: "demo-project-2,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-ready-2.pdf",
},
{
id: "demo-job-ready-3",
source: "manual",
title: "Senior Full-Stack Engineer",
employer: "Northstar Health",
jobUrl: "https://example.com",
applicationLink: "https://example.com",
location: "Remote (US)",
salary: "$145,000 - $170,000",
deadline: "2026-03-11",
jobDescription:
"Lead implementation of internal tools across React frontends and Node services. Improve operator workflows, reduce manual effort, and ship measurable productivity gains for operations teams.",
status: "ready",
discoveredOffsetMinutes: 760,
suitabilityScore: 79,
suitabilityReason:
"Solid match on full-stack delivery and internal tooling outcomes. Strong evidence of reducing operational toil through productized workflows; moderate gap on healthcare domain specifics.",
tailoredSummary:
"Product-oriented full-stack engineer who translates operations pain points into maintainable React + Node workflows, with an emphasis on speed, clarity, and measurable automation impact.",
tailoredHeadline: "Senior Full-Stack Engineer for internal platforms",
tailoredSkills: ["React", "TypeScript", "UX Systems", "Node.js"],
selectedProjectIds: "demo-project-2,demo-project-3,demo-project-4",
pdfPath: "/pdfs/demo-job-ready-3.pdf",
},
{
id: "demo-job-discovered-1",
source: "indeed",
title: "Backend Engineer",
employer: "Acme Data Systems",
jobUrl: "https://www.indeed.com",
applicationLink: "https://www.indeed.com",
location: "Austin, TX",
salary: "$120,000 - $145,000",
deadline: "2026-03-10",
jobDescription:
"Own backend APIs and data pipelines supporting analytics products. Work across schema evolution, endpoint performance, and production support rotations.",
status: "discovered",
discoveredOffsetMinutes: 640,
suitabilityScore: 72,
suitabilityReason:
"Balanced fit for backend API ownership and data-heavy workloads. Meets core technical baseline; impact would improve with more recent analytics product depth.",
},
{
id: "demo-job-discovered-2",
source: "gradcracker",
title: "Graduate Software Developer",
employer: "Orbital Labs",
jobUrl: "https://www.gradcracker.com",
applicationLink: "https://www.gradcracker.com",
location: "London, UK",
salary: "GBP 42,000",
deadline: "2026-03-20",
jobDescription:
"Join a rotational engineering cohort focused on backend services, deployment tooling, and CI/CD quality practices. Mentorship and growth path are core to the role.",
status: "discovered",
discoveredOffsetMinutes: 420,
suitabilityScore: 74,
suitabilityReason:
"Strong foundational fit for mentorship-heavy backend track with good fundamentals in delivery workflows and testing discipline.",
},
{
id: "demo-job-discovered-3",
source: "linkedin",
title: "Platform Reliability Engineer",
employer: "VectorScale",
jobUrl: "https://www.linkedin.com",
applicationLink: "https://www.linkedin.com",
location: "Seattle, WA",
salary: "$150,000 - $180,000",
deadline: "2026-03-28",
jobDescription:
"Drive production reliability for customer-facing APIs. Build SLO dashboards, incident playbooks, and reliability automation to reduce mean time to recovery.",
status: "discovered",
discoveredOffsetMinutes: 300,
suitabilityScore: 81,
suitabilityReason:
"Very strong reliability and observability alignment with clear evidence of incident response rigor and production hardening ownership.",
},
{
id: "demo-job-discovered-4",
source: "ukvisajobs",
title: "Software Engineer (Visa Sponsorship)",
employer: "BluePeak Commerce",
jobUrl: "https://www.ukvisajobs.com",
applicationLink: "https://www.ukvisajobs.com",
location: "Birmingham, UK",
salary: "GBP 60,000",
deadline: "2026-03-24",
jobDescription:
"Build commerce backend features including checkout services, inventory sync, and operational dashboards. Sponsorship available for eligible candidates.",
status: "discovered",
discoveredOffsetMinutes: 180,
suitabilityScore: 70,
suitabilityReason:
"Good backend feature-delivery fit with practical systems experience. Sponsor-friendly listing increases viability despite limited direct commerce background.",
},
{
id: "demo-job-applied-1",
source: "manual",
title: "Senior TypeScript Engineer",
employer: "BrightScale",
jobUrl: "https://example.com",
applicationLink: "https://example.com",
location: "New York, NY",
salary: "$155,000 - $180,000",
deadline: "2026-03-08",
jobDescription:
"Lead architecture of high-throughput TypeScript services powering customer automations. Mentor engineers and own service quality, incident response, and scalability planning.",
status: "applied",
discoveredOffsetMinutes: 5600,
appliedOffsetMinutes: 5040,
suitabilityScore: 88,
suitabilityReason:
"Excellent fit across senior ownership, service architecture, and TypeScript depth. Prior impact in scaling queue-backed systems directly matches role expectations.",
tailoredSummary:
"Senior backend engineer experienced in scaling TypeScript platforms, reducing failure rates through resilient service design, and mentoring teams through architecture-critical initiatives.",
tailoredHeadline: "Senior TypeScript Engineer for scalable services",
tailoredSkills: ["TypeScript", "Architecture", "Mentorship", "SRE"],
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-applied-1.pdf",
notionPageId: "demo-notion-applied-1",
},
{
id: "demo-job-applied-2",
source: "linkedin",
title: "Backend Engineer (Data Platform)",
employer: "QuantaLedger",
jobUrl: "https://www.linkedin.com",
applicationLink: "https://www.linkedin.com",
location: "Remote (US)",
salary: "$140,000 - $165,000",
deadline: "2026-03-06",
jobDescription:
"Develop core data platform capabilities: ingestion validation, metric freshness guarantees, and internal APIs for downstream analytics consumers.",
status: "applied",
discoveredOffsetMinutes: 4300,
appliedOffsetMinutes: 3800,
suitabilityScore: 86,
suitabilityReason:
"Strong fit for data-platform backend development with proven work in ingestion reliability and observable data flow guarantees.",
tailoredSummary:
"Backend engineer with practical data-pipeline ownership, focused on consistency checks, downstream contract safety, and production-grade diagnostics.",
tailoredHeadline: "Backend Engineer for data reliability systems",
tailoredSkills: ["Data Pipelines", "TypeScript", "SQL", "Observability"],
selectedProjectIds: "demo-project-1,demo-project-2,demo-project-4",
pdfPath: "/pdfs/demo-job-applied-2.pdf",
notionPageId: "demo-notion-applied-2",
},
{
id: "demo-job-applied-3",
source: "indeed",
title: "Staff Software Engineer",
employer: "Harbor AI",
jobUrl: "https://www.indeed.com",
applicationLink: "https://www.indeed.com",
location: "Boston, MA",
salary: "$175,000 - $205,000",
deadline: "2026-03-04",
jobDescription:
"Own technical strategy for workflow automation products. Set service boundaries, guide quality standards, and partner with product leadership on roadmap decomposition.",
status: "applied",
discoveredOffsetMinutes: 3200,
appliedOffsetMinutes: 2600,
suitabilityScore: 83,
suitabilityReason:
"Strong technical leadership overlap and systems design depth. Role is staff-level strategy heavy; profile demonstrates clear mentorship and architecture outcomes.",
tailoredSummary:
"Engineering lead with a record of defining service architecture, mentoring teams, and shipping workflow automation capabilities that improve throughput and reliability.",
tailoredHeadline: "Staff engineer with architecture ownership",
tailoredSkills: ["System Design", "Team Leadership", "TypeScript", "APIs"],
selectedProjectIds: "demo-project-2,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-applied-3.pdf",
notionPageId: "demo-notion-applied-3",
},
{
id: "demo-job-applied-4",
source: "gradcracker",
title: "Software Engineer",
employer: "Crestwave Labs",
jobUrl: "https://www.gradcracker.com",
applicationLink: "https://www.gradcracker.com",
location: "Cambridge, UK",
salary: "GBP 58,000",
deadline: "2026-03-14",
jobDescription:
"Contribute to customer-facing workflow APIs and developer tooling. Help improve release quality through stronger integration testing and release observability.",
status: "applied",
discoveredOffsetMinutes: 2100,
appliedOffsetMinutes: 1680,
suitabilityScore: 77,
suitabilityReason:
"Good fit on service/API delivery and testing rigor; interview progression likely depends on depth of UK market and domain-specific examples.",
tailoredSummary:
"API-focused software engineer who improves delivery confidence through practical testing strategy, release hygiene, and clear service contracts.",
tailoredHeadline: "Software engineer for workflow API delivery",
tailoredSkills: ["APIs", "Testing", "TypeScript", "CI/CD"],
selectedProjectIds: "demo-project-2,demo-project-3,demo-project-4",
pdfPath: "/pdfs/demo-job-applied-4.pdf",
notionPageId: "demo-notion-applied-4",
},
{
id: "demo-job-applied-5",
source: "ukvisajobs",
title: "Senior Backend Engineer",
employer: "Lattice Retail",
jobUrl: "https://www.ukvisajobs.com",
applicationLink: "https://www.ukvisajobs.com",
location: "London, UK",
salary: "GBP 92,000",
deadline: "2026-03-09",
jobDescription:
"Scale payment and fulfillment backend services with a focus on resiliency, incident reduction, and operational tooling for support teams.",
status: "applied",
discoveredOffsetMinutes: 1600,
appliedOffsetMinutes: 900,
suitabilityScore: 80,
suitabilityReason:
"Strong backend reliability fit with relevant operations tooling experience. Limited direct payment domain history, but technical foundations are strong.",
tailoredSummary:
"Backend engineer experienced in high-availability services, incident reduction, and workflow automation for operations-heavy product teams.",
tailoredHeadline: "Senior backend engineer for resilient systems",
tailoredSkills: ["Reliability", "Node.js", "TypeScript", "Operations"],
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-applied-5.pdf",
notionPageId: "demo-notion-applied-5",
},
{
id: "demo-job-skipped-1",
source: "ukvisajobs",
title: "Full Stack Engineer",
employer: "Cloudbridge",
jobUrl: "https://www.ukvisajobs.com",
applicationLink: "https://www.ukvisajobs.com",
location: "Manchester, UK",
salary: "GBP 55,000",
deadline: "2026-03-02",
jobDescription:
"Generalist full-stack role supporting a legacy monolith migration and mixed frontend/backend ownership.",
status: "skipped",
discoveredOffsetMinutes: 1240,
suitabilityScore: 64,
suitabilityReason:
"Lower priority match due to broad role scope and weaker alignment with desired backend platform focus.",
},
{
id: "demo-job-skipped-2",
source: "linkedin",
title: "Junior Frontend Engineer",
employer: "Pixelnest",
jobUrl: "https://www.linkedin.com",
applicationLink: "https://www.linkedin.com",
location: "Remote (EU)",
salary: "EUR 45,000",
deadline: "2026-03-01",
jobDescription:
"Frontend-first role focused on marketing pages and design implementation.",
status: "skipped",
discoveredOffsetMinutes: 860,
suitabilityScore: 58,
suitabilityReason:
"Deliberately skipped: role is frontend-heavy and below desired seniority target for this search profile.",
},
];
export const DEMO_GENERATED_APPLIED_JOB_COUNT = 48;
export interface DemoDefaultStageEvent {
id: string;
applicationId: string;
fromStage: ApplicationStage | null;
toStage: ApplicationStage;
title: string;
occurredOffsetMinutes: number;
metadata: StageEventMetadata | null;
}
export const DEMO_BASE_STAGE_EVENTS: DemoDefaultStageEvent[] = [
{
id: "demo-event-applied-1",
applicationId: "demo-job-applied-1",
fromStage: null,
toStage: "applied",
title: "Applied (seeded demo)",
occurredOffsetMinutes: 180,
metadata: { eventLabel: "Applied (seeded demo)", actor: "system" },
},
{
id: "demo-event-screen-1",
applicationId: "demo-job-applied-1",
fromStage: "applied",
toStage: "recruiter_screen",
title: "Recruiter intro call",
occurredOffsetMinutes: 4560,
metadata: { eventLabel: "Recruiter Screen", actor: "user" },
},
{
id: "demo-event-tech-1",
applicationId: "demo-job-applied-1",
fromStage: "recruiter_screen",
toStage: "technical_interview",
title: "Technical interview scheduled",
occurredOffsetMinutes: 4200,
metadata: { eventLabel: "Technical Interview", actor: "user" },
},
{
id: "demo-event-applied-2",
applicationId: "demo-job-applied-2",
fromStage: null,
toStage: "applied",
title: "Applied via company portal",
occurredOffsetMinutes: 3800,
metadata: { eventLabel: "Applied", actor: "system" },
},
{
id: "demo-event-assessment-2",
applicationId: "demo-job-applied-2",
fromStage: "applied",
toStage: "assessment",
title: "Take-home assessment sent",
occurredOffsetMinutes: 3500,
metadata: { eventLabel: "Assessment", actor: "user" },
},
{
id: "demo-event-applied-3",
applicationId: "demo-job-applied-3",
fromStage: null,
toStage: "applied",
title: "Applied with tailored resume",
occurredOffsetMinutes: 2600,
metadata: { eventLabel: "Applied", actor: "system" },
},
{
id: "demo-event-applied-4",
applicationId: "demo-job-applied-4",
fromStage: null,
toStage: "applied",
title: "Applied from referral link",
occurredOffsetMinutes: 1680,
metadata: { eventLabel: "Applied", actor: "system" },
},
{
id: "demo-event-screen-4",
applicationId: "demo-job-applied-4",
fromStage: "applied",
toStage: "recruiter_screen",
title: "Recruiter screen booked",
occurredOffsetMinutes: 1500,
metadata: { eventLabel: "Recruiter Screen", actor: "user" },
},
{
id: "demo-event-hm-4",
applicationId: "demo-job-applied-4",
fromStage: "recruiter_screen",
toStage: "hiring_manager_screen",
title: "Hiring manager interview",
occurredOffsetMinutes: 1320,
metadata: { eventLabel: "Hiring Manager Screen", actor: "user" },
},
{
id: "demo-event-offer-4",
applicationId: "demo-job-applied-4",
fromStage: "hiring_manager_screen",
toStage: "offer",
title: "Offer received",
occurredOffsetMinutes: 1200,
metadata: { eventLabel: "Offer", actor: "user" },
},
{
id: "demo-event-applied-5",
applicationId: "demo-job-applied-5",
fromStage: null,
toStage: "applied",
title: "Applied with cover note",
occurredOffsetMinutes: 900,
metadata: { eventLabel: "Applied", actor: "system" },
},
{
id: "demo-event-screen-5",
applicationId: "demo-job-applied-5",
fromStage: "applied",
toStage: "recruiter_screen",
title: "Recruiter screening complete",
occurredOffsetMinutes: 760,
metadata: { eventLabel: "Recruiter Screen", actor: "user" },
},
{
id: "demo-event-closed-5",
applicationId: "demo-job-applied-5",
fromStage: "recruiter_screen",
toStage: "closed",
title: "Position closed",
occurredOffsetMinutes: 640,
metadata: {
eventLabel: "Closed",
actor: "user",
reasonCode: "rejected",
},
},
];

View File

@ -0,0 +1,308 @@
import type { JobSource } from "@shared/types";
import {
COMPANY_PREFIXES,
COMPANY_SUFFIXES,
DEMO_BASE_JOBS,
DEMO_BASE_STAGE_EVENTS,
DEMO_BASELINE_NAME,
DEMO_BASELINE_VERSION,
DEMO_DEFAULT_PIPELINE_RUNS,
DEMO_DEFAULT_SETTINGS,
DEMO_PROJECT_CATALOG,
DEMO_SOURCE_BASE_URLS,
type DemoDefaultJob,
type DemoDefaultPipelineRun,
type DemoDefaultSettings,
type DemoDefaultStageEvent,
} from "./demo-defaults.data";
function makeDemoCompany(index: number): string {
const prefix = COMPANY_PREFIXES[index % COMPANY_PREFIXES.length];
const suffix = COMPANY_SUFFIXES[(index * 7 + 3) % COMPANY_SUFFIXES.length];
const mode = index % 4;
if (mode === 1) return `${prefix}-${suffix}`;
if (mode === 2) return `${prefix} ${suffix} Co.`;
if (mode === 3) return `${prefix} ${suffix} Inc.`;
return `${prefix} ${suffix}`;
}
function sourceBaseUrl(source: JobSource): string {
return DEMO_SOURCE_BASE_URLS[source];
}
const SOURCE_CYCLE: JobSource[] = [
"linkedin",
"indeed",
"gradcracker",
"ukvisajobs",
"manual",
];
const ROLE_TRACK = [
"Backend Engineer",
"Software Engineer",
"Senior Backend Engineer",
"Platform Engineer",
"Full Stack Engineer",
"TypeScript Engineer",
] as const;
const FOCUS_TRACK = [
"Core Platform",
"Integrations",
"Data",
"Reliability",
] as const;
const LOCATION_TRACK = [
"Remote (US)",
"New York, NY",
"Chicago, IL",
"Austin, TX",
] as const;
const PROJECT_ID_SETS = [
"demo-project-1,demo-project-4,demo-project-5",
"demo-project-1,demo-project-2,demo-project-4",
"demo-project-2,demo-project-3,demo-project-4",
"demo-project-2,demo-project-4,demo-project-5",
] as const;
function buildDemoDeadline(idx: number): string {
// Use Date rollover so month/day track changes cannot generate invalid dates.
const monthIndex = 2 + (idx % 6); // March..August (0-indexed months)
const dayOfMonth = (idx % 26) + 1;
return new Date(Date.UTC(2026, monthIndex, dayOfMonth))
.toISOString()
.slice(0, 10);
}
const baseDiscoveredCount = DEMO_BASE_JOBS.filter(
(job) => job.status === "discovered",
).length;
const baseReadyCount = DEMO_BASE_JOBS.filter(
(job) => job.status === "ready",
).length;
const baseAppliedCount = DEMO_BASE_JOBS.filter(
(job) => job.status === "applied",
).length;
const TARGET_DISCOVERED_TOTAL = 45;
const TARGET_READY_TOTAL = Math.floor(TARGET_DISCOVERED_TOTAL / 3);
// Keep applied volume high in demo seeds so stage timelines have enough events.
const TARGET_APPLIED_TOTAL = TARGET_DISCOVERED_TOTAL;
const GENERATED_DISCOVERED_JOB_COUNT = Math.max(
TARGET_DISCOVERED_TOTAL - baseDiscoveredCount,
0,
);
const GENERATED_READY_JOB_COUNT = Math.max(
TARGET_READY_TOTAL - baseReadyCount,
0,
);
const GENERATED_APPLIED_JOB_COUNT = Math.max(
TARGET_APPLIED_TOTAL - baseAppliedCount,
0,
);
function buildGeneratedJob(
idx: number,
status: "discovered" | "ready" | "applied",
): DemoDefaultJob {
const n = idx + 1;
const source = SOURCE_CYCLE[idx % SOURCE_CYCLE.length];
const role = ROLE_TRACK[idx % ROLE_TRACK.length];
const focus = FOCUS_TRACK[idx % FOCUS_TRACK.length];
const employer = makeDemoCompany(idx + 10);
const score = 68 + (idx % 24);
const common = {
source,
title: `${role} (${focus})`,
employer,
jobUrl: sourceBaseUrl(source),
applicationLink: sourceBaseUrl(source),
location: LOCATION_TRACK[idx % LOCATION_TRACK.length],
salary: `$${115 + (idx % 11) * 5},000 - $${135 + (idx % 11) * 5},000`,
deadline: buildDemoDeadline(idx),
jobDescription:
"Build and improve backend workflow systems, API contracts, and operational tooling. Partner with product and operations to increase reliability, reduce manual effort, and improve delivery throughput.",
suitabilityScore: score,
suitabilityReason:
"Good-to-strong fit based on TypeScript backend delivery, workflow automation ownership, and observability practices. Alignment is strongest on API reliability and production operations.",
} satisfies Omit<
DemoDefaultJob,
| "id"
| "status"
| "discoveredOffsetMinutes"
| "appliedOffsetMinutes"
| "tailoredSummary"
| "tailoredHeadline"
| "tailoredSkills"
| "selectedProjectIds"
| "pdfPath"
| "notionPageId"
>;
if (status === "applied") {
const appliedDaysAgo =
2 + Math.floor((idx * 18) / Math.max(GENERATED_APPLIED_JOB_COUNT, 1));
const appliedOffsetMinutes = appliedDaysAgo * 24 * 60 + (idx % 16) * 15;
const discoveredOffsetMinutes =
appliedOffsetMinutes + (2 + (idx % 8)) * 24 * 60 + (idx % 5) * 60;
return {
...common,
id: `demo-job-applied-auto-${n}`,
status,
discoveredOffsetMinutes,
appliedOffsetMinutes,
tailoredSummary:
"Backend engineer with experience shipping resilient TypeScript services, improving queue and workflow reliability, and tightening API contracts for operational safety.",
tailoredHeadline: `${role} with systems and reliability focus`,
tailoredSkills: ["TypeScript", "Node.js", "APIs", "Observability"],
selectedProjectIds: PROJECT_ID_SETS[idx % PROJECT_ID_SETS.length],
pdfPath: `/pdfs/demo-job-applied-auto-${n}.pdf`,
notionPageId: `demo-notion-applied-auto-${n}`,
};
}
const discoveredDaysAgo =
1 + Math.floor((idx * 29) / Math.max(TARGET_DISCOVERED_TOTAL, 1));
const discoveredOffsetMinutes = discoveredDaysAgo * 24 * 60 + (idx % 12) * 20;
if (status === "ready") {
return {
...common,
id: `demo-job-ready-auto-${n}`,
status,
discoveredOffsetMinutes,
tailoredSummary:
"Backend-focused engineer with a strong record of API reliability improvements, structured observability, and operational workflow automation.",
tailoredHeadline: `${role} for production-grade systems`,
tailoredSkills: ["TypeScript", "Node.js", "Observability", "APIs"],
selectedProjectIds: PROJECT_ID_SETS[idx % PROJECT_ID_SETS.length],
pdfPath: `/pdfs/demo-job-ready-auto-${n}.pdf`,
};
}
return {
...common,
id: `demo-job-discovered-auto-${n}`,
status,
discoveredOffsetMinutes,
};
}
const DEMO_GENERATED_DISCOVERED_JOBS: DemoDefaultJob[] = Array.from(
{ length: GENERATED_DISCOVERED_JOB_COUNT },
(_, idx) => buildGeneratedJob(idx, "discovered"),
);
const DEMO_GENERATED_READY_JOBS: DemoDefaultJob[] = Array.from(
{ length: GENERATED_READY_JOB_COUNT },
(_, idx) => buildGeneratedJob(idx + GENERATED_DISCOVERED_JOB_COUNT, "ready"),
);
const DEMO_GENERATED_APPLIED_JOBS: DemoDefaultJob[] = Array.from(
{ length: GENERATED_APPLIED_JOB_COUNT },
(_, idx) =>
buildGeneratedJob(
idx + GENERATED_DISCOVERED_JOB_COUNT + GENERATED_READY_JOB_COUNT,
"applied",
),
);
export const DEMO_DEFAULT_JOBS: DemoDefaultJob[] = [
...DEMO_BASE_JOBS,
...DEMO_GENERATED_DISCOVERED_JOBS,
...DEMO_GENERATED_READY_JOBS,
...DEMO_GENERATED_APPLIED_JOBS,
];
const DEMO_GENERATED_STAGE_EVENTS: DemoDefaultStageEvent[] =
DEMO_GENERATED_APPLIED_JOBS.flatMap((job, idx) => {
const n = idx + 1;
const appliedOffset = job.appliedOffsetMinutes ?? 0;
const events: DemoDefaultStageEvent[] = [
{
id: `demo-event-auto-applied-${n}`,
applicationId: job.id,
fromStage: null,
toStage: "applied",
title: "Applied (seeded demo)",
occurredOffsetMinutes: appliedOffset,
metadata: { eventLabel: "Applied", actor: "system" },
},
];
if (idx % 3 === 0) {
events.push({
id: `demo-event-auto-screen-${n}`,
applicationId: job.id,
fromStage: "applied",
toStage: "recruiter_screen",
title: "Recruiter screening",
occurredOffsetMinutes: Math.max(appliedOffset - 24 * 60, 15),
metadata: { eventLabel: "Recruiter Screen", actor: "user" },
});
}
if (idx % 6 === 0) {
events.push({
id: `demo-event-auto-tech-${n}`,
applicationId: job.id,
fromStage: "recruiter_screen",
toStage: "technical_interview",
title: "Technical interview",
occurredOffsetMinutes: Math.max(appliedOffset - 2 * 24 * 60, 15),
metadata: { eventLabel: "Technical Interview", actor: "user" },
});
}
if (idx % 12 === 0) {
events.push({
id: `demo-event-auto-offer-${n}`,
applicationId: job.id,
fromStage: "technical_interview",
toStage: "offer",
title: "Offer received",
occurredOffsetMinutes: Math.max(appliedOffset - 3 * 24 * 60, 15),
metadata: { eventLabel: "Offer", actor: "user" },
});
} else if (idx % 10 === 0) {
events.push({
id: `demo-event-auto-closed-${n}`,
applicationId: job.id,
fromStage: "recruiter_screen",
toStage: "closed",
title: "Closed without offer",
occurredOffsetMinutes: Math.max(appliedOffset - 2 * 24 * 60, 15),
metadata: {
eventLabel: "Closed",
actor: "user",
reasonCode: "rejected",
},
});
}
return events;
});
export const DEMO_DEFAULT_STAGE_EVENTS: DemoDefaultStageEvent[] = [
...DEMO_BASE_STAGE_EVENTS,
...DEMO_GENERATED_STAGE_EVENTS,
];
export {
DEMO_BASELINE_NAME,
DEMO_BASELINE_VERSION,
DEMO_DEFAULT_PIPELINE_RUNS,
DEMO_DEFAULT_SETTINGS,
DEMO_PROJECT_CATALOG,
};
export type {
DemoDefaultJob,
DemoDefaultPipelineRun,
DemoDefaultSettings,
DemoDefaultStageEvent,
};

View File

@ -0,0 +1,76 @@
import { AppError } from "@infra/errors";
import { fail } from "@infra/http";
import { logger } from "@infra/logger";
import {
DEMO_BASELINE_NAME,
DEMO_BASELINE_VERSION,
} from "@server/config/demo-defaults";
import type { DemoInfoResponse } from "@shared/types";
import type { Response } from "express";
export const DEMO_RESET_CADENCE_HOURS = 6;
type DemoState = {
lastResetAt: string | null;
nextResetAt: string | null;
};
const state: DemoState = {
lastResetAt: null,
nextResetAt: null,
};
export function isDemoMode(): boolean {
return process.env.DEMO_MODE === "true";
}
export function getDemoInfo(): DemoInfoResponse {
const demoMode = isDemoMode();
return {
demoMode,
resetCadenceHours: DEMO_RESET_CADENCE_HOURS,
lastResetAt: state.lastResetAt,
nextResetAt: state.nextResetAt,
baselineVersion: demoMode ? DEMO_BASELINE_VERSION : null,
baselineName: demoMode ? DEMO_BASELINE_NAME : null,
};
}
export function setDemoResetTimes(args: {
lastResetAt?: string | null;
nextResetAt?: string | null;
}): void {
if (args.lastResetAt !== undefined) state.lastResetAt = args.lastResetAt;
if (args.nextResetAt !== undefined) state.nextResetAt = args.nextResetAt;
}
export function makeDemoMeta(options?: {
simulated?: boolean;
blockedReason?: string;
}): { simulated?: boolean; blockedReason?: string } {
return {
...(options?.simulated ? { simulated: true } : {}),
...(options?.blockedReason ? { blockedReason: options.blockedReason } : {}),
};
}
export function sendDemoBlocked(
res: Response,
blockedReason: string,
context: Record<string, unknown> = {},
): void {
logger.info("Blocked action in demo mode", {
blockedReason,
...context,
});
fail(
res,
new AppError({
status: 403,
code: "FORBIDDEN",
message: "This action is disabled in the public demo.",
details: { blockedReason },
}),
{ blockedReason },
);
}

View File

@ -10,6 +10,7 @@ import {
setBackupSettings,
startBackupScheduler,
} from "./services/backup/index";
import { initializeDemoModeServices } from "./services/demo-mode";
import { applyStoredEnvOverrides } from "./services/envSettings";
import { initialize as initializeVisaSponsors } from "./services/visa-sponsors/index";
@ -37,7 +38,13 @@ async function startServer() {
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
try {
await initializeVisaSponsors();
if (process.env.DEMO_MODE === "true") {
console.log(
" Demo mode enabled. Skipping visa sponsors initialization.",
);
} else {
await initializeVisaSponsors();
}
} catch (error) {
console.warn("⚠️ Failed to initialize visa sponsors service:", error);
}
@ -80,6 +87,12 @@ async function startServer() {
} catch (error) {
console.warn("⚠️ Failed to initialize backup service:", error);
}
try {
await initializeDemoModeServices();
} catch (error) {
console.warn("⚠️ Failed to initialize demo mode services:", error);
}
});
}

View File

@ -30,7 +30,25 @@ export function ok<T>(res: Response, data: T, status = 200): void {
res.status(status).json(payload);
}
export function fail(res: Response, error: AppError): void {
export function okWithMeta<T>(
res: Response,
data: T,
meta: Omit<NonNullable<ApiResponse<T>["meta"]>, "requestId">,
status = 200,
): void {
const payload: ApiResponse<T> = {
ok: true,
data,
meta: { requestId: getResponseRequestId(res), ...meta },
};
res.status(status).json(payload);
}
export function fail(
res: Response,
error: AppError,
meta?: Omit<ApiResponse<never>["meta"], "requestId">,
): void {
const payload: ApiResponse<never> = {
ok: false,
error: {
@ -40,7 +58,7 @@ export function fail(res: Response, error: AppError): void {
? { details: sanitizeUnknown(error.details) }
: {}),
},
meta: { requestId: getResponseRequestId(res) },
meta: { requestId: getResponseRequestId(res), ...(meta ?? {}) },
};
res.status(error.status).json(payload);
}

View File

@ -0,0 +1,69 @@
import { logger } from "@infra/logger";
import {
DEMO_RESET_CADENCE_HOURS,
isDemoMode,
setDemoResetTimes,
} from "@server/config/demo";
import {
DEMO_BASELINE_NAME,
DEMO_BASELINE_VERSION,
} from "@server/config/demo-defaults";
import { applyDemoBaseline, buildDemoBaseline } from "./demo-seed";
const RESET_INTERVAL_MS = DEMO_RESET_CADENCE_HOURS * 60 * 60 * 1000;
let resetTimer: ReturnType<typeof setTimeout> | null = null;
let isResetRunning = false;
function computeNextReset(now: Date): Date {
return new Date(now.getTime() + RESET_INTERVAL_MS);
}
function scheduleNextReset(): void {
const now = new Date();
const nextReset = computeNextReset(now);
const delay = nextReset.getTime() - now.getTime();
setDemoResetTimes({ nextResetAt: nextReset.toISOString() });
if (resetTimer) clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
void runDemoResetCycle();
}, delay);
}
export async function resetDemoData(): Promise<void> {
const baseline = buildDemoBaseline(new Date());
await applyDemoBaseline(baseline);
}
export async function runDemoResetCycle(): Promise<void> {
if (isResetRunning) return;
isResetRunning = true;
try {
await resetDemoData();
const nowIso = new Date().toISOString();
setDemoResetTimes({ lastResetAt: nowIso });
scheduleNextReset();
logger.info("Demo dataset reset completed", {
lastResetAt: nowIso,
baselineVersion: DEMO_BASELINE_VERSION,
});
} catch (error) {
logger.error("Failed to reset demo dataset", { error });
scheduleNextReset();
} finally {
isResetRunning = false;
}
}
export async function initializeDemoModeServices(): Promise<void> {
if (!isDemoMode()) return;
await runDemoResetCycle();
logger.info("Demo mode services initialized", {
resetCadenceHours: DEMO_RESET_CADENCE_HOURS,
baselineVersion: DEMO_BASELINE_VERSION,
baselineName: DEMO_BASELINE_NAME,
});
}

View File

@ -0,0 +1,149 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEMO_DEFAULT_JOBS,
DEMO_DEFAULT_PIPELINE_RUNS,
DEMO_DEFAULT_SETTINGS,
DEMO_DEFAULT_STAGE_EVENTS,
} from "@server/config/demo-defaults";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const originalEnv = { ...process.env };
function sortedPairs(map: Record<string, string>) {
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
}
describe.sequential("demo seed baseline", () => {
let tempDir: string;
let closeDb: (() => void) | null = null;
beforeEach(async () => {
vi.resetModules();
tempDir = await mkdtemp(join(tmpdir(), "job-ops-demo-seed-test-"));
process.env = {
...originalEnv,
DATA_DIR: tempDir,
NODE_ENV: "test",
MODEL: "test-model",
DEMO_MODE: "true",
};
await import("../db/migrate");
const dbMod = await import("../db/index");
closeDb = dbMod.closeDb;
});
afterEach(async () => {
if (closeDb) closeDb();
await rm(tempDir, { recursive: true, force: true });
process.env = { ...originalEnv };
});
it("buildDemoBaseline returns deterministic, schema-shaped fixtures", async () => {
const { buildDemoBaseline } = await import("./demo-seed");
const now = new Date("2026-02-05T12:00:00.000Z");
const baseline = buildDemoBaseline(now);
expect(baseline.resetAt).toBe(now.toISOString());
expect(Object.keys(baseline.settings).length).toBeGreaterThan(0);
expect(baseline.pipelineRuns).toHaveLength(
DEMO_DEFAULT_PIPELINE_RUNS.length,
);
expect(baseline.jobs).toHaveLength(DEMO_DEFAULT_JOBS.length);
expect(baseline.stageEvents).toHaveLength(DEMO_DEFAULT_STAGE_EVENTS.length);
const seededJobIds = baseline.jobs.map((job) => job.id).sort();
expect(seededJobIds).toEqual(DEMO_DEFAULT_JOBS.map((job) => job.id).sort());
});
it("resetDemoData restores settings and data to demo defaults", async () => {
const { db, schema } = await import("../db/index");
const { resetDemoData } = await import("./demo-mode");
const { setSetting, getAllSettings } = await import(
"../repositories/settings"
);
await resetDemoData();
await db.delete(schema.jobs);
await db.insert(schema.jobs).values({
id: "mutated-job",
source: "manual",
title: "Mutated Job",
employer: "Mutated Employer",
jobUrl: "https://demo.job-ops.local/jobs/mutated",
status: "discovered",
});
await setSetting("llmProvider", "openai");
await resetDemoData();
const allJobs = await db.select({ id: schema.jobs.id }).from(schema.jobs);
expect(allJobs.map((row) => row.id).sort()).toEqual(
DEMO_DEFAULT_JOBS.map((job) => job.id).sort(),
);
const allSettings = (await getAllSettings()) as Record<string, string>;
expect(sortedPairs(allSettings)).toEqual(
sortedPairs(DEMO_DEFAULT_SETTINGS as Record<string, string>),
);
});
it("reset is idempotent for logical baseline content", async () => {
const { db, schema } = await import("../db/index");
const { resetDemoData } = await import("./demo-mode");
const logicalSnapshot = async () => {
const [settingsRows, runRows, jobRows, stageRows] = await Promise.all([
db
.select({ key: schema.settings.key, value: schema.settings.value })
.from(schema.settings),
db
.select({
id: schema.pipelineRuns.id,
status: schema.pipelineRuns.status,
jobsDiscovered: schema.pipelineRuns.jobsDiscovered,
jobsProcessed: schema.pipelineRuns.jobsProcessed,
errorMessage: schema.pipelineRuns.errorMessage,
})
.from(schema.pipelineRuns),
db
.select({
id: schema.jobs.id,
status: schema.jobs.status,
source: schema.jobs.source,
title: schema.jobs.title,
employer: schema.jobs.employer,
notionPageId: schema.jobs.notionPageId,
})
.from(schema.jobs),
db
.select({
id: schema.stageEvents.id,
applicationId: schema.stageEvents.applicationId,
fromStage: schema.stageEvents.fromStage,
toStage: schema.stageEvents.toStage,
title: schema.stageEvents.title,
})
.from(schema.stageEvents),
]);
return {
settings: settingsRows.sort((a, b) => a.key.localeCompare(b.key)),
runs: runRows.sort((a, b) => a.id.localeCompare(b.id)),
jobs: jobRows.sort((a, b) => a.id.localeCompare(b.id)),
stageEvents: stageRows.sort((a, b) => a.id.localeCompare(b.id)),
};
};
await resetDemoData();
const first = await logicalSnapshot();
await resetDemoData();
const second = await logicalSnapshot();
expect(second).toEqual(first);
});
});

View File

@ -0,0 +1,127 @@
import {
DEMO_DEFAULT_JOBS,
DEMO_DEFAULT_PIPELINE_RUNS,
DEMO_DEFAULT_SETTINGS,
DEMO_DEFAULT_STAGE_EVENTS,
type DemoDefaultSettings,
} from "@server/config/demo-defaults";
import { db, schema } from "@server/db/index";
type BuiltDemoBaseline = {
resetAt: string;
settings: DemoDefaultSettings;
pipelineRuns: Array<typeof schema.pipelineRuns.$inferInsert>;
jobs: Array<typeof schema.jobs.$inferInsert>;
stageEvents: Array<typeof schema.stageEvents.$inferInsert>;
};
const { interviews, jobs, pipelineRuns, settings, stageEvents, tasks } = schema;
function toIsoFromOffset(now: Date, offsetMinutes: number): string {
return new Date(now.getTime() - offsetMinutes * 60 * 1000).toISOString();
}
function makeDemoLink(
baseUrl: string,
jobId: string,
kind: "job" | "apply",
): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/${kind}/${jobId}`;
}
export function buildDemoBaseline(now: Date): BuiltDemoBaseline {
const resetAt = now.toISOString();
return {
resetAt,
settings: DEMO_DEFAULT_SETTINGS,
pipelineRuns: DEMO_DEFAULT_PIPELINE_RUNS.map((run) => ({
id: run.id,
status: run.status,
startedAt: toIsoFromOffset(now, run.startedOffsetMinutes),
completedAt: toIsoFromOffset(now, run.completedOffsetMinutes),
jobsDiscovered: run.jobsDiscovered,
jobsProcessed: run.jobsProcessed,
errorMessage: run.errorMessage ?? null,
})),
jobs: DEMO_DEFAULT_JOBS.map((job) => ({
id: job.id,
source: job.source,
title: job.title,
employer: job.employer,
jobUrl: makeDemoLink(job.jobUrl, job.id, "job"),
applicationLink: makeDemoLink(job.applicationLink, job.id, "apply"),
location: job.location,
salary: job.salary,
deadline: job.deadline,
jobDescription: job.jobDescription,
status: job.status,
suitabilityScore: job.suitabilityScore,
suitabilityReason: job.suitabilityReason,
tailoredSummary: job.tailoredSummary ?? null,
tailoredHeadline: job.tailoredHeadline ?? null,
tailoredSkills: job.tailoredSkills
? JSON.stringify(job.tailoredSkills)
: null,
selectedProjectIds: job.selectedProjectIds ?? null,
pdfPath: job.pdfPath ?? null,
notionPageId: job.notionPageId ?? null,
discoveredAt: toIsoFromOffset(now, job.discoveredOffsetMinutes),
appliedAt:
job.status === "applied" && typeof job.appliedOffsetMinutes === "number"
? toIsoFromOffset(now, job.appliedOffsetMinutes)
: null,
createdAt: toIsoFromOffset(now, job.discoveredOffsetMinutes),
updatedAt: resetAt,
})),
stageEvents: DEMO_DEFAULT_STAGE_EVENTS.map((event) => ({
id: event.id,
applicationId: event.applicationId,
title: event.title,
fromStage: event.fromStage,
toStage: event.toStage,
occurredAt: Math.floor(
(now.getTime() - event.occurredOffsetMinutes * 60 * 1000) / 1000,
),
metadata: event.metadata,
outcome: null,
groupId: null,
})),
};
}
export async function applyDemoBaseline(
baseline: BuiltDemoBaseline,
): Promise<void> {
db.transaction((tx) => {
tx.delete(stageEvents).run();
tx.delete(tasks).run();
tx.delete(interviews).run();
tx.delete(jobs).run();
tx.delete(pipelineRuns).run();
tx.delete(settings).run();
const settingRows = Object.entries(baseline.settings).map(
([key, value]) => ({
key,
value,
createdAt: baseline.resetAt,
updatedAt: baseline.resetAt,
}),
);
if (settingRows.length > 0) {
tx.insert(settings).values(settingRows).run();
}
if (baseline.pipelineRuns.length > 0) {
tx.insert(pipelineRuns).values(baseline.pipelineRuns).run();
}
if (baseline.jobs.length > 0) {
tx.insert(jobs).values(baseline.jobs).run();
}
if (baseline.stageEvents.length > 0) {
tx.insert(stageEvents).values(baseline.stageEvents).run();
}
});
}

View File

@ -0,0 +1,158 @@
import { logger } from "@infra/logger";
import * as pipeline from "@server/pipeline/index";
import * as jobsRepo from "@server/repositories/jobs";
import * as pipelineRepo from "@server/repositories/pipeline";
import { transitionStage } from "@server/services/applicationTracking";
import type {
Job,
JobSource,
PipelineConfig,
StageEventMetadata,
} from "@shared/types";
type ProcessOptions = {
force?: boolean;
};
function scoreFromJob(job: Job): number {
const seed = `${job.id}:${job.title}:${job.employer}`;
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) % 100000;
}
return 55 + (hash % 40);
}
function makeDemoReason(job: Job, score: number): string {
return `Demo score ${score}: simulated match for ${job.title} at ${job.employer}.`;
}
function makeDemoSummary(job: Job): string {
return `Demo summary for ${job.title} at ${job.employer}. This text is simulated in demo mode and does not call a live LLM provider.`;
}
function ensureProjectIds(job: Job): string {
if (job.selectedProjectIds?.trim()) return job.selectedProjectIds;
return "demo-project-1,demo-project-2";
}
function samplePdfPath(job: Job): string {
const safeId = job.id.replace(/[^a-zA-Z0-9-_]/g, "");
return `/pdfs/demo-${safeId || "sample"}.pdf`;
}
async function ensureJob(jobId: string): Promise<Job> {
const job = await jobsRepo.getJobById(jobId);
if (!job) throw new Error("Job not found");
return job;
}
export async function simulatePipelineRun(
config?: Partial<PipelineConfig>,
): Promise<{ message: string; runId: string; jobsDiscovered: number }> {
const run = await pipelineRepo.createPipelineRun();
const source = config?.sources?.[0] ?? "manual";
const now = new Date();
const isoNow = now.toISOString();
const jobUrl = `https://demo.job-ops.local/jobs/${run.id}`;
await jobsRepo.createJob({
source: source as JobSource,
title: "Demo Software Engineer",
employer: "Demo Systems Ltd",
jobUrl,
applicationLink: jobUrl,
location: "Remote",
salary: "Competitive",
deadline: now.toISOString().slice(0, 10),
jobDescription:
"This is a generated demo job used to simulate pipeline behavior.",
});
await pipelineRepo.updatePipelineRun(run.id, {
status: "completed",
completedAt: isoNow,
jobsDiscovered: 1,
jobsProcessed: 0,
});
pipeline.progressHelpers.complete(1, 0);
logger.info("Simulated demo pipeline run", { pipelineRunId: run.id });
return {
message: "Pipeline simulated in demo mode",
runId: run.id,
jobsDiscovered: 1,
};
}
export async function simulateSummarizeJob(
jobId: string,
_options?: ProcessOptions,
): Promise<{ success: boolean; error?: string }> {
const job = await ensureJob(jobId);
await jobsRepo.updateJob(job.id, {
tailoredSummary: makeDemoSummary(job),
tailoredHeadline: `Demo Tailored Resume - ${job.title}`,
tailoredSkills: JSON.stringify([
"TypeScript",
"System Design",
"Communication",
]),
selectedProjectIds: ensureProjectIds(job),
});
return { success: true };
}
export async function simulateGeneratePdf(
jobId: string,
): Promise<{ success: boolean; error?: string }> {
const job = await ensureJob(jobId);
await jobsRepo.updateJob(job.id, {
status: "ready",
pdfPath: samplePdfPath(job),
});
return { success: true };
}
export async function simulateProcessJob(
jobId: string,
options?: ProcessOptions,
): Promise<{ success: boolean; error?: string }> {
const summarize = await simulateSummarizeJob(jobId, options);
if (!summarize.success) return summarize;
return simulateGeneratePdf(jobId);
}
export async function simulateRescoreJob(jobId: string): Promise<Job> {
const job = await ensureJob(jobId);
const score = scoreFromJob(job);
const updated = await jobsRepo.updateJob(job.id, {
suitabilityScore: score,
suitabilityReason: makeDemoReason(job, score),
});
if (!updated) throw new Error("Job not found");
return updated;
}
export async function simulateApplyJob(jobId: string): Promise<Job> {
const job = await ensureJob(jobId);
const appliedAtDate = new Date();
transitionStage(
job.id,
"applied",
Math.floor(appliedAtDate.getTime() / 1000),
{
eventLabel: "Applied (Demo Simulation)",
actor: "system",
note: "This apply action was simulated in demo mode.",
} satisfies StageEventMetadata,
null,
);
const updated = await jobsRepo.updateJob(job.id, {
status: "applied",
appliedAt: appliedAtDate.toISOString(),
notionPageId: `demo-notion-${job.id.slice(0, 8)}`,
});
if (!updated) throw new Error("Job not found");
return updated;
}

View File

@ -311,6 +311,8 @@ export interface PipelineRun {
// API Response types
export interface ApiMeta {
requestId: string;
simulated?: boolean;
blockedReason?: string;
}
export interface ApiErrorPayload {
@ -487,6 +489,15 @@ export interface ValidationResult {
message: string | null;
}
export interface DemoInfoResponse {
demoMode: boolean;
resetCadenceHours: number;
lastResetAt: string | null;
nextResetAt: string | null;
baselineVersion: string | null;
baselineName: string | null;
}
export interface AppSettings {
model: string;
defaultModel: string;