feat(test): on-demand prod->test sync + persistent volumes

This commit is contained in:
Hermes
2026-08-23 01:37:53 +00:00
parent bc1bab1006
commit c1d488e03b
5 changed files with 174 additions and 1 deletions
+48
View File
@@ -109,6 +109,13 @@ export default function AdminPage() {
const [editingOverride, setEditingOverride] = useState<Record<number, boolean>>({});
const [showIssuesOnly, setShowIssuesOnly] = useState(false);
const [manualPunchError, setManualPunchError] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [isTestEnv, setIsTestEnv] = useState(false);
useEffect(() => {
// Only show sync button on pulsy-test host (even if code ships to prod, prod will hide it)
setIsTestEnv(window.location.hostname.includes('pulsy-test'));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -145,6 +152,28 @@ export default function AdminPage() {
.catch(console.error);
}, [selectedCompany, startDate, endDate]);
const handleSyncFromProd = async () => {
if (!confirm('Sync from prod will WIPE the test DB and replace it with a copy of prod. Continue?')) return;
setSyncing(true);
setSyncMsg(null);
try {
const res = await fetchApi<{ ok: boolean; bytes: number; tables: number; summary: string }>(
'/api/admin/sync-from-prod',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true }),
},
);
setSyncMsg(`Synced ${Math.round(res.bytes / 1024)}KB · ${res.tables} tables · ${res.summary}`);
loadEntries();
} catch (e) {
setSyncMsg(e instanceof Error ? e.message : 'Sync failed');
} finally {
setSyncing(false);
}
};
useEffect(() => {
loadEntries();
}, [loadEntries]);
@@ -448,6 +477,16 @@ export default function AdminPage() {
<button className="btn btn-outline btn-sm" onClick={loadEntries}>
Refresh
</button>
{isTestEnv && (
<button
className="btn btn-error btn-sm"
onClick={handleSyncFromProd}
disabled={syncing}
title="Wipes test DB and copies prod"
>
{syncing ? <span className="loading loading-spinner loading-xs" /> : '⬇'} Sync from Prod
</button>
)}
<button
className={`btn btn-sm ${showIssuesOnly ? 'btn-warning' : 'btn-outline'}`}
onClick={() => setShowIssuesOnly((v) => !v)}
@@ -523,6 +562,15 @@ export default function AdminPage() {
</div>
</div>
{syncMsg && (
<div className="max-w-7xl mx-auto px-4 pt-2">
<div className="alert alert-info text-sm py-2">
<span>{syncMsg}</span>
<button className="btn btn-ghost btn-xs ml-auto" onClick={() => setSyncMsg(null)}><X className="size-3" /></button>
</div>
</div>
)}
{/* Time Grid */}
<div className="p-4 max-w-7xl mx-auto overflow-x-auto">
<div className="min-w-[800px]">
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawnSync } from 'child_process';
import path from 'path';
import fs from 'fs';
function isAuthorized(req: NextRequest): boolean {
const token = req.headers.get('x-sync-token') ?? req.nextUrl.searchParams.get('token') ?? '';
const expected = process.env.PULSE_SYNC_TOKEN;
if (!expected || !token) return false;
if (token.length !== expected.length) return false;
// constant-time-ish
let ok = true;
for (let i = 0; i < token.length; i++) if (token[i] !== expected[i]) ok = false;
if (!ok) return false;
return true;
}
function resolveDbPath(): string {
const raw = process.env.DATABASE_URL ?? '/app/data/pulse-clock.db';
if (raw.startsWith('file:')) return raw.slice(5);
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
}
export async function GET(request: NextRequest) {
if (!isAuthorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbPath = resolveDbPath();
if (!fs.existsSync(dbPath)) {
return NextResponse.json({ error: 'DB file not found', path: dbPath }, { status: 404 });
}
const res = spawnSync('sqlite3', [dbPath, '.dump'], { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024 });
if (res.status !== 0) {
return NextResponse.json({ error: 'dump failed', detail: (res.stderr || '').slice(0, 2000) }, { status: 500 });
}
return new NextResponse(res.stdout, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename="pulse-clock.sql"',
'Cache-Control': 'no-store',
},
});
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawnSync, execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
function resolveDbPath(): string {
const raw = process.env.DATABASE_URL ?? '/app/data/pulse-clock.db';
if (raw.startsWith('file:')) return raw.slice(5);
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({} as Record<string, unknown>));
const expected = process.env.PULSE_SYNC_TOKEN;
if (!expected) {
return NextResponse.json({ error: 'Sync not configured (PULSE_SYNC_TOKEN missing)' }, { status: 500 });
}
const prodUrl = process.env.PULSE_PROD_URL ?? 'https://pulsy.hellobaka.com';
const confirm = (body as Record<string, unknown>).confirm;
if (confirm !== true && confirm !== 'true') {
return NextResponse.json({ error: 'Missing confirm:true — this wipes test DB' }, { status: 400 });
}
// Fetch dump from prod
let dump: string;
try {
const res = await fetch(`${prodUrl.replace(/\/$/, '')}/api/admin/dump`, {
headers: { 'x-sync-token': expected },
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json({ error: `prod dump failed: ${res.status} ${text.slice(0, 800)}` }, { status: 502 });
}
dump = await res.text();
if (!dump || dump.length < 100) {
return NextResponse.json({ error: 'prod dump empty or too small', len: dump.length }, { status: 502 });
}
} catch (e) {
return NextResponse.json({ error: `fetch prod failed: ${(e as Error).message}` }, { status: 502 });
}
const dbPath = resolveDbPath();
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// Write dump to temp file then restore
const tmp = path.join(os.tmpdir(), `pulse-restore-${Date.now()}.sql`);
try {
fs.writeFileSync(tmp, dump, 'utf-8');
// Count tables in dump for sanity
const tableCount = (dump.match(/CREATE TABLE/g) || []).length;
// Restore: sqlite3 db < dump.sql
const restore = spawnSync('sh', ['-c', `sqlite3 "${dbPath}" < "${tmp}"`], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
if (restore.status !== 0) {
return NextResponse.json({ error: 'restore failed', detail: (restore.stderr || restore.stdout || '').slice(0, 3000) }, { status: 500 });
}
// Quick sanity: count rows
const check = spawnSync('sqlite3', [dbPath, 'SELECT "companies:" || (SELECT count(*) FROM companies) || " employees:" || (SELECT count(*) FROM employees) || " entries:" || (SELECT count(*) FROM time_entries);'], { encoding: 'utf-8' });
const summary = (check.stdout || '').trim();
return NextResponse.json({ ok: true, bytes: dump.length, tables: tableCount, summary, dbPath });
} finally {
try { fs.unlinkSync(tmp); } catch {}
}
}
export async function GET() {
return NextResponse.json({ ok: true, hint: 'POST with { token, confirm: true } and x-sync-token header' });
}
+7
View File
@@ -47,6 +47,13 @@ export function proxy(request: NextRequest) {
return response;
}
// /api/admin/dump is server-to-server sync — auth via X-Sync-Token, not cookie
if (pathname === '/api/admin/dump') {
const rateLimitError = checkRateLimit(request);
if (rateLimitError) return withCors(rateLimitError, request);
return withCors(NextResponse.next(), request);
}
if (pathname === '/api/authorize') {
const rateLimitError = checkRateLimit(request);
if (rateLimitError) return withCors(rateLimitError, request);