From c9010fed5307d1d89b839fe24527c67efca97715 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 23 Aug 2026 01:37:58 +0000 Subject: [PATCH] feat: sync endpoints + sqlite for prod (mirrors test) --- Dockerfile | 3 +- src/app/admin/page.tsx | 48 +++++++++++++++ src/app/api/admin/dump/route.ts | 43 +++++++++++++ src/app/api/admin/sync-from-prod/route.ts | 74 +++++++++++++++++++++++ src/proxy.ts | 7 +++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/dump/route.ts create mode 100644 src/app/api/admin/sync-from-prod/route.ts diff --git a/Dockerfile b/Dockerfile index 9949fb3..73be1d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ ENV PORT=3000 ENV HOSTNAME=0.0.0.0 ENV DATABASE_URL=/app/data/pulse-clock.db -RUN groupadd --system --gid 1001 nodejs \ +RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/* && \ + groupadd --system --gid 1001 nodejs \ && useradd --system --uid 1001 --gid nodejs nextjs \ && mkdir -p /app/data \ && chown -R nextjs:nodejs /app/data diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a275c7c..2dca223 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -109,6 +109,13 @@ export default function AdminPage() { const [editingOverride, setEditingOverride] = useState>({}); const [showIssuesOnly, setShowIssuesOnly] = useState(false); const [manualPunchError, setManualPunchError] = useState(null); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(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() { + {isTestEnv && ( + + )} + + + )} + {/* Time Grid */}
diff --git a/src/app/api/admin/dump/route.ts b/src/app/api/admin/dump/route.ts new file mode 100644 index 0000000..36fdafd --- /dev/null +++ b/src/app/api/admin/dump/route.ts @@ -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', + }, + }); +} diff --git a/src/app/api/admin/sync-from-prod/route.ts b/src/app/api/admin/sync-from-prod/route.ts new file mode 100644 index 0000000..77e66f9 --- /dev/null +++ b/src/app/api/admin/sync-from-prod/route.ts @@ -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)); + 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).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' }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 10ab202..1d761d2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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);