Database backups (#75)

* initial commit

* test fix

* manual test

* copilot comments

* code quality skill

* comments

* fix types problem

* formatting

* tests now correct for new backup method

* UTC dates
This commit is contained in:
Shaheer Sarfaraz 2026-02-02 00:07:39 +00:00 committed by GitHub
parent 2841a7fe3a
commit 179deffe13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2035 additions and 53 deletions

View File

@ -0,0 +1,145 @@
---
name: orchestrator-code-quality
description: Generate a code quality report for the orchestrator/ folder focusing on duplication, complexity hotspots, and error/logging consistency, using npm run check:all as the primary gate; report only (no code changes).
license: MIT
compatibility: opencode
metadata:
audience: maintainers
repo_area: orchestrator
primary_command: npm run check:all
---
## What I do
- Operate strictly within the `orchestrator/` folder.
- Produce a Markdown report describing:
1. **Duplication / near-duplication**
2. **Over-complex functions / “god modules”**
3. **Inconsistent error handling and logging**
- Use `npm run check:all` as the authoritative signal for tests + linting.
- Do **not** change code, configuration, scripts, or dependencies. This skill outputs a report only.
## When to use me
Use this when you want an evidence-driven code quality assessment and a prioritized plan for remediation **without implementing changes yet**.
## Constraints
- Work only in `orchestrator/`. Do not analyze or reference other folders except when required to understand imports into `orchestrator/`.
- Do not propose changes to tooling. Assume `npm run check:all` is the existing gate.
- Do not perform refactors. Do not open PRs. Do not rewrite files. Report findings and recommended fixes only.
## Inputs I expect
- Repository access with an `orchestrator/` directory.
- Ability to run `npm run check:all` from within `orchestrator/`.
- (Optional but recommended) Output logs from `npm run check:all` if the environment cannot run commands.
## Process
1. **Set working area**
- Ensure all investigation is scoped to `orchestrator/`.
2. **Execute quality gate**
- Run `npm run check:all` in `orchestrator/`.
- Capture:
- lint errors/warnings (rule IDs, counts, file paths)
- failing tests (test names, stack traces, determinism vs flakiness indicators)
- coverage output (if produced by the command)
3. **Assess duplication**
- Identify copy/paste and near-duplicate logic patterns in `orchestrator/`:
- repeated parsing/validation
- repeated error mapping and response shaping
- repeated API call wrappers / fetch patterns
- repeated UI hooks/components with minor differences (if applicable)
- Prefer evidence:
- “same structure appears in files A/B/C”
- “multiple implementations differ only by X/Y flags”
- If the repo already includes duplication tooling, use it; otherwise rely on code inspection and minimal search.
4. **Assess complexity hotspots**
- Identify functions/modules with:
- deep nesting
- long functions
- mixed responsibilities (validation + IO + transformation + logging)
- difficult-to-test implicit dependencies
- Provide concrete hotspots (file paths, function names) and why theyre hotspots.
5. **Assess error handling and logging consistency**
- Check for:
- multiple error shapes (different status codes/messages for same category)
- swallowed errors vs raw throws
- missing context/correlation fields in logs
- potentially sensitive data in logs
- Identify boundary points (handlers, adapters) where standardization should occur.
6. **Synthesize a fix plan**
- Provide a prioritized plan with blast radius assessment and test needs.
- Do not implement. Do not request changes. Report recommended actions only.
## Report output format
Create a single Markdown report containing these sections:
### 1. Scope and constraints
- Confirm `orchestrator/` scope and reliance on `npm run check:all`.
### 2. Evidence summary
- `npm run check:all` outcome:
- Lint: top rule IDs + counts + representative file paths
- Tests: failing tests list + deterministic vs flaky notes
- Coverage: summary if present
### 3. Whats wrong and why it matters
Include these subsections (at minimum):
#### 3.1 Duplication and near-duplication
- Symptoms observed (with file-level examples)
- Why it matters in this repo
- What should be done to fix it (safe refactor tactics + tests needed)
#### 3.2 Over-complex functions and “god modules”
- Hotspots (file paths + functions)
- Risk explanation (bug surface, change friction)
- What should be done (decomposition targets + boundaries + tests)
#### 3.3 Inconsistent error handling and logging
- Inconsistencies found (examples)
- Operational/security impact
- What should be done (standard error types/mapping + logging conventions)
### 4. Fix strategy (prioritized)
- Phase 1 — Reduce duplication safely
- Phase 2 — Decrease complexity in hotspots
- Phase 3 — Standardize error handling and logging
Each phase must include:
- Ordered list of actions
- Expected blast radius (low/medium/high)
- Required test additions (if any)
- Validation steps (how to confirm improvement via `npm run check:all`)
### 5. Definition of done
- `npm run check:all` passes reliably
- Measurable reduction in targeted duplication clusters
- Complexity hotspots reduced or isolated behind clear boundaries
- Error/logging behavior consistent at boundaries
- No coverage regressions (if coverage is tracked)
## Quality bar for recommendations
- Be specific: include file paths, function names, rule IDs, and observed patterns.
- Be conservative: prefer small, behavior-preserving refactors in the plan.
- Avoid “make it green” shortcuts:
- do not recommend disabling lint rules/tests as a first move
- if quarantining a flaky test is suggested, include a concrete follow-up requirement and deadline policy

View File

@ -8,6 +8,7 @@ import type {
ApplicationStage,
ApplicationTask,
AppSettings,
BackupInfo,
CreateJobInput,
Job,
JobOutcome,
@ -474,3 +475,25 @@ export async function updateVisaSponsorList(): Promise<{
}
// Bulk operations (intentionally none - processing is manual)
// Backup API
export interface BackupListResponse {
backups: BackupInfo[];
nextScheduled: string | null;
}
export async function getBackups(): Promise<BackupListResponse> {
return fetchApi<BackupListResponse>("/backups");
}
export async function createManualBackup(): Promise<BackupInfo> {
return fetchApi<BackupInfo>("/backups", {
method: "POST",
});
}
export async function deleteBackup(filename: string): Promise<void> {
await fetchApi<void>(`/backups/${encodeURIComponent(filename)}`, {
method: "DELETE",
});
}

View File

@ -114,6 +114,16 @@ const baseSettings: AppSettings = {
webhookSecretHint: null,
basicAuthActive: false,
rxresumeBaseResumeId: null,
// Backup settings
backupEnabled: false,
defaultBackupEnabled: false,
overrideBackupEnabled: null,
backupHour: 2,
defaultBackupHour: 2,
overrideBackupHour: null,
backupMaxCount: 5,
defaultBackupMaxCount: 5,
overrideBackupMaxCount: null,
};
const renderPage = () => {

View File

@ -1,5 +1,6 @@
import * as api from "@client/api";
import { PageHeader } from "@client/components/layout";
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
@ -22,13 +23,14 @@ import {
} from "@shared/settings-schema";
import type {
AppSettings,
BackupInfo,
JobStatus,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
} from "@shared/types";
import { Settings } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { Accordion } from "@/components/ui/accordion";
@ -67,6 +69,9 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
ukvisajobsPassword: "",
webhookSecret: "",
enableBasicAuth: false,
backupEnabled: null,
backupHour: null,
backupMaxCount: null,
};
type LlmProviderValue = LlmProviderId | null;
@ -107,6 +112,9 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
ukvisajobsPassword: null,
webhookSecret: null,
enableBasicAuth: undefined,
backupEnabled: null,
backupHour: null,
backupMaxCount: null,
};
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
@ -141,6 +149,9 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
ukvisajobsPassword: "",
webhookSecret: "",
enableBasicAuth: data.basicAuthActive,
backupEnabled: data.overrideBackupEnabled,
backupHour: data.overrideBackupHour,
backupMaxCount: data.overrideBackupMaxCount,
});
const normalizeString = (value: string | null | undefined) => {
@ -307,6 +318,21 @@ const getDerivedSettings = (settings: AppSettings | null) => {
profileProjects,
maxProjectsTotal: profileProjects.length,
backup: {
backupEnabled: {
effective: settings?.backupEnabled ?? false,
default: settings?.defaultBackupEnabled ?? false,
},
backupHour: {
effective: settings?.backupHour ?? 2,
default: settings?.defaultBackupHour ?? 2,
},
backupMaxCount: {
effective: settings?.backupMaxCount ?? 5,
default: settings?.defaultBackupMaxCount ?? 5,
},
},
};
};
@ -326,6 +352,13 @@ export const SettingsPage: React.FC = () => {
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] =
useState(false);
// Backup state
const [backups, setBackups] = useState<BackupInfo[]>([]);
const [nextScheduled, setNextScheduled] = useState<string | null>(null);
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(
updateSettingsSchema,
@ -446,8 +479,68 @@ export const SettingsPage: React.FC = () => {
envSettings,
defaultResumeProjects,
profileProjects,
backup,
} = derived;
// Backup functions
const loadBackups = useCallback(async () => {
setIsLoadingBackups(true);
try {
const response = await api.getBackups();
setBackups(response.backups);
setNextScheduled(response.nextScheduled);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load backups";
toast.error(message);
} finally {
setIsLoadingBackups(false);
}
}, []);
const handleCreateBackup = async () => {
setIsCreatingBackup(true);
try {
await api.createManualBackup();
toast.success("Backup created successfully");
await loadBackups();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to create backup";
toast.error(message);
} finally {
setIsCreatingBackup(false);
}
};
const handleDeleteBackup = async (filename: string) => {
const confirmed = window.confirm(
`Delete backup "${filename}"? This action cannot be undone.`,
);
if (!confirmed) {
return;
}
setIsDeletingBackup(true);
try {
await api.deleteBackup(filename);
toast.success("Backup deleted successfully");
await loadBackups();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to delete backup";
toast.error(message);
} finally {
setIsDeletingBackup(false);
}
};
// Load backups when settings are loaded
useEffect(() => {
if (settings) {
loadBackups();
}
}, [settings, loadBackups]);
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
@ -589,6 +682,15 @@ export const SettingsPage: React.FC = () => {
jobspy.isRemote.default,
),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
backupEnabled: nullIfSame(
data.backupEnabled,
backup.backupEnabled.default,
),
backupHour: nullIfSame(data.backupHour, backup.backupHour.default),
backupMaxCount: nullIfSame(
data.backupMaxCount,
backup.backupMaxCount.default,
),
...envPayload,
};
@ -751,6 +853,17 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<BackupSettingsSection
values={backup}
backups={backups}
nextScheduled={nextScheduled}
isLoading={isLoading || isLoadingBackups}
isSaving={isSaving}
onCreateBackup={handleCreateBackup}
onDeleteBackup={handleDeleteBackup}
isCreatingBackup={isCreatingBackup}
isDeletingBackup={isDeletingBackup}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}

