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:
parent
d18464548e
commit
c4749b4211
13
README.md
13
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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
50
orchestrator/src/client/api/client.demo.test.ts
Normal file
50
orchestrator/src/client/api/client.demo.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
30
orchestrator/src/client/hooks/useDemoInfo.ts
Normal file
30
orchestrator/src/client/hooks/useDemoInfo.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
51
orchestrator/src/lib/demo-toast.tsx
Normal file
51
orchestrator/src/lib/demo-toast.tsx
Normal 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 },
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
132
orchestrator/src/server/api/routes/demo-mode.test.ts
Normal file
132
orchestrator/src/server/api/routes/demo-mode.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
9
orchestrator/src/server/api/routes/demo.ts
Normal file
9
orchestrator/src/server/api/routes/demo.ts
Normal 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());
|
||||
});
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -34,6 +34,9 @@ vi.mock("../../pipeline/index", () => {
|
||||
listener(progress);
|
||||
return () => {};
|
||||
}),
|
||||
progressHelpers: {
|
||||
complete: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
732
orchestrator/src/server/config/demo-defaults.data.ts
Normal file
732
orchestrator/src/server/config/demo-defaults.data.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
308
orchestrator/src/server/config/demo-defaults.ts
Normal file
308
orchestrator/src/server/config/demo-defaults.ts
Normal 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,
|
||||
};
|
||||
76
orchestrator/src/server/config/demo.ts
Normal file
76
orchestrator/src/server/config/demo.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
69
orchestrator/src/server/services/demo-mode.ts
Normal file
69
orchestrator/src/server/services/demo-mode.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
149
orchestrator/src/server/services/demo-seed.test.ts
Normal file
149
orchestrator/src/server/services/demo-seed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
orchestrator/src/server/services/demo-seed.ts
Normal file
127
orchestrator/src/server/services/demo-seed.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
158
orchestrator/src/server/services/demo-simulator.ts
Normal file
158
orchestrator/src/server/services/demo-simulator.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user