diff --git a/README.md b/README.md index a680822..98a1568 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/documentation/self-hosting.md b/documentation/self-hosting.md index ff59c56..c6fc30c 100644 --- a/documentation/self-hosting.md +++ b/documentation/self-hosting.md @@ -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 diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 776dca6..fe01771 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -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(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 ( <> - - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
+ {demoInfo?.demoMode && ( +
+ Demo mode: integrations are simulated and data resets every{" "} + {demoInfo.resetCadenceHours} hours. +
+ )} +
+ + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
diff --git a/orchestrator/src/client/api/client.demo.test.ts b/orchestrator/src/client/api/client.demo.test.ts new file mode 100644 index 0000000..b938bea --- /dev/null +++ b/orchestrator/src/client/api/client.demo.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index aeb799c..80579e1 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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( 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( endpoint: string, options?: RequestInit, @@ -102,11 +131,17 @@ async function fetchApi( 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 { + return fetchApi("/demo/info"); +} + // UK Visa Jobs API export async function searchUkVisaJobs(input: { searchTerm?: string; diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index 9d3d638..bcc3eff 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -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(); 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 () => { diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 469abbd..999a2fe 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -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(null); + const demoInfo = useDemoInfo(); + const demoMode = demoInfo?.demoMode ?? false; const { control, watch, getValues, reset, setValue } = useForm({ @@ -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 () => { diff --git a/orchestrator/src/client/hooks/useDemoInfo.ts b/orchestrator/src/client/hooks/useDemoInfo.ts new file mode 100644 index 0000000..41a090c --- /dev/null +++ b/orchestrator/src/client/hooks/useDemoInfo.ts @@ -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(null); + + useEffect(() => { + let isCancelled = false; + + void api + .getDemoInfo() + .then((info) => { + if (!isCancelled) { + setDemoInfo(info); + } + }) + .catch(() => { + if (!isCancelled) { + setDemoInfo(null); + } + }); + + return () => { + isCancelled = true; + }; + }, []); + + return demoInfo; +} diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts index 0089602..6d33c36 100644 --- a/orchestrator/src/client/hooks/useSettings.test.ts +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -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); }); diff --git a/orchestrator/src/lib/demo-toast.tsx b/orchestrator/src/lib/demo-toast.tsx new file mode 100644 index 0000000..e8ac124 --- /dev/null +++ b/orchestrator/src/lib/demo-toast.tsx @@ -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 ( +
+
{icon}
+
+

+ {title} +

+

{description}

+
+
+ ); +} + +export function showDemoSimulatedToast(description: string): void { + toast.custom( + () => ( + } + /> + ), + { duration: 3600 }, + ); +} + +export function showDemoBlockedToast(description: string): void { + toast.custom( + () => ( + } + /> + ), + { duration: 4200 }, + ); +} diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index 964aef4..ee864f0 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -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); diff --git a/orchestrator/src/server/api/routes/backup.ts b/orchestrator/src/server/api/routes/backup.ts index b286289..91290fc 100644 --- a/orchestrator/src/server/api/routes/backup.ts +++ b/orchestrator/src/server/api/routes/backup.ts @@ -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) { diff --git a/orchestrator/src/server/api/routes/database.ts b/orchestrator/src/server/api/routes/database.ts index b3a1750..cdbd57f 100644 --- a/orchestrator/src/server/api/routes/database.ts +++ b/orchestrator/src/server/api/routes/database.ts @@ -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({ diff --git a/orchestrator/src/server/api/routes/demo-mode.test.ts b/orchestrator/src/server/api/routes/demo-mode.test.ts new file mode 100644 index 0000000..f88d239 --- /dev/null +++ b/orchestrator/src/server/api/routes/demo-mode.test.ts @@ -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 }); + } + }); +}); diff --git a/orchestrator/src/server/api/routes/demo.ts b/orchestrator/src/server/api/routes/demo.ts new file mode 100644 index 0000000..0f0fb27 --- /dev/null +++ b/orchestrator/src/server/api/routes/demo.ts @@ -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()); +}); diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index 5bcb3ed..dea2e1e 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -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); diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index fb928eb..baa1020 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -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 }); }, diff --git a/orchestrator/src/server/api/routes/pipeline.ts b/orchestrator/src/server/api/routes/pipeline.ts index 7c6a612..d57a706 100644 --- a/orchestrator/src/server/api/routes/pipeline.ts +++ b/orchestrator/src/server/api/routes/pipeline.ts @@ -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) => { diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 1d03d16..8545e5b 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -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", () => { diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index 45eab77..2aa563f 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -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 }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 8db5837..1a7114d 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -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); diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 02d5e77..cb59d12 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -34,6 +34,9 @@ vi.mock("../../pipeline/index", () => { listener(progress); return () => {}; }), + progressHelpers: { + complete: vi.fn(), + }, }; }); diff --git a/orchestrator/src/server/api/routes/webhook.test.ts b/orchestrator/src/server/api/routes/webhook.test.ts index 221efbf..7f5d6b2 100644 --- a/orchestrator/src/server/api/routes/webhook.test.ts +++ b/orchestrator/src/server/api/routes/webhook.test.ts @@ -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); + } + }); }); diff --git a/orchestrator/src/server/api/routes/webhook.ts b/orchestrator/src/server/api/routes/webhook.ts index b2d1a05..404476e 100644 --- a/orchestrator/src/server/api/routes/webhook.ts +++ b/orchestrator/src/server/api/routes/webhook.ts @@ -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) => { diff --git a/orchestrator/src/server/config/demo-defaults.data.ts b/orchestrator/src/server/config/demo-defaults.data.ts new file mode 100644 index 0000000..241920d --- /dev/null +++ b/orchestrator/src/server/config/demo-defaults.data.ts @@ -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>; + +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 = { + 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", + }, + }, +]; diff --git a/orchestrator/src/server/config/demo-defaults.ts b/orchestrator/src/server/config/demo-defaults.ts new file mode 100644 index 0000000..438cf6b --- /dev/null +++ b/orchestrator/src/server/config/demo-defaults.ts @@ -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, +}; diff --git a/orchestrator/src/server/config/demo.ts b/orchestrator/src/server/config/demo.ts new file mode 100644 index 0000000..afeb496 --- /dev/null +++ b/orchestrator/src/server/config/demo.ts @@ -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 = {}, +): 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 }, + ); +} diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts index 95c0541..f72a126 100644 --- a/orchestrator/src/server/index.ts +++ b/orchestrator/src/server/index.ts @@ -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); + } }); } diff --git a/orchestrator/src/server/infra/http.ts b/orchestrator/src/server/infra/http.ts index e804f93..d9ebaf6 100644 --- a/orchestrator/src/server/infra/http.ts +++ b/orchestrator/src/server/infra/http.ts @@ -30,7 +30,25 @@ export function ok(res: Response, data: T, status = 200): void { res.status(status).json(payload); } -export function fail(res: Response, error: AppError): void { +export function okWithMeta( + res: Response, + data: T, + meta: Omit["meta"]>, "requestId">, + status = 200, +): void { + const payload: ApiResponse = { + ok: true, + data, + meta: { requestId: getResponseRequestId(res), ...meta }, + }; + res.status(status).json(payload); +} + +export function fail( + res: Response, + error: AppError, + meta?: Omit["meta"], "requestId">, +): void { const payload: ApiResponse = { 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); } diff --git a/orchestrator/src/server/services/demo-mode.ts b/orchestrator/src/server/services/demo-mode.ts new file mode 100644 index 0000000..8c3f486 --- /dev/null +++ b/orchestrator/src/server/services/demo-mode.ts @@ -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 | 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 { + const baseline = buildDemoBaseline(new Date()); + await applyDemoBaseline(baseline); +} + +export async function runDemoResetCycle(): Promise { + 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 { + if (!isDemoMode()) return; + + await runDemoResetCycle(); + logger.info("Demo mode services initialized", { + resetCadenceHours: DEMO_RESET_CADENCE_HOURS, + baselineVersion: DEMO_BASELINE_VERSION, + baselineName: DEMO_BASELINE_NAME, + }); +} diff --git a/orchestrator/src/server/services/demo-seed.test.ts b/orchestrator/src/server/services/demo-seed.test.ts new file mode 100644 index 0000000..f2a2f69 --- /dev/null +++ b/orchestrator/src/server/services/demo-seed.test.ts @@ -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) { + 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; + expect(sortedPairs(allSettings)).toEqual( + sortedPairs(DEMO_DEFAULT_SETTINGS as Record), + ); + }); + + 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); + }); +}); diff --git a/orchestrator/src/server/services/demo-seed.ts b/orchestrator/src/server/services/demo-seed.ts new file mode 100644 index 0000000..2095185 --- /dev/null +++ b/orchestrator/src/server/services/demo-seed.ts @@ -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; + jobs: Array; + stageEvents: Array; +}; + +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 { + 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(); + } + }); +} diff --git a/orchestrator/src/server/services/demo-simulator.ts b/orchestrator/src/server/services/demo-simulator.ts new file mode 100644 index 0000000..842072e --- /dev/null +++ b/orchestrator/src/server/services/demo-simulator.ts @@ -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 { + const job = await jobsRepo.getJobById(jobId); + if (!job) throw new Error("Job not found"); + return job; +} + +export async function simulatePipelineRun( + config?: Partial, +): 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 { + 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 { + 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; +} diff --git a/shared/src/types.ts b/shared/src/types.ts index 1c84498..ed08f6b 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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;