feat: sync endpoints + sqlite for prod (mirrors test)
This commit is contained in:
+2
-1
@@ -19,7 +19,8 @@ ENV PORT=3000
|
|||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV DATABASE_URL=/app/data/pulse-clock.db
|
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 \
|
&& useradd --system --uid 1001 --gid nodejs nextjs \
|
||||||
&& mkdir -p /app/data \
|
&& mkdir -p /app/data \
|
||||||
&& chown -R nextjs:nodejs /app/data
|
&& chown -R nextjs:nodejs /app/data
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export default function AdminPage() {
|
|||||||
const [editingOverride, setEditingOverride] = useState<Record<number, boolean>>({});
|
const [editingOverride, setEditingOverride] = useState<Record<number, boolean>>({});
|
||||||
const [showIssuesOnly, setShowIssuesOnly] = useState(false);
|
const [showIssuesOnly, setShowIssuesOnly] = useState(false);
|
||||||
const [manualPunchError, setManualPunchError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -145,6 +152,28 @@ export default function AdminPage() {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [selectedCompany, startDate, endDate]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadEntries();
|
loadEntries();
|
||||||
}, [loadEntries]);
|
}, [loadEntries]);
|
||||||
@@ -448,6 +477,16 @@ export default function AdminPage() {
|
|||||||
<button className="btn btn-outline btn-sm" onClick={loadEntries}>
|
<button className="btn btn-outline btn-sm" onClick={loadEntries}>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</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
|
<button
|
||||||
className={`btn btn-sm ${showIssuesOnly ? 'btn-warning' : 'btn-outline'}`}
|
className={`btn btn-sm ${showIssuesOnly ? 'btn-warning' : 'btn-outline'}`}
|
||||||
onClick={() => setShowIssuesOnly((v) => !v)}
|
onClick={() => setShowIssuesOnly((v) => !v)}
|
||||||
@@ -523,6 +562,15 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Time Grid */}
|
||||||
<div className="p-4 max-w-7xl mx-auto overflow-x-auto">
|
<div className="p-4 max-w-7xl mx-auto overflow-x-auto">
|
||||||
<div className="min-w-[800px]">
|
<div className="min-w-[800px]">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ export function proxy(request: NextRequest) {
|
|||||||
return response;
|
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') {
|
if (pathname === '/api/authorize') {
|
||||||
const rateLimitError = checkRateLimit(request);
|
const rateLimitError = checkRateLimit(request);
|
||||||
if (rateLimitError) return withCors(rateLimitError, request);
|
if (rateLimitError) return withCors(rateLimitError, request);
|
||||||
|
|||||||
Reference in New Issue
Block a user