View File

@ -0,0 +1,283 @@
import { EmptyState, ListItem, ListPanel } from "@client/components/layout";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { BackupValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type { BackupInfo } from "@shared/types";
import { Archive, Clock, Trash2 } from "lucide-react";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
type BackupSettingsSectionProps = {
values: BackupValues;
backups: BackupInfo[];
nextScheduled: string | null;
isLoading: boolean;
isSaving: boolean;
onCreateBackup: () => void;
onDeleteBackup: (filename: string) => void;
isCreatingBackup: boolean;
isDeletingBackup: boolean;
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatBackupDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString("en-US", {
timeZone: "UTC",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZoneName: "short",
});
};
export const BackupSettingsSection: React.FC<BackupSettingsSectionProps> = ({
values,
backups,
nextScheduled,
isLoading,
isSaving,
onCreateBackup,
onDeleteBackup,
isCreatingBackup,
isDeletingBackup,
}) => {
const { backupEnabled, backupHour, backupMaxCount } = values;
const { control, watch } = useFormContext<UpdateSettingsInput>();
// Watch the current form value to conditionally show/hide fields
const currentBackupEnabled = watch("backupEnabled") ?? backupEnabled.default;
return (
<AccordionItem value="backup" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Backup</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
{/* Enable automatic backups toggle */}
<div className="flex items-start space-x-3">
<Controller
name="backupEnabled"
control={control}
render={({ field }) => (
<Checkbox
id="backupEnabled"
checked={field.value ?? backupEnabled.default}
onCheckedChange={(checked) => {
field.onChange(
checked === "indeterminate" ? null : checked === true,
);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="backupEnabled"
className="text-sm font-medium leading-none cursor-pointer"
>
Enable automatic backups
</label>
<p className="text-xs text-muted-foreground">
Automatically create database backups on a daily schedule.
Manual backups can always be created regardless of this setting.
</p>
</div>
</div>
{/* Backup settings - only shown when enabled */}
{currentBackupEnabled && (
<div className="grid gap-6 md:grid-cols-2 pl-7">
<Controller
name="backupHour"
control={control}
render={({ field }) => (
<SettingsInput
label="Backup Hour"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 0,
max: 23,
value: field.value ?? backupHour.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null);
} else {
field.onChange(Math.min(23, Math.max(0, value)));
}
},
}}
disabled={isLoading || isSaving}
helper={`Hour of the day (0-23) in UTC when automatic backups should run. Default: ${backupHour.default}:00 UTC.`}
current={`Effective: ${backupHour.effective}:00 UTC | Default: ${backupHour.default}:00 UTC`}
/>
)}
/>
<Controller
name="backupMaxCount"
control={control}
render={({ field }) => (
<SettingsInput
label="Max Backups to Keep"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 5,
value: field.value ?? backupMaxCount.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null);
} else {
field.onChange(Math.min(5, Math.max(1, value)));
}
},
}}
disabled={isLoading || isSaving}
helper={`Maximum number of automatic backups to retain (1-5). Older backups are deleted automatically. Default: ${backupMaxCount.default}.`}
current={`Effective: ${backupMaxCount.effective} | Default: ${backupMaxCount.default}`}
/>
)}
/>
</div>
)}
{/* Next scheduled backup */}
{currentBackupEnabled && nextScheduled && (
<div className="flex items-center gap-2 text-sm text-muted-foreground pl-7">
<Clock className="h-4 w-4" />
<span>
Next scheduled backup: {formatBackupDate(nextScheduled)}
</span>
</div>
)}
<Separator />
{/* Backup list */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Backup History</div>
<Button
size="sm"
onClick={onCreateBackup}
disabled={isLoading || isCreatingBackup || isDeletingBackup}
>
{isCreatingBackup ? "Creating..." : "Create Backup Now"}
</Button>
</div>
<ListPanel
header={
<div className="flex items-center justify-between text-sm">
<span>
{backups.length} backup{backups.length !== 1 ? "s" : ""}
</span>
</div>
}
>
{backups.length === 0 ? (
<EmptyState
icon={Archive}
title="No backups yet"
description="Create your first backup to protect your data."
/>
) : (
backups.map((backup) => (
<ListItem
key={backup.filename}
className="flex items-center justify-between"
>
<div className="flex items-center gap-3 min-w-0">
<Archive className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{backup.filename}
</div>
<div className="text-xs text-muted-foreground">
{formatBackupDate(backup.createdAt)} ·{" "}
{formatFileSize(backup.size)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={
backup.type === "auto" ? "secondary" : "default"
}
className="text-xs"
>
{backup.type === "auto" ? "Auto" : "Manual"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDeleteBackup(backup.filename)}
disabled={isDeletingBackup || isCreatingBackup}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</ListItem>
))
)}
</ListPanel>
</div>
<Separator />
{/* Effective/Default values display */}
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div>
<div className="text-xs text-muted-foreground">Enabled</div>
<div className="break-words font-mono text-xs">
Effective: {backupEnabled.effective ? "Yes" : "No"} | Default:{" "}
{backupEnabled.default ? "Yes" : "No"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Hour</div>
<div className="break-words font-mono text-xs">
Effective: {backupHour.effective}:00 UTC | Default:{" "}
{backupHour.default}:00 UTC
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Max Count</div>
<div className="break-words font-mono text-xs">
Effective: {backupMaxCount.effective} | Default:{" "}
{backupMaxCount.default}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -42,3 +42,9 @@ export type EnvSettingsValues = {
};
basicAuthActive: boolean;
};
export type BackupValues = {
backupEnabled: EffectiveDefault<boolean>;
backupHour: EffectiveDefault<number>;
backupMaxCount: EffectiveDefault<number>;
};

View File

@ -3,6 +3,7 @@
*/
import { Router } from "express";
import { backupRouter } from "./routes/backup.js";
import { databaseRouter } from "./routes/database.js";
import { jobsRouter } from "./routes/jobs.js";
import { manualJobsRouter } from "./routes/manual-jobs.js";
@ -26,3 +27,4 @@ apiRouter.use("/profile", profileRouter);
apiRouter.use("/database", databaseRouter);
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
apiRouter.use("/onboarding", onboardingRouter);
apiRouter.use("/backups", backupRouter);

View File

@ -0,0 +1,122 @@
import fs from "node:fs";
import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, stopServer } from "./test-utils.js";
describe.sequential("Backup API routes", () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
describe("GET /api/backups", () => {
it("should return empty array when no backups exist", async () => {
const res = await fetch(`${baseUrl}/api/backups`);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.backups).toEqual([]);
expect(body.data.nextScheduled).toBeNull();
});
it("should list backups with metadata", async () => {
// Create a backup first
await fetch(`${baseUrl}/api/backups`, { method: "POST" });
const res = await fetch(`${baseUrl}/api/backups`);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.backups).toHaveLength(1);
expect(body.data.backups[0]).toHaveProperty("filename");
expect(body.data.backups[0]).toHaveProperty("type", "manual");
expect(body.data.backups[0]).toHaveProperty("size");
expect(body.data.backups[0]).toHaveProperty("createdAt");
});
});
describe("POST /api/backups", () => {
it("should create a manual backup", async () => {
const res = await fetch(`${baseUrl}/api/backups`, { method: "POST" });
const body = await res.json();
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.type).toBe("manual");
expect(body.data.filename).toMatch(
/^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}\.db$/,
);
expect(body.data.size).toBeGreaterThan(0);
});
it("should return error if database does not exist", async () => {
// Delete the database
await fs.promises.unlink(`${tempDir}/jobs.db`);
const res = await fetch(`${baseUrl}/api/backups`, { method: "POST" });
const body = await res.json();
expect(res.status).toBe(500);
expect(body.success).toBe(false);
expect(body.error).toContain("Database file not found");
});
});
describe("DELETE /api/backups/:filename", () => {
it("should delete a backup", async () => {
// Create a backup first
const createRes = await fetch(`${baseUrl}/api/backups`, {
method: "POST",
});
const createBody = await createRes.json();
const filename = createBody.data.filename;
// Delete the backup
const deleteRes = await fetch(`${baseUrl}/api/backups/${filename}`, {
method: "DELETE",
});
const deleteBody = await deleteRes.json();
expect(deleteRes.status).toBe(200);
expect(deleteBody.success).toBe(true);
expect(deleteBody.message).toContain("deleted successfully");
// Verify it's gone
const listRes = await fetch(`${baseUrl}/api/backups`);
const listBody = await listRes.json();
expect(listBody.data.backups).toHaveLength(0);
});
it("should return 404 for non-existent backup", async () => {
const res = await fetch(`${baseUrl}/api/backups/jobs_2026_01_01.db`, {
method: "DELETE",
});
const body = await res.json();
expect(res.status).toBe(404);
expect(body.success).toBe(false);
expect(body.error).toContain("not found");
});
it("should return 400 for invalid filename", async () => {
const res = await fetch(`${baseUrl}/api/backups/invalid_filename.txt`, {
method: "DELETE",
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toContain("Invalid");
});
});
});

View File

@ -0,0 +1,93 @@
import {
createBackup,
deleteBackup,
getNextBackupTime,
listBackups,
} from "@server/services/backup/index.js";
import { type Request, type Response, Router } from "express";
export const backupRouter = Router();
/**
* GET /api/backups - List all backups with metadata
*/
backupRouter.get("/", async (_req: Request, res: Response) => {
try {
const backups = await listBackups();
const nextScheduled = getNextBackupTime();
res.json({
success: true,
data: {
backups,
nextScheduled,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("❌ [backup-api] Failed to list backups:", error);
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/backups - Create a manual backup
*/
backupRouter.post("/", async (_req: Request, res: Response) => {
try {
const filename = await createBackup("manual");
const backups = await listBackups();
const backup = backups.find((b) => b.filename === filename);
if (!backup) {
throw new Error("Backup was created but not found in list");
}
res.json({
success: true,
data: backup,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("❌ [backup-api] Failed to create backup:", error);
res.status(500).json({ success: false, error: message });
}
});
/**
* DELETE /api/backups/:filename - Delete a specific backup
*/
backupRouter.delete("/:filename", async (req: Request, res: Response) => {
try {
const { filename } = req.params;
if (!filename) {
res.status(400).json({
success: false,
error: "Filename is required",
});
return;
}
await deleteBackup(filename);
res.json({
success: true,
message: `Backup ${filename} deleted successfully`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(
`❌ [backup-api] Failed to delete backup ${req.params.filename}:`,
error,
);
if (message.includes("not found")) {
res.status(404).json({ success: false, error: message });
} else if (message.includes("Invalid")) {
res.status(400).json({ success: false, error: message });
} else {
res.status(500).json({ success: false, error: message });
}
}
});

View File

@ -1,4 +1,5 @@
import * as settingsRepo from "@server/repositories/settings.js";
import { setBackupSettings } from "@server/services/backup/index.js";
import {
applyEnvValue,
normalizeEnvInput,
@ -328,9 +329,53 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
);
}
// Backup settings
if ("backupEnabled" in input) {
const val = input.backupEnabled ?? null;
promises.push(
settingsRepo.setSetting(
"backupEnabled",
val !== null ? (val ? "1" : "0") : null,
),
);
}
if ("backupHour" in input) {
const val = input.backupHour ?? null;
promises.push(
settingsRepo.setSetting(
"backupHour",
val !== null ? String(val) : null,
),
);
}
if ("backupMaxCount" in input) {
const val = input.backupMaxCount ?? null;
promises.push(
settingsRepo.setSetting(
"backupMaxCount",
val !== null ? String(val) : null,
),
);
}
await Promise.all(promises);
const data = await getEffectiveSettings();
// Update backup scheduler if backup settings changed
if (
"backupEnabled" in input ||
"backupHour" in input ||
"backupMaxCount" in input
) {
setBackupSettings({
enabled: data.backupEnabled,
hour: data.backupHour,
maxCount: data.backupMaxCount,
});
}
res.json({ success: true, data });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -4,6 +4,12 @@
import "./config/env.js";
import { createApp } from "./app.js";
import * as settingsRepo from "./repositories/settings.js";
import {
getBackupSettings,
setBackupSettings,
startBackupScheduler,
} from "./services/backup/index.js";
import { applyStoredEnvOverrides } from "./services/envSettings.js";
import { initialize as initializeVisaSponsors } from "./services/visa-sponsors/index.js";
@ -35,6 +41,45 @@ async function startServer() {
} catch (error) {
console.warn("⚠️ Failed to initialize visa sponsors service:", error);
}
// Initialize backup service (load settings and start scheduler if enabled)
try {
const backupEnabled = await settingsRepo.getSetting("backupEnabled");
const backupHour = await settingsRepo.getSetting("backupHour");
const backupMaxCount = await settingsRepo.getSetting("backupMaxCount");
const parsedHour = backupHour ? parseInt(backupHour, 10) : NaN;
const parsedMaxCount = backupMaxCount
? parseInt(backupMaxCount, 10)
: NaN;
const safeHour = Number.isNaN(parsedHour)
? 2
: Math.min(23, Math.max(0, parsedHour));
const safeMaxCount = Number.isNaN(parsedMaxCount)
? 5
: Math.min(5, Math.max(1, parsedMaxCount));
setBackupSettings({
enabled: backupEnabled === "true" || backupEnabled === "1",
hour: safeHour,
maxCount: safeMaxCount,
});
startBackupScheduler();
const settings = getBackupSettings();
if (settings.enabled) {
console.log(
`✅ Backup scheduler started (hour: ${settings.hour}, max: ${settings.maxCount})`,
);
} else {
console.log(
" Backups disabled. Enable in settings to schedule automatic backups.",
);
}
} catch (error) {
console.warn("⚠️ Failed to initialize backup service:", error);
}
});
}

View File

@ -37,7 +37,10 @@ export type SettingKey =
| "basicAuthPassword"
| "ukvisajobsEmail"
| "ukvisajobsPassword"
| "webhookSecret";
| "webhookSecret"
| "backupEnabled"
| "backupHour"
| "backupMaxCount";
export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key));

View File

@ -0,0 +1,366 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as backup from "./index.js";
// Mock the dataDir module
vi.mock("../../config/dataDir.js", () => ({
getDataDir: vi.fn(),
}));
import { getDataDir } from "../../config/dataDir.js";
describe("Backup Service", () => {
let tempDir: string;
let dbPath: string;
beforeEach(async () => {
// Create temp directory for tests
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "backup-test-"));
dbPath = path.join(tempDir, "jobs.db");
// Create a real SQLite database file for backup() to work.
const db = new Database(dbPath);
try {
db.exec(
[
"PRAGMA journal_mode = DELETE;",
"CREATE TABLE IF NOT EXISTS test_items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
"DELETE FROM test_items;",
"INSERT INTO test_items (name) VALUES ('alpha');",
].join("\n"),
);
} finally {
db.close();
}
// Mock getDataDir to return temp directory
vi.mocked(getDataDir).mockReturnValue(tempDir);
// Reset backup settings
backup.setBackupSettings({ enabled: false, hour: 2, maxCount: 5 });
backup.stopBackupScheduler();
});
afterEach(async () => {
// Clean up temp directory
try {
await fs.promises.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
vi.clearAllMocks();
});
describe("createBackup", () => {
it("should create an automatic backup with correct filename format", async () => {
const filename = await backup.createBackup("auto");
// Check filename format: jobs_YYYY_MM_DD.db
expect(filename).toMatch(/^jobs_\d{4}_\d{2}_\d{2}\.db$/);
// Check file was created
const backupPath = path.join(tempDir, filename);
expect(fs.existsSync(backupPath)).toBe(true);
// Check backup is a valid SQLite database with expected data
const backupDb = new Database(backupPath, {
readonly: true,
fileMustExist: true,
});
try {
const row = backupDb
.prepare("SELECT name FROM test_items ORDER BY id LIMIT 1")
.get() as { name: string } | undefined;
expect(row?.name).toBe("alpha");
} finally {
backupDb.close();
}
});
it("should create a manual backup with correct filename format", async () => {
const filename = await backup.createBackup("manual");
// Check filename format: jobs_manual_YYYY_MM_DD_HH_MM_SS.db
expect(filename).toMatch(
/^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}\.db$/,
);
// Check file was created
const backupPath = path.join(tempDir, filename);
expect(fs.existsSync(backupPath)).toBe(true);
const backupDb = new Database(backupPath, {
readonly: true,
fileMustExist: true,
});
try {
const count = backupDb
.prepare("SELECT COUNT(*) as count FROM test_items")
.get() as { count: number };
expect(count.count).toBe(1);
} finally {
backupDb.close();
}
});
it("should add a suffix when manual backup name collides", async () => {
// Only fake Date to keep async I/O (used by better-sqlite3 backup) real.
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date("2026-01-15T12:30:45Z"));
const first = await backup.createBackup("manual");
const second = await backup.createBackup("manual");
expect(first).toBe("jobs_manual_2026_01_15_12_30_45.db");
expect(second).toBe("jobs_manual_2026_01_15_12_30_45_1.db");
expect(fs.existsSync(path.join(tempDir, second))).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("should throw error if database does not exist", async () => {
// Delete the database
await fs.promises.unlink(dbPath);
await expect(backup.createBackup("auto")).rejects.toThrow(
"Database file not found",
);
});
});
describe("listBackups", () => {
it("should return empty array when no backups exist", async () => {
const backups = await backup.listBackups();
expect(backups).toEqual([]);
});
it("should list all backups with metadata", async () => {
// Create some backups
await backup.createBackup("auto");
await backup.createBackup("manual");
const backups = await backup.listBackups();
expect(backups).toHaveLength(2);
expect(backups[0]).toHaveProperty("filename");
expect(backups[0]).toHaveProperty("type");
expect(backups[0]).toHaveProperty("size");
expect(backups[0]).toHaveProperty("createdAt");
});
it("should sort backups by date (newest first)", async () => {
// Create backups with different dates by manipulating filenames
const oldBackup = path.join(tempDir, "jobs_2026_01_01.db");
const newBackup = path.join(tempDir, "jobs_2026_01_15.db");
await fs.promises.writeFile(oldBackup, "old");
await fs.promises.writeFile(newBackup, "new");
const backups = await backup.listBackups();
expect(backups[0].filename).toBe("jobs_2026_01_15.db");
expect(backups[1].filename).toBe("jobs_2026_01_01.db");
});
it("should ignore non-backup files", async () => {
// Create a backup and some other files
await backup.createBackup("auto");
await fs.promises.writeFile(path.join(tempDir, "random.txt"), "text");
await fs.promises.writeFile(path.join(tempDir, "jobs.db"), "db");
const backups = await backup.listBackups();
expect(backups).toHaveLength(1);
expect(backups[0].filename).toMatch(/^jobs_\d{4}_\d{2}_\d{2}\.db$/);
});
it("should include suffixed manual backups", async () => {
const filename = "jobs_manual_2026_01_01_12_00_00_2.db";
await fs.promises.writeFile(path.join(tempDir, filename), "manual");
const backups = await backup.listBackups();
expect(backups).toHaveLength(1);
expect(backups[0].filename).toBe(filename);
expect(backups[0].type).toBe("manual");
expect(backups[0].createdAt).toBe("2026-01-01T12:00:00.000Z");
});
});
describe("deleteBackup", () => {
it("should delete a backup file", async () => {
const filename = await backup.createBackup("auto");
const backupPath = path.join(tempDir, filename);
expect(fs.existsSync(backupPath)).toBe(true);
await backup.deleteBackup(filename);
expect(fs.existsSync(backupPath)).toBe(false);
});
it("should throw error for invalid filename", async () => {
await expect(backup.deleteBackup("../../../etc/passwd")).rejects.toThrow(
"Invalid backup filename",
);
await expect(backup.deleteBackup("random.txt")).rejects.toThrow(
"Invalid backup filename",
);
});
it("should throw error if backup does not exist", async () => {
await expect(backup.deleteBackup("jobs_2026_01_01.db")).rejects.toThrow(
"Backup not found",
);
});
it("should delete a suffixed manual backup", async () => {
const filename = "jobs_manual_2026_01_01_12_00_00_1.db";
await fs.promises.writeFile(path.join(tempDir, filename), "manual");
await backup.deleteBackup(filename);
expect(fs.existsSync(path.join(tempDir, filename))).toBe(false);
});
});
describe("cleanupOldBackups", () => {
it("should delete oldest automatic backups when exceeding max count", async () => {
// Create 7 auto backups (max is 5)
for (let i = 1; i <= 7; i++) {
const filename = `jobs_2026_01_${String(i).padStart(2, "0")}.db`;
await fs.promises.writeFile(path.join(tempDir, filename), "data");
}
// Set max count to 5
backup.setBackupSettings({ maxCount: 5 });
await backup.cleanupOldBackups();
const remaining = await backup.listBackups();
expect(remaining).toHaveLength(5);
// Should keep the 5 newest (03-07)
const filenames = remaining.map((b) => b.filename);
expect(filenames).toContain("jobs_2026_01_03.db");
expect(filenames).toContain("jobs_2026_01_07.db");
expect(filenames).not.toContain("jobs_2026_01_01.db");
expect(filenames).not.toContain("jobs_2026_01_02.db");
});
it("should not delete manual backups", async () => {
// Create auto backups
for (let i = 1; i <= 7; i++) {
const filename = `jobs_2026_01_${String(i).padStart(2, "0")}.db`;
await fs.promises.writeFile(path.join(tempDir, filename), "data");
}
// Create manual backups
await fs.promises.writeFile(
path.join(tempDir, "jobs_manual_2026_01_01_12_00_00.db"),
"manual",
);
await fs.promises.writeFile(
path.join(tempDir, "jobs_manual_2026_01_02_12_00_00.db"),
"manual",
);
backup.setBackupSettings({ maxCount: 5 });
await backup.cleanupOldBackups();
const remaining = await backup.listBackups();
const autoBackups = remaining.filter((b) => b.type === "auto");
const manualBackups = remaining.filter((b) => b.type === "manual");
expect(autoBackups).toHaveLength(5);
expect(manualBackups).toHaveLength(2);
});
it("should not delete if count is within limit", async () => {
// Create 3 auto backups (max is 5)
for (let i = 1; i <= 3; i++) {
const filename = `jobs_2026_01_${String(i).padStart(2, "0")}.db`;
await fs.promises.writeFile(path.join(tempDir, filename), "data");
}
backup.setBackupSettings({ maxCount: 5 });
await backup.cleanupOldBackups();
const remaining = await backup.listBackups();
expect(remaining).toHaveLength(3);
});
});
describe("setBackupSettings", () => {
it("should update settings", () => {
backup.setBackupSettings({ enabled: true, hour: 4, maxCount: 3 });
const settings = backup.getBackupSettings();
expect(settings.enabled).toBe(true);
expect(settings.hour).toBe(4);
expect(settings.maxCount).toBe(3);
});
it("should merge partial settings", () => {
backup.setBackupSettings({ hour: 6 });
const settings = backup.getBackupSettings();
expect(settings.enabled).toBe(false); // unchanged
expect(settings.hour).toBe(6); // updated
expect(settings.maxCount).toBe(5); // unchanged
});
});
describe("scheduler integration", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should start scheduler when enabled", () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
expect(backup.isBackupSchedulerRunning()).toBe(false);
backup.setBackupSettings({ enabled: true, hour: 14 });
expect(backup.isBackupSchedulerRunning()).toBe(true);
expect(backup.getNextBackupTime()).not.toBeNull();
});
it("should stop scheduler when disabled", () => {
backup.setBackupSettings({ enabled: true, hour: 14 });
expect(backup.isBackupSchedulerRunning()).toBe(true);
backup.setBackupSettings({ enabled: false });
expect(backup.isBackupSchedulerRunning()).toBe(false);
expect(backup.getNextBackupTime()).toBeNull();
});
it("should restart scheduler when hour changes", () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
backup.setBackupSettings({ enabled: true, hour: 14 });
const firstRun = backup.getNextBackupTime();
backup.setBackupSettings({ hour: 16 });
const secondRun = backup.getNextBackupTime();
expect(secondRun).not.toBe(firstRun);
expect(secondRun).not.toBeNull();
expect(firstRun).not.toBeNull();
if (secondRun && firstRun) {
expect(new Date(secondRun).getTime()).toBeGreaterThan(
new Date(firstRun).getTime(),
);
}
});
});
});

View File

@ -0,0 +1,398 @@
/**
* Database Backup Service
*
* Manages automatic and manual backups of the SQLite database.
* Stores backups in the same directory as the original database.
*/
import fs from "node:fs";
import type { FileHandle } from "node:fs/promises";
import path from "node:path";
import type { BackupInfo } from "@shared/types.js";
import Database from "better-sqlite3";
import { getDataDir } from "../../config/dataDir.js";
import { createScheduler } from "../../utils/scheduler.js";
const DB_FILENAME = "jobs.db";
const AUTO_BACKUP_PREFIX = "jobs_";
const MANUAL_BACKUP_PREFIX = "jobs_manual_";
const AUTO_BACKUP_PATTERN = /^jobs_\d{4}_\d{2}_\d{2}\.db$/;
const MANUAL_BACKUP_PATTERN =
/^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}(?:_\d+)?\.db$/;
const AUTO_BACKUP_REGEX = /^jobs_(\d{4})_(\d{2})_(\d{2})\.db$/;
const MANUAL_BACKUP_REGEX =
/^jobs_manual_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})(?:_\d+)?\.db$/;
type SqliteDatabase = InstanceType<typeof Database>;
interface BackupSettings {
enabled: boolean;
hour: number;
maxCount: number;
}
// Current settings (updated by setBackupSettings)
let currentSettings: BackupSettings = {
enabled: false,
hour: 2,
maxCount: 5,
};
// Create scheduler for automatic backups
const scheduler = createScheduler("backup", async () => {
await createBackup("auto");
await cleanupOldBackups();
});
/**
* Get the path to the database file
*/
function getDbPath(): string {
return path.join(getDataDir(), DB_FILENAME);
}
/**
* Get the data directory path
*/
function getBackupDir(): string {
return getDataDir();
}
/**
* Generate filename for a backup
*/
function generateBackupFilename(type: "auto" | "manual"): string {
const now = new Date();
if (type === "auto") {
// Format: jobs_YYYY_MM_DD.db (UTC date to match UTC scheduler)
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const day = String(now.getUTCDate()).padStart(2, "0");
return `${AUTO_BACKUP_PREFIX}${year}_${month}_${day}.db`;
} else {
// Format: jobs_manual_YYYY_MM_DD_HH_MM_SS.db (local time for manual backups)
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`;
}
}
/**
* Parse backup filename to extract creation date
*/
function parseBackupDate(filename: string): Date | null {
const autoMatch = filename.match(AUTO_BACKUP_REGEX);
if (autoMatch) {
const [, year, month, day] = autoMatch;
return buildUtcDate(year, month, day, "0", "0", "0");
}
const manualMatch = filename.match(MANUAL_BACKUP_REGEX);
if (manualMatch) {
const [, year, month, day, hours, minutes, seconds] = manualMatch;
return buildUtcDate(year, month, day, hours, minutes, seconds);
}
return null;
}
function buildUtcDate(
yearRaw: string,
monthRaw: string,
dayRaw: string,
hourRaw: string,
minuteRaw: string,
secondRaw: string,
): Date | null {
const year = Number(yearRaw);
const month = Number(monthRaw);
const day = Number(dayRaw);
const hour = Number(hourRaw);
const minute = Number(minuteRaw);
const second = Number(secondRaw);
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
if (Number.isNaN(date.getTime())) {
return null;
}
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day ||
date.getUTCHours() !== hour ||
date.getUTCMinutes() !== minute ||
date.getUTCSeconds() !== second
) {
return null;
}
return date;
}
/**
* Determine backup type from filename
*/
function getBackupType(filename: string): "auto" | "manual" | null {
if (AUTO_BACKUP_PATTERN.test(filename)) return "auto";
if (MANUAL_BACKUP_PATTERN.test(filename)) return "manual";
return null;
}
/**
* Create a backup of the database
* @param type - 'auto' for scheduled backups, 'manual' for user-triggered
* @returns The filename of the created backup
*/
export async function createBackup(type: "auto" | "manual"): Promise<string> {
const dbPath = getDbPath();
const backupDir = getBackupDir();
const baseFilename = generateBackupFilename(type);
let filename = baseFilename;
let backupPath = path.join(backupDir, filename);
let reservedHandle: FileHandle | null = null;
// Check if database exists
if (!fs.existsSync(dbPath)) {
throw new Error(`Database file not found: ${dbPath}`);
}
const tryReserve = async (
candidatePath: string,
): Promise<FileHandle | null> => {
try {
return await fs.promises.open(candidatePath, "wx");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
throw error;
}
};
if (type === "auto") {
reservedHandle = await tryReserve(backupPath);
if (!reservedHandle) {
console.log(
` [backup] Auto backup already exists for today: ${filename}`,
);
return filename;
}
} else {
const baseName = baseFilename.replace(/\.db$/, "");
let sequence = 0;
while (!reservedHandle && sequence <= 100) {
const candidate =
sequence === 0 ? baseFilename : `${baseName}_${sequence}.db`;
const candidatePath = path.join(backupDir, candidate);
const reserved = await tryReserve(candidatePath);
if (reserved) {
reservedHandle = reserved;
filename = candidate;
backupPath = candidatePath;
} else {
sequence += 1;
}
}
if (!reservedHandle) {
throw new Error("Failed to create unique manual backup filename");
}
}
// Close the reserved file handle before running SQLite backup
await reservedHandle.close();
let sqlite: SqliteDatabase | null = null;
try {
sqlite = new Database(dbPath, { readonly: true, fileMustExist: true });
await sqlite.backup(backupPath);
} catch (error) {
await fs.promises.unlink(backupPath).catch(() => undefined);
throw error;
} finally {
sqlite?.close();
}
console.log(
`✅ [backup] Created ${type} backup: ${filename} (${(await fs.promises.stat(backupPath)).size} bytes)`,
);
return filename;
}
/**
* List all backups with metadata
* @returns Array of backup information
*/
export async function listBackups(): Promise<BackupInfo[]> {
const backupDir = getBackupDir();
// Check if directory exists
if (!fs.existsSync(backupDir)) {
return [];
}
// Read directory and filter backup files
const files = await fs.promises.readdir(backupDir);
const backupFiles = files.filter((file) => {
return AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file);
});
// Get metadata for each backup
const backups: BackupInfo[] = [];
for (const filename of backupFiles) {
const filePath = path.join(backupDir, filename);
const type = getBackupType(filename);
const createdAt = parseBackupDate(filename);
if (type && createdAt) {
const stats = await fs.promises.stat(filePath);
backups.push({
filename,
type,
size: stats.size,
createdAt: createdAt.toISOString(),
});
}
}
// Sort by creation date (newest first)
backups.sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return backups;
}
/**
* Delete a specific backup
* @param filename - Name of the backup file to delete
*/
export async function deleteBackup(filename: string): Promise<void> {
// Validate filename to prevent path traversal
if (
!AUTO_BACKUP_PATTERN.test(filename) &&
!MANUAL_BACKUP_PATTERN.test(filename)
) {
throw new Error("Invalid backup filename");
}
const backupDir = getBackupDir();
const filePath = path.join(backupDir, filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error(`Backup not found: ${filename}`);
}
// Delete file
await fs.promises.unlink(filePath);
console.log(`🗑️ [backup] Deleted backup: ${filename}`);
}
/**
* Clean up old automatic backups
* Keeps only the most recent N automatic backups (where N = maxCount)
* Manual backups are never deleted automatically
*/
export async function cleanupOldBackups(): Promise<void> {
const backups = await listBackups();
// Filter to only automatic backups
const autoBackups = backups.filter((b) => b.type === "auto");
// Sort by creation date (oldest first for deletion)
autoBackups.sort((a, b) => {
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
// Delete oldest backups if we exceed max count
const maxCount = currentSettings.maxCount;
if (autoBackups.length > maxCount) {
const toDelete = autoBackups.slice(0, autoBackups.length - maxCount);
for (const backup of toDelete) {
try {
await deleteBackup(backup.filename);
} catch (error) {
console.error(
`❌ [backup] Failed to delete old backup ${backup.filename}:`,
error,
);
}
}
console.log(
`🧹 [backup] Cleaned up ${toDelete.length} old automatic backups (max: ${maxCount})`,
);
}
}
/**
* Update backup settings and restart scheduler if needed
* @param settings - New backup settings
*/
export function setBackupSettings(settings: Partial<BackupSettings>): void {
const oldEnabled = currentSettings.enabled;
const oldHour = currentSettings.hour;
// Update settings
currentSettings = { ...currentSettings, ...settings };
console.log(`⚙️ [backup] Settings updated:`, currentSettings);
// Restart scheduler if settings changed
if (currentSettings.enabled) {
if (!oldEnabled || oldHour !== currentSettings.hour) {
// Start or restart with new hour
scheduler.start(currentSettings.hour);
}
} else if (oldEnabled && !currentSettings.enabled) {
// Stop scheduler
scheduler.stop();
}
}
/**
* Get current backup settings
*/
export function getBackupSettings(): BackupSettings {
return { ...currentSettings };
}
/**
* Get the next scheduled backup time
* @returns ISO string of next backup time, or null if disabled
*/
export function getNextBackupTime(): string | null {
return scheduler.getNextRun();
}
/**
* Check if automatic backup scheduler is running
*/
export function isBackupSchedulerRunning(): boolean {
return scheduler.isRunning();
}
/**
* Start the backup scheduler manually (used on server startup)
* Only starts if backup is enabled
*/
export function startBackupScheduler(): void {
if (currentSettings.enabled) {
scheduler.start(currentSettings.hour);
}
}
/**
* Stop the backup scheduler
*/
export function stopBackupScheduler(): void {
scheduler.stop();
}

View File

@ -184,6 +184,34 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
: null;
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
// Backup settings
const defaultBackupEnabled = false;
const overrideBackupEnabledRaw = overrides.backupEnabled;
const overrideBackupEnabled = overrideBackupEnabledRaw
? overrideBackupEnabledRaw === "true" || overrideBackupEnabledRaw === "1"
: null;
const backupEnabled = overrideBackupEnabled ?? defaultBackupEnabled;
const defaultBackupHour = 2;
const overrideBackupHourRaw = overrides.backupHour;
const parsedBackupHour = overrideBackupHourRaw
? parseInt(overrideBackupHourRaw, 10)
: NaN;
const overrideBackupHour = Number.isNaN(parsedBackupHour)
? null
: Math.min(23, Math.max(0, parsedBackupHour));
const backupHour = overrideBackupHour ?? defaultBackupHour;
const defaultBackupMaxCount = 5;
const overrideBackupMaxCountRaw = overrides.backupMaxCount;
const parsedBackupMaxCount = overrideBackupMaxCountRaw
? parseInt(overrideBackupMaxCountRaw, 10)
: NaN;
const overrideBackupMaxCount = Number.isNaN(parsedBackupMaxCount)
? null
: Math.min(5, Math.max(1, parsedBackupMaxCount));
const backupMaxCount = overrideBackupMaxCount ?? defaultBackupMaxCount;
return {
...envSettings,
model,
@ -242,6 +270,15 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
backupEnabled,
defaultBackupEnabled,
overrideBackupEnabled,
backupHour,
defaultBackupHour,
overrideBackupHour,
backupMaxCount,
defaultBackupMaxCount,
overrideBackupMaxCount,
} as AppSettings;
}

View File

@ -7,6 +7,7 @@
import fs from "node:fs";
import path from "node:path";
import { getDataDir } from "../../config/dataDir.js";
import { createScheduler } from "../../utils/scheduler.js";
const DATA_DIR = path.join(getDataDir(), "visa-sponsors");
@ -492,75 +493,32 @@ export function getOrganizationDetails(
}
// ============================================================================
// Scheduled Updates (Cron-style)
// Scheduled Updates (Cron-style) - Uses shared scheduler utility
// ============================================================================
let scheduledTimer: ReturnType<typeof setTimeout> | null = null;
let nextScheduledUpdateTime: Date | null = null;
/**
* Calculate the next update time (default: 2 AM daily)
*/
function calculateNextUpdateTime(hour = 2): Date {
const now = new Date();
const next = new Date(now);
next.setHours(hour, 0, 0, 0);
// If we've passed the time today, schedule for tomorrow
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next;
}
const scheduler = createScheduler("visa-sponsors", async () => {
await downloadLatestCsv();
});
/**
* Get the next scheduled update time as ISO string
*/
function getNextScheduledUpdate(): string | null {
return nextScheduledUpdateTime?.toISOString() || null;
}
/**
* Schedule the next update
*/
function scheduleNextUpdate(hour = 2): void {
if (scheduledTimer) {
clearTimeout(scheduledTimer);
}
nextScheduledUpdateTime = calculateNextUpdateTime(hour);
const delay = nextScheduledUpdateTime.getTime() - Date.now();
console.log(
`⏰ Next visa sponsor update scheduled for: ${nextScheduledUpdateTime.toISOString()}`,
);
scheduledTimer = setTimeout(async () => {
console.log("🔄 Running scheduled visa sponsor update...");
await downloadLatestCsv();
scheduleNextUpdate(hour); // Schedule the next one
}, delay);
export function getNextScheduledUpdate(): string | null {
return scheduler.getNextRun();
}
/**
* Start the scheduler
*/
export function startScheduler(hour = 2): void {
console.log("🚀 Starting visa sponsor update scheduler...");
scheduleNextUpdate(hour);
scheduler.start(hour);
}
/**
* Stop the scheduler
*/
export function stopScheduler(): void {
if (scheduledTimer) {
clearTimeout(scheduledTimer);
scheduledTimer = null;
nextScheduledUpdateTime = null;
console.log("⏹️ Stopped visa sponsor update scheduler");
}
scheduler.stop();
}
/**

View File

@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { calculateNextTime, createScheduler } from "./scheduler.js";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("calculateNextTime", () => {
it("should return today if hour is in the future", () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const result = calculateNextTime(14); // 2 PM
expect(result.getUTCHours()).toBe(14);
expect(result.getUTCDate()).toBe(15); // Same day
expect(result.getTime()).toBeGreaterThan(now.getTime());
});
it("should return tomorrow if hour has passed today", () => {
const now = new Date("2026-01-15T16:00:00Z");
vi.setSystemTime(now);
const result = calculateNextTime(10); // 10 AM (already passed)
expect(result.getUTCHours()).toBe(10);
expect(result.getUTCDate()).toBe(16); // Next day
});
it("should return tomorrow if current time equals target hour", () => {
const now = new Date("2026-01-15T14:00:00Z");
vi.setSystemTime(now);
const result = calculateNextTime(14); // Same hour
expect(result.getUTCHours()).toBe(14);
expect(result.getUTCDate()).toBe(16); // Next day (since we're at exactly 14:00)
});
it("should handle hour 0 (midnight)", () => {
const now = new Date("2026-01-15T12:00:00Z");
vi.setSystemTime(now);
const result = calculateNextTime(0); // Midnight
expect(result.getUTCHours()).toBe(0);
expect(result.getUTCDate()).toBe(16); // Next day
});
it("should handle hour 23", () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const result = calculateNextTime(23);
expect(result.getUTCHours()).toBe(23);
expect(result.getUTCDate()).toBe(15); // Same day
});
});
describe("createScheduler", () => {
it("should create a scheduler with initial stopped state", () => {
const callback = vi.fn().mockResolvedValue(undefined);
const scheduler = createScheduler("test", callback);
expect(scheduler.isRunning()).toBe(false);
expect(scheduler.getNextRun()).toBeNull();
});
it("should start scheduling when start() is called", () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const callback = vi.fn().mockResolvedValue(undefined);
const scheduler = createScheduler("test", callback);
scheduler.start(14);
expect(scheduler.isRunning()).toBe(true);
expect(scheduler.getNextRun()).not.toBeNull();
});
it("should stop scheduling when stop() is called", () => {
const callback = vi.fn().mockResolvedValue(undefined);
const scheduler = createScheduler("test", callback);
scheduler.start(14);
expect(scheduler.isRunning()).toBe(true);
scheduler.stop();
expect(scheduler.isRunning()).toBe(false);
expect(scheduler.getNextRun()).toBeNull();
});
it("should execute callback after delay (simulated)", async () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const callback = vi.fn().mockResolvedValue(undefined);
const scheduler = createScheduler("test", callback);
// Start at hour 10 tomorrow (24 hours from now in this test)
scheduler.start(10);
// Fast-forward time by 24 hours to trigger the callback
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
expect(callback).toHaveBeenCalledTimes(1);
scheduler.stop();
});
it("should reschedule after execution", async () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const callback = vi.fn().mockResolvedValue(undefined);
const scheduler = createScheduler("test", callback);
scheduler.start(10);
const firstRun = scheduler.getNextRun();
// Fast-forward 24 hours to trigger execution and rescheduling
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
const secondRun = scheduler.getNextRun();
// Second run should be 24 hours after first run
expect(secondRun).not.toBe(firstRun);
expect(secondRun).not.toBeNull();
expect(firstRun).not.toBeNull();
if (secondRun && firstRun) {
expect(new Date(secondRun).getTime()).toBe(
new Date(firstRun).getTime() + 24 * 60 * 60 * 1000,
);
}
scheduler.stop();
});
it("should restart with new hour when start() called again", () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const callback = vi.fn().mockResolvedValue(undefined);
const scheduler = createScheduler("test", callback);
scheduler.start(14); // 2 PM
const firstRun = scheduler.getNextRun();
scheduler.start(16); // 4 PM
const secondRun = scheduler.getNextRun();
// Second run should be later than first run
expect(secondRun).not.toBeNull();
expect(firstRun).not.toBeNull();
if (secondRun && firstRun) {
expect(new Date(secondRun).getTime()).toBeGreaterThan(
new Date(firstRun).getTime(),
);
}
scheduler.stop();
});
it("should handle callback errors gracefully", async () => {
const now = new Date("2026-01-15T10:00:00Z");
vi.setSystemTime(now);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const callback = vi.fn().mockRejectedValue(new Error("Test error"));
const scheduler = createScheduler("test", callback);
scheduler.start(10);
// Fast-forward 24 hours to trigger execution
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
expect(callback).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
"❌ [test] Scheduled task failed:",
expect.any(Error),
);
// Scheduler should still be running after error
expect(scheduler.isRunning()).toBe(true);
scheduler.stop();
consoleSpy.mockRestore();
});
});

View File

@ -0,0 +1,118 @@
/**
* Shared daily scheduler utility for running tasks at a specific hour.
* Used by visa-sponsors and backup services.
*/
export interface Scheduler {
/** Start scheduling at the specified hour (0-23) */
start(hour: number): void;
/** Stop the scheduler */
stop(): void;
/** Get ISO string of next scheduled run, or null if not running */
getNextRun(): string | null;
/** Check if scheduler is currently running */
isRunning(): boolean;
}
interface SchedulerState {
timer: ReturnType<typeof setTimeout> | null;
nextRunTime: Date | null;
currentHour: number | null;
}
/**
* Calculate the next occurrence of a specific hour (UTC)
* @param hour - Hour of day (0-23) in UTC
* @returns Date object set to the next UTC occurrence of that hour
*/
export function calculateNextTime(hour: number): Date {
const now = new Date();
const next = new Date(now);
next.setUTCHours(hour, 0, 0, 0);
// If we've passed the time today, schedule for tomorrow
if (next <= now) {
next.setUTCDate(next.getUTCDate() + 1);
}
return next;
}
/**
* Create a reusable daily scheduler
* @param name - Service name for logging
* @param callback - Async function to execute at scheduled time
* @returns Scheduler interface with start/stop/getNextRun methods
*/
export function createScheduler(
name: string,
callback: () => Promise<void>,
): Scheduler {
const state: SchedulerState = {
timer: null,
nextRunTime: null,
currentHour: null,
};
function clearState(): void {
if (state.timer) {
clearTimeout(state.timer);
}
state.timer = null;
state.nextRunTime = null;
state.currentHour = null;
}
function scheduleNext(hour: number): void {
// Clear any existing timer
if (state.timer) {
clearState();
}
state.currentHour = hour;
state.nextRunTime = calculateNextTime(hour);
const delay = state.nextRunTime.getTime() - Date.now();
console.log(
`⏰ [${name}] Next run scheduled for: ${state.nextRunTime.toISOString()}`,
);
state.timer = setTimeout(async () => {
console.log(`🔄 [${name}] Running scheduled task...`);
try {
await callback();
} catch (error) {
console.error(`❌ [${name}] Scheduled task failed:`, error);
}
// Reschedule for next occurrence
scheduleNext(hour);
}, delay);
}
return {
start(hour: number): void {
if (state.timer) {
console.log(`🔄 [${name}] Restarting scheduler with hour ${hour}...`);
clearState();
} else {
console.log(`🚀 [${name}] Starting scheduler at hour ${hour}...`);
}
scheduleNext(hour);
},
stop(): void {
if (state.timer) {
clearState();
console.log(`⏹️ [${name}] Stopped scheduler`);
}
},
getNextRun(): string | null {
return state.nextRunTime?.toISOString() || null;
},
isRunning(): boolean {
return state.timer !== null;
},
};
}

View File

@ -72,6 +72,9 @@ export const updateSettingsSchema = z
ukvisajobsPassword: z.string().trim().max(2000).nullable().optional(),
webhookSecret: z.string().trim().max(2000).nullable().optional(),
enableBasicAuth: z.boolean().optional(),
backupEnabled: z.boolean().nullable().optional(),
backupHour: z.number().int().min(0).max(23).nullable().optional(),
backupMaxCount: z.number().int().min(1).max(5).nullable().optional(),
})
.superRefine((data, ctx) => {
if (data.enableBasicAuth) {

View File

@ -545,4 +545,21 @@ export interface AppSettings {
ukvisajobsPasswordHint: string | null;
webhookSecretHint: string | null;
basicAuthActive: boolean;
// Backup settings
backupEnabled: boolean;
defaultBackupEnabled: boolean;
overrideBackupEnabled: boolean | null;
backupHour: number;
defaultBackupHour: number;
overrideBackupHour: number | null;
backupMaxCount: number;
defaultBackupMaxCount: number;
overrideBackupMaxCount: number | null;
}
export interface BackupInfo {
filename: string;
type: "auto" | "manual";
size: number;
createdAt: string;
}