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:
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user