feat: expand admin and data sync workflows
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m40s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m13s

This commit is contained in:
2026-07-23 22:00:08 -07:00
parent 70911e7e62
commit 27b3bd9efc
9 changed files with 127 additions and 13 deletions
+24 -1
View File
@@ -112,6 +112,26 @@ function dec(v: Prisma.Decimal | null | undefined): string {
*/
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
/**
* Source tables excluded from the customer-facing statement.
*
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
* (`objects.json:1358`), so the customer's "current balance" never saw
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
* the statement must drop them to match the legacy number the customer has
* been quoted for years. The staff-facing balances worklist and movement
* browser keep them — they're real money, just tracked separately
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
*/
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
];
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
@@ -579,7 +599,10 @@ export class BillingService {
}
const rows = await this.prisma.transaction.findMany({
where: { customerId },
where: {
customerId,
legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] },
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
id: true,
+1 -1
View File
@@ -44,7 +44,7 @@ export class OpsController {
@Post("ingest/:name")
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }),
FileInterceptor("file", { limits: { fileSize: 2 * 1024 * 1024 * 1024 } }),
)
async uploadIngest(
@Param("name") name: string,
+4 -1
View File
@@ -43,8 +43,11 @@ interface MysqlConn {
export class OpsService implements OnModuleInit {
private readonly logger = new Logger(OpsService.name);
// Resolve from this source file so it works regardless of process.cwd()
// (the API runs from apps/api/, but the Python ETL lives at repo-root migration/).
private readonly migrationDir =
process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration");
process.env.MIGRATION_DIR ??
path.resolve(__dirname, "..", "..", "..", "..", "migration");
private readonly ingestDir =
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
private readonly backupDir =
+10
View File
@@ -1,7 +1,9 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
@@ -69,4 +71,12 @@ export class UsersController {
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
return user;
}
@Delete(":id")
@HttpCode(204)
async remove(@Param("id") id: string, @Req() req: Request) {
const actingId = this.actingId(req);
await this.users.remove(id, actingId);
void this.audit.log(actingId, "user.delete", { userId: id });
}
}
+24
View File
@@ -111,6 +111,30 @@ export class UsersService {
});
}
/**
* Hard-delete a user. The schema's ActivityLog.userId FK would otherwise
* block the row (default `Restrict`), so null it out in the same
* transaction. Rows + the actor id captured in the `message` JSON stay
* intact for the audit trail.
*/
async remove(id: string, actingUserId: string): Promise<void> {
if (id === actingUserId) {
throw new BadRequestException("No puede eliminar su propia cuenta");
}
await this.ensureExists(id);
try {
await this.prisma.$transaction([
this.prisma.activityLog.updateMany({
where: { userId: id },
data: { userId: null },
}),
this.prisma.user.delete({ where: { id } }),
]);
} catch (e) {
throw this.mapError(e);
}
}
private async ensureExists(id: string): Promise<void> {
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
+24 -10
View File
@@ -27,6 +27,8 @@ import type {
OpsJobKind,
} from "@/lib/types";
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export default function OperacionesPage() {
return (
<AppShell>
@@ -37,6 +39,7 @@ export default function OperacionesPage() {
type ConfirmState =
| { kind: "REIMPORT" }
| { kind: "SYNC" }
| { kind: "RESTORE"; file: string }
| null;
@@ -176,6 +179,7 @@ function Operaciones() {
const c = confirm;
setConfirm(null);
if (c.kind === "REIMPORT") await start("REIMPORT");
else if (c.kind === "SYNC") await start("SYNC");
else await start("RESTORE", c.file);
}
@@ -224,7 +228,7 @@ function Operaciones() {
<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í.
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
</p>
<div className="tx-scroll">
<table className="tx-table">
@@ -306,11 +310,11 @@ function Operaciones() {
/>
<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={() => {}}
desc="Respalda, luego importa lo nuevo del legado. Borra del sistema los registros del legado que ya no aparecen en los archivos de ingesta. Se conservan los datos capturados a mano."
action="Sincronizar"
tone="primary"
disabled={jobRunning || starting || !ingestReady}
onClick={() => askConfirm({ kind: "SYNC" })}
/>
</div>
{!ingestReady && (
@@ -446,12 +450,18 @@ function Operaciones() {
<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"}
{confirm.kind === "REIMPORT"
? "Confirmar reimportación"
: confirm.kind === "SYNC"
? "Confirmar sincronizació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.`}
: confirm.kind === "SYNC"
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
: `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>
@@ -464,12 +474,16 @@ function Operaciones() {
</label>
<div className="form-actions">
<button
className="btn btn-danger"
className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
type="button"
disabled={confirmText !== "CONFIRMAR" || starting}
onClick={runConfirmed}
>
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"}
{confirm.kind === "REIMPORT"
? "Reimportar"
: confirm.kind === "SYNC"
? "Sincronizar"
: "Restaurar"}
</button>
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
Cancelar
+30
View File
@@ -6,6 +6,7 @@ import { useAuth, useCan } from "@/lib/abilities";
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
import {
createUser,
deleteUser,
listUsers,
resetUserPassword,
updateUser,
@@ -133,6 +134,22 @@ function UsuariosAdmin() {
}
}
async function submitDelete(u: UserRow) {
if (!window.confirm(`¿Eliminar al usuario "${u.name}"? Esta acción no se puede deshacer.`)) {
return;
}
setError(null);
setNotice(null);
try {
await deleteUser(u.id);
if (editingId === u.id) startCreate();
setNotice("Usuario eliminado.");
refresh();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar el usuario.");
}
}
return (
<>
<div className="page-head">
@@ -312,6 +329,19 @@ function UsuariosAdmin() {
>
Contraseña
</button>
<button
className="btn btn-ghost btn-danger"
type="button"
disabled={u.id === me?.id}
title={
u.id === me?.id
? "No puede eliminar su propia cuenta"
: "Eliminar usuario"
}
onClick={() => submitDelete(u)}
>
Eliminar
</button>
</div>
)}
</td>
+4
View File
@@ -655,6 +655,10 @@ export function resetUserPassword(id: string, password: string): Promise<UserRow
});
}
export function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/users/${id}`, { method: "DELETE" });
}
/* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> {