Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc0768725 | ||
|
|
d1860316f6 | ||
|
|
69107b2e60 | ||
|
|
c1d488e03b |
+2
-1
@@ -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
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { ok, err } from '@/lib/api-response';
|
||||
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(err('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(err('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(err(`prod dump failed: ${res.status} ${text.slice(0, 800)}`), { status: 502 });
|
||||
}
|
||||
dump = await res.text();
|
||||
if (!dump || dump.length < 100) {
|
||||
return NextResponse.json(err('prod dump empty or too small'), { status: 502 });
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json(err(`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 });
|
||||
|
||||
const tmp = path.join(os.tmpdir(), `pulse-restore-${Date.now()}.sql`);
|
||||
try {
|
||||
const tableCount = (dump.match(/CREATE TABLE/g) || []).length;
|
||||
// Keep same SQLite inode so live connection stays valid.
|
||||
// 1) Clear existing rows
|
||||
spawnSync('sqlite3', [dbPath, "PRAGMA foreign_keys=OFF; DELETE FROM time_entries; DELETE FROM employees; DELETE FROM companies; DELETE FROM audit_log; DELETE FROM __drizzle_migrations; DELETE FROM sqlite_sequence;"], { encoding: 'utf-8' });
|
||||
// 2) Filter dump to INSERTs only (DDL already exists via migrations) — otherwise CREATE TABLE fails on re-sync
|
||||
const filtered = dump
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
const t = line.trimStart();
|
||||
return t.startsWith('INSERT ') || t.startsWith('PRAGMA ') || t.startsWith('BEGIN ') || t.startsWith('COMMIT');
|
||||
})
|
||||
.join('\n');
|
||||
fs.writeFileSync(tmp, filtered, 'utf-8');
|
||||
const restore = spawnSync('sh', ['-c', `sqlite3 "${dbPath}" < "${tmp}"`], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
||||
if (restore.status !== 0) {
|
||||
return NextResponse.json(err('restore failed: ' + (restore.stderr || restore.stdout || '').slice(0, 1500)), { 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({ bytes: dump.length, tables: tableCount, summary, dbPath }));
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(ok({ hint: 'POST with { confirm: true }' }));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user