wip: ops admin panel + migration sync + crud/rbac phase-5 snapshot

Working-tree checkpoint of in-progress work carried across prior
sessions on the feat/crud-rbac branch, committed so it lands on the
remote alongside the CI changes.

- Operaciones admin panel: apps/api/src/ops (ingest upload, backup /
  restore / re-import jobs) wired into app.module + RBAC abilities, and
  the apps/web/src/app/operaciones page. docker-compose gets INGEST_DIR
  / BACKUP_DIR volumes; .gitignore excludes migration/ingest + backups.
- migration/sync.py plus transform_*.py / run_all / config / dbenv /
  blob_extract adjustments for the additive sync path.
- crud/rbac phase-5 web bits: AppShell, api/labels/types libs, globals.
- schema.prisma + PLAN/RESUME doc updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 19:01:36 -07:00
co-authored by Claude Opus 4.8
parent 6ad0993a71
commit f1ef1c70b3
27 changed files with 1480 additions and 107 deletions
+53 -2
View File
@@ -247,6 +247,7 @@ button {
border-radius: 7px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.appbar-link:hover {
color: #fff;
@@ -400,13 +401,24 @@ button {
}
.btn-ghost {
background: transparent;
color: var(--ink-soft);
border-color: var(--line-strong);
}
.btn-ghost:hover {
background: var(--surface-2);
color: var(--ink);
border-color: var(--brand-600);
text-decoration: none;
}
/* Dark appbar keeps the light-on-dark ghost button. */
.appbar .btn-ghost {
color: rgba(242, 239, 231, 0.85);
border-color: rgba(255, 255, 255, 0.22);
}
.btn-ghost:hover {
.appbar .btn-ghost:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
text-decoration: none;
border-color: rgba(255, 255, 255, 0.35);
}
.btn-outline {
background: var(--surface);
@@ -2153,3 +2165,42 @@ button {
position: relative;
z-index: 1;
}
/* --- Admin DB operations (Operaciones) --- */
.btn-danger {
background: var(--negative);
color: #fff;
}
.btn-danger:hover {
filter: brightness(0.94);
text-decoration: none;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ops-log {
margin: 12px 0 0;
padding: 12px 14px;
max-height: 320px;
overflow: auto;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: grid;
place-items: center;
padding: 20px;
z-index: 50;
}
+515
View File
@@ -0,0 +1,515 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import {
OPS_KIND_LABELS,
OPS_STATUS_LABELS,
formatBytes,
formatDateTime,
} from "@/lib/labels";
import {
backupDownloadUrl,
deleteBackup,
deleteIngest,
getOpsJob,
listBackups,
listIngest,
listOpsJobs,
startOpsJob,
uploadIngest,
} from "@/lib/api";
import type {
BackupFile,
IngestFile,
OpsJob,
OpsJobKind,
} from "@/lib/types";
export default function OperacionesPage() {
return (
<AppShell>
<Operaciones />
</AppShell>
);
}
type ConfirmState =
| { kind: "REIMPORT" }
| { kind: "RESTORE"; file: string }
| null;
function Operaciones() {
const allowed = useCan("db:manage");
const [ingest, setIngest] = useState<IngestFile[] | null>(null);
const [backups, setBackups] = useState<BackupFile[] | null>(null);
const [jobs, setJobs] = useState<OpsJob[] | null>(null);
const [activeJob, setActiveJob] = useState<OpsJob | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmText, setConfirmText] = useState("");
const [uploading, setUploading] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
const refreshLists = useCallback(() => {
listIngest().then(setIngest).catch(() => setIngest([]));
listBackups().then(setBackups).catch(() => setBackups([]));
listOpsJobs()
.then((rows) => {
setJobs(rows);
const running = rows.find((j) => j.status === "RUNNING");
if (running) setActiveJob(running);
})
.catch(() => setJobs([]));
}, []);
useEffect(() => {
if (allowed) refreshLists();
}, [allowed, refreshLists]);
// Poll the active job while it runs; refresh everything when it finishes.
useEffect(() => {
if (!activeJob || activeJob.status !== "RUNNING") return;
const id = activeJob.id;
const timer = setInterval(() => {
getOpsJob(id)
.then((job) => {
setActiveJob(job);
if (job.status !== "RUNNING") {
clearInterval(timer);
refreshLists();
setNotice(
job.status === "SUCCESS"
? `${OPS_KIND_LABELS[job.kind]} completada.`
: `${OPS_KIND_LABELS[job.kind]} terminó con error. Revise el registro.`,
);
}
})
.catch(() => {
/* transient — keep polling */
});
}, 1500);
return () => clearInterval(timer);
}, [activeJob, refreshLists]);
if (!allowed) {
return (
<div className="page-head">
<h1 className="page-title">Operaciones</h1>
<div className="state-box state-error">
No tiene permisos para administrar la base de datos.
</div>
</div>
);
}
const jobRunning = activeJob?.status === "RUNNING";
async function handleUpload(name: string, file: File | undefined) {
if (!file) return;
setError(null);
setNotice(null);
setUploading(name);
try {
await uploadIngest(name, file);
setNotice(`${name} cargado.`);
refreshLists();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
} finally {
setUploading(null);
const input = fileInputs.current[name];
if (input) input.value = "";
}
}
async function handleDeleteIngest(name: string) {
setError(null);
try {
await deleteIngest(name);
refreshLists();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar.");
}
}
async function handleDeleteBackup(name: string) {
setError(null);
try {
await deleteBackup(name);
refreshLists();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar el respaldo.");
}
}
async function start(kind: OpsJobKind, file?: string) {
setError(null);
setNotice(null);
setStarting(true);
try {
const job = await startOpsJob(kind, file);
setActiveJob(job);
setJobs((prev) => (prev ? [job, ...prev] : [job]));
} catch (e) {
setError((e as Error)?.message ?? "No se pudo iniciar la operación.");
} finally {
setStarting(false);
}
}
function askConfirm(state: ConfirmState) {
setConfirm(state);
setConfirmText("");
setError(null);
setNotice(null);
}
async function runConfirmed() {
if (!confirm) return;
const c = confirm;
setConfirm(null);
if (c.kind === "REIMPORT") await start("REIMPORT");
else await start("RESTORE", c.file);
}
const ingestReady = (ingest ?? []).every((f) => f.present);
return (
<>
<div className="page-head">
<h1 className="page-title">Operaciones de base de datos</h1>
</div>
{error && <div className="state-box state-error">{error}</div>}
{notice && <div className="state-box">{notice}</div>}
{/* Active / running job with live log */}
{activeJob && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div className="row-actions" style={{ justifyContent: "space-between" }}>
<h2 className="section-title" style={{ margin: 0 }}>
{OPS_KIND_LABELS[activeJob.kind]}{" "}
<span
className={`badge ${
activeJob.status === "SUCCESS"
? "badge-positive"
: activeJob.status === "FAILED"
? "badge-negative"
: "badge-neutral"
}`}
>
{jobRunning && <span className="spinner" aria-hidden style={{ marginRight: 6 }} />}
{OPS_STATUS_LABELS[activeJob.status]}
</span>
</h2>
{!jobRunning && (
<button className="btn btn-ghost" type="button" onClick={() => setActiveJob(null)}>
Ocultar
</button>
)}
</div>
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
</div>
)}
{/* Ingest folder */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Carpeta de ingesta</h2>
<p className="inline-form-note">
Los cuatro archivos originales de Access. La reimportación y la
sincronización leen de aquí.
</p>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Archivo</th>
<th>Estado</th>
<th className="num">Tamaño</th>
<th>Modificado</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{(ingest ?? []).map((f) => (
<tr key={f.name}>
<td className="mono">{f.name}</td>
<td>
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
{f.present ? "Presente" : "Falta"}
</span>
</td>
<td className="num">{formatBytes(f.size)}</td>
<td>{formatDateTime(f.modifiedAt)}</td>
<td>
<div className="row-actions">
<input
ref={(el) => {
fileInputs.current[f.name] = el;
}}
type="file"
style={{ display: "none" }}
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
/>
<button
className="btn btn-outline"
type="button"
disabled={uploading === f.name}
onClick={() => fileInputs.current[f.name]?.click()}
>
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
</button>
{f.present && (
<button
className="btn btn-ghost"
type="button"
onClick={() => handleDeleteIngest(f.name)}
>
Eliminar
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Operations */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Operaciones</h2>
<div className="form-grid">
<OpTile
title="Respaldo"
desc="Genera un volcado comprimido de la base de datos actual."
action="Crear respaldo"
tone="primary"
disabled={jobRunning || starting}
onClick={() => start("BACKUP")}
/>
<OpTile
title="Reimportar (purga)"
desc="Respalda, borra TODO y reconstruye desde los archivos de ingesta. Se pierden los datos capturados manualmente."
action="Reimportar"
tone="danger"
disabled={jobRunning || starting || !ingestReady}
onClick={() => askConfirm({ kind: "REIMPORT" })}
/>
<OpTile
title="Sincronizar"
desc="Conserva los datos actuales e importa solo lo nuevo del legado. Disponible en la Fase B."
action="Próximamente"
tone="muted"
disabled
onClick={() => {}}
/>
</div>
{!ingestReady && (
<p className="inline-form-note" style={{ marginTop: 12 }}>
La reimportación requiere que los cuatro archivos estén presentes.
</p>
)}
</div>
{/* Backups */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Respaldos</h2>
<p className="inline-form-note">
Restaurar sobreescribe la base de datos completa con el respaldo elegido.
</p>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Archivo</th>
<th className="num">Tamaño</th>
<th>Creado</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{backups === null ? (
<tr>
<td colSpan={4}>
<span className="spinner" aria-label="Cargando" />
</td>
</tr>
) : backups.length === 0 ? (
<tr>
<td colSpan={4} className="muted">
Sin respaldos.
</td>
</tr>
) : (
backups.map((b) => (
<tr key={b.name}>
<td className="mono">{b.name}</td>
<td className="num">{formatBytes(b.size)}</td>
<td>{formatDateTime(b.createdAt)}</td>
<td>
<div className="row-actions">
<a className="btn btn-outline" href={backupDownloadUrl(b.name)}>
Descargar
</a>
<button
className="btn btn-outline"
type="button"
disabled={jobRunning || starting}
onClick={() => askConfirm({ kind: "RESTORE", file: b.name })}
>
Restaurar
</button>
<button
className="btn btn-ghost"
type="button"
onClick={() => handleDeleteBackup(b.name)}
>
Eliminar
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Recent jobs */}
<div className="card" style={{ padding: 20 }}>
<h2 className="section-title">Historial</h2>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Operación</th>
<th>Estado</th>
<th>Inicio</th>
<th>Fin</th>
<th className="num"></th>
</tr>
</thead>
<tbody>
{(jobs ?? []).map((j) => (
<tr key={j.id}>
<td>{OPS_KIND_LABELS[j.kind]}</td>
<td>
<span
className={`badge ${
j.status === "SUCCESS"
? "badge-positive"
: j.status === "FAILED"
? "badge-negative"
: "badge-neutral"
}`}
>
{OPS_STATUS_LABELS[j.status]}
</span>
</td>
<td>{formatDateTime(j.startedAt)}</td>
<td>{formatDateTime(j.finishedAt)}</td>
<td className="num">
<button
className="btn btn-ghost"
type="button"
onClick={() => setActiveJob(j)}
>
Ver registro
</button>
</td>
</tr>
))}
{jobs && jobs.length === 0 && (
<tr>
<td colSpan={5} className="muted">
Sin operaciones registradas.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Destructive-op confirm */}
{confirm && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="card" style={{ padding: 24, maxWidth: 480 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
{confirm.kind === "REIMPORT" ? "Confirmar reimportación" : "Confirmar restauración"}
</h2>
<p className="inline-form-note">
{confirm.kind === "REIMPORT"
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
</p>
<label className="field">
<span className="field-label">Escriba CONFIRMAR para continuar</span>
<input
className="input"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
autoFocus
/>
</label>
<div className="form-actions">
<button
className="btn btn-danger"
type="button"
disabled={confirmText !== "CONFIRMAR" || starting}
onClick={runConfirmed}
>
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"}
</button>
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
Cancelar
</button>
</div>
</div>
</div>
)}
</>
);
}
function OpTile({
title,
desc,
action,
tone,
disabled,
onClick,
}: {
title: string;
desc: string;
action: string;
tone: "primary" | "danger" | "muted";
disabled: boolean;
onClick: () => void;
}) {
const btnClass =
tone === "danger" ? "btn btn-danger" : tone === "muted" ? "btn btn-outline" : "btn btn-primary";
return (
<div className="card" style={{ padding: 16 }}>
<h3 className="section-title" style={{ fontSize: 15, margin: "0 0 4px" }}>
{title}
</h3>
<p className="inline-form-note" style={{ minHeight: 48 }}>
{desc}
</p>
<button className={btnClass} type="button" disabled={disabled} onClick={onClick}>
{action}
</button>
</div>
);
}
+1
View File
@@ -22,6 +22,7 @@ const NAV: { href: string; label: string; ability?: Ability }[] = [
{ href: "/banco", label: "Chequera" },
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
];
export function AppShell({ children }: { children: ReactNode }) {
+63
View File
@@ -34,6 +34,10 @@ import type {
PolicyStats,
PolicyStatus,
LookupsResponse,
OpsJob,
OpsJobKind,
IngestFile,
BackupFile,
PropertyDetail,
PropertyFacets,
PropertyInput,
@@ -593,3 +597,62 @@ export function resetUserPassword(id: string, password: string): Promise<UserRow
body: JSON.stringify({ password }),
});
}
/* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> {
return apiFetch<IngestFile[]>("/ops/ingest");
}
/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */
export async function uploadIngest(name: string, file: File): Promise<void> {
const body = new FormData();
body.append("file", file, name);
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body,
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const b = await res.json();
if (b?.message) message = b.message;
} catch {
/* ignore */
}
throw new ApiError(res.status, message);
}
}
export function deleteIngest(name: string): Promise<unknown> {
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listBackups(): Promise<BackupFile[]> {
return apiFetch<BackupFile[]>("/ops/backups");
}
export function backupDownloadUrl(name: string): string {
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
}
export function deleteBackup(name: string): Promise<unknown> {
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listOpsJobs(): Promise<OpsJob[]> {
return apiFetch<OpsJob[]>("/ops/jobs");
}
export function getOpsJob(id: string): Promise<OpsJob> {
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
}
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
return apiFetch<OpsJob>("/ops/jobs", {
method: "POST",
body: JSON.stringify({ kind, file }),
});
}
+42
View File
@@ -266,6 +266,48 @@ export function bankSourceLabel(source: string | null | undefined): string {
return BANK_SOURCE_LABELS[source] ?? source;
}
// ----- DB operations (admin) -----
export const OPS_KIND_LABELS: Record<string, string> = {
BACKUP: "Respaldo",
RESTORE: "Restauración",
REIMPORT: "Reimportación",
SYNC: "Sincronización",
};
export const OPS_STATUS_LABELS: Record<string, string> = {
RUNNING: "En curso",
SUCCESS: "Completado",
FAILED: "Con error",
};
/** Bytes → human size (KB/MB/GB), es-MX formatting. */
export function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return "—";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let n = bytes / 1024;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toLocaleString("es-MX", { maximumFractionDigits: 1 })} ${units[i]}`;
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("es-MX", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export const MONTH_NAMES = [
"Enero",
"Febrero",
+32 -1
View File
@@ -20,7 +20,8 @@ export type Ability =
| "bank:create"
| "bank:void"
| "lookup:manage"
| "user:manage";
| "user:manage"
| "db:manage";
export interface AuthUser {
id: string;
@@ -43,6 +44,36 @@ export interface UserRow {
updatedAt: string;
}
/* --------------------------------------------------- DB operations (admin) */
export type OpsJobKind = "BACKUP" | "RESTORE" | "REIMPORT" | "SYNC";
export type OpsJobStatus = "RUNNING" | "SUCCESS" | "FAILED";
export interface OpsJob {
id: string;
kind: OpsJobKind;
status: OpsJobStatus;
log: string;
params: Record<string, unknown> | null;
createdById: string | null;
startedAt: string;
finishedAt: string | null;
}
/** One of the four legacy Access files expected in the ingest folder. */
export interface IngestFile {
name: string;
present: boolean;
size: number | null;
modifiedAt: string | null;
}
export interface BackupFile {
name: string;
size: number;
createdAt: string;
}
export interface CustomerStats {
customers: number;
withUtilities: number;