normalizar los tickets y las intervenciones

This commit is contained in:
David Arranz 2026-10-10 14:08:54 +02:00
parent 933972ae8e
commit 035553065d
17 changed files with 1014 additions and 106 deletions

View File

@ -51,6 +51,7 @@ import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResourceNormalization } from "./resource-normalization.integration";
import { verifyResources } from "./resources.integration";
import { verifySupport } from "./support.integration";
import { verifySupportNormalization } from "./support-normalization.integration";
import { verifyTelework } from "./telework.integration";
import { verifyTickets } from "./tickets.integration";
import { verifyWorkProfiles } from "./work-profiles.integration";
@ -936,6 +937,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
customer.id,
secondCompany.id
);
await verifySupportNormalization(database);
await verifyBrands(
(...args) => operator.request(...args),
customer.id,

View File

@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { migrateInterventions } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-migration";
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
import {
type SupportTable,
readSupportRows,
supportChildren,
supportIdentifier,
} from "../../../modules/companies/src/api/infrastructure/persistence/support-record-relational";
import { migrateTickets } from "../../../modules/companies/src/api/infrastructure/persistence/ticket-migration";
const tables: readonly SupportTable[] = [
"intervention_templates",
"intervention_template_revisions",
"tickets",
"ticket_revisions",
"interventions",
"intervention_revisions",
];
async function snapshots(database: Sequelize) {
return database.transaction(async (transaction) => {
const result = new Map<SupportTable, Record<string, unknown>[]>();
for (const table of tables) {
const rows = await readSupportRows(database, table, transaction);
rows.sort((left, right) => String(left.id).localeCompare(String(right.id)));
result.set(table, rows);
}
return result;
});
}
async function migrate(database: Sequelize) {
await migrateInterventionTemplates(database);
await migrateTickets(database);
await migrateInterventions(database);
}
async function clearChildren(database: Sequelize, table: SupportTable, transaction: Transaction) {
const children = supportChildren(table);
for (const child of [children.targets, children.steps]) {
if (child) {
await database.query(`DELETE FROM ${supportIdentifier(child)}`, { transaction });
}
}
}
async function restoreLegacy(
database: Sequelize,
expected: ReadonlyMap<SupportTable, readonly Record<string, unknown>[]>
) {
await database.transaction(async (transaction) => {
for (const [table, rows] of expected) {
for (const row of rows) {
await database.query(
`UPDATE ${supportIdentifier(table)} SET \`values\`=:values${table === "interventions" ? ",`procedure`=:procedure" : ""} WHERE id=:id`,
{
replacements: {
id: row.id,
values: JSON.stringify(row.values),
procedure: JSON.stringify(row.procedure ?? null),
},
transaction,
}
);
}
await clearChildren(database, table, transaction);
}
await database.query("DELETE FROM intervention_procedures", { transaction });
});
}
// Las columnas JSON solo se recrean en la base dedicada para probar el formato de origen.
export async function verifySupportNormalization(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
const expected = await snapshots(database);
const counters = await database.query(
"SELECT nextNumber FROM ticket_counter UNION ALL SELECT nextNumber FROM intervention_counter",
{ type: QueryTypes.SELECT }
);
assert.ok((expected.get("tickets")?.length ?? 0) > 0);
assert.ok((expected.get("interventions")?.length ?? 0) > 0);
assert.ok(expected.get("interventions")?.some((row) => row.procedure !== null));
const query = database.getQueryInterface();
for (const table of tables) {
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
}
await query.addColumn("interventions", "procedure", { type: DataTypes.JSON, allowNull: true });
await restoreLegacy(database, expected);
const first = expected.get("tickets")?.[0];
assert.ok(first);
const valid = JSON.stringify(first.values);
await database.query("UPDATE tickets SET `values`=:values WHERE id=:id", {
replacements: { id: first.id, values: JSON.stringify({ subject: "Origen inválido" }) },
});
await assert.rejects(() => migrateTickets(database));
assert.ok((await query.describeTable("tickets")).values);
assert.deepEqual(
await database.query("SELECT ownerId FROM ticket_targets", { type: QueryTypes.SELECT }),
[]
);
await database.query("UPDATE tickets SET `values`=:values WHERE id=:id", {
replacements: { id: first.id, values: valid },
});
// Una referencia escalar incompatible tampoco se corrige silenciosamente.
await database.query("UPDATE tickets SET requesterId=NULL WHERE id=:id", {
replacements: { id: first.id },
});
const input = first.values;
assert.ok(input && typeof input === "object" && "requesterId" in input);
if (input.requesterId !== null) {
await assert.rejects(() => migrateTickets(database), /inconsistente/);
}
await database.query("UPDATE tickets SET requesterId=:requesterId WHERE id=:id", {
replacements: { id: first.id, requesterId: input.requesterId },
});
await migrate(database);
assert.deepEqual(await snapshots(database), expected);
for (const table of tables) {
const columns = await query.describeTable(table);
assert.equal(columns.values, undefined);
assert.equal(columns.procedure, undefined);
}
// Simula un corte de DDL: raíz legada, revisiones ya normalizadas.
await query.addColumn("tickets", "values", { type: DataTypes.JSON, allowNull: true });
for (const row of expected.get("tickets") ?? []) {
await database.query("UPDATE tickets SET `values`=:values WHERE id=:id", {
replacements: { id: row.id, values: JSON.stringify(row.values) },
});
}
await migrate(database);
assert.deepEqual(await snapshots(database), expected);
await migrate(database);
assert.deepEqual(await snapshots(database), expected);
assert.deepEqual(
await database.query(
"SELECT nextNumber FROM ticket_counter UNION ALL SELECT nextNumber FROM intervention_counter",
{ type: QueryTypes.SELECT }
),
counters
);
// Solo puede existir un destino por fila y los destinos deben existir.
await assert.rejects(() =>
database.query(
"INSERT INTO ticket_targets (ownerId,position,personId,workstationId,assetId,resourceId) VALUES (:id,999,NULL,NULL,NULL,NULL)",
{ replacements: { id: first.id } }
)
);
await assert.rejects(() =>
database.query(
"INSERT INTO ticket_targets (ownerId,position,personId,workstationId,assetId,resourceId) VALUES (:id,999,'00000000-0000-0000-0000-000000000001',NULL,NULL,NULL)",
{ replacements: { id: first.id } }
)
);
}

View File

@ -102,3 +102,7 @@ Pruebas de dominio y persistencia: límites, pasos repetidos por UUID, igualdad
Las pruebas de intervenciones cubren actuaciones sencillas, requisitos de cierre, no aplicación justificada, fechas, minutos, historial, snapshots, idempotencia, concurrencia, compañía de ticket/afectados, procedimientos versionados, no cierre automático del ticket, filtros es/en y autor de finalización de pasos.
## Persistencia normalizada
Los valores actuales y las revisiones se almacenan en columnas tradicionales. Los afectados y pasos se guardan en relaciones ordenadas con claves foráneas; los procedimientos de intervención conservan una copia inmutable de la plantilla y su revisión. No se utilizan columnas JSON para este ámbito. Véase [Normalización de la persistencia](normalizacion-de-la-persistencia.md#tickets-intervenciones-y-plantillas) para el inventario de tablas y las garantías de transferencia.

View File

@ -4,7 +4,7 @@ Se mantiene Sequelize 6 y MariaDB. El criterio del proyecto es almacenar datos d
## Estado de la normalización
La normalización completa todavía está en curso. Esta revisión extiende el modelo relacional anterior de activos a recursos, puestos, asistencia y configuraciones de perfiles. No representa el cierre de la auditoría de toda la base.
La normalización completa todavía está en curso. La revisión anterior extendió el modelo relacional de activos a recursos, puestos, asistencia y configuraciones de perfiles. La continuación incorpora tickets, intervenciones y plantillas de intervención. No representa el cierre de la auditoría de toda la base.
| Ámbito | Persistencia actual |
| --- | --- |
@ -20,7 +20,7 @@ La normalización completa todavía está en curso. Esta revisión extiende el m
| Correo | Pendiente: proyecciones de buzones, usos y configuraciones, incluido su formato histórico anterior |
| Accesos remotos | Pendiente |
| Procesos y plantillas de procesos | Pendiente: pasos y revisiones |
| Tickets e intervenciones | Pendiente: afectados, pasos, procedimiento y plantillas |
| Tickets e intervenciones | Columnas actuales e históricas; afectados, pasos, procedimientos congelados y plantillas en tablas relacionadas |
| Sesiones HTTP | Pendiente de evaluar el contrato del almacén de sesiones existente; no se introduce una excepción nueva para JSON |
Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio.
@ -44,6 +44,27 @@ Los vínculos de integración con buzones y accesos se conservan en columnas con
Los perfiles y configuraciones mantienen sus reglas de unicidad, transiciones y cierre. El cambio de persistencia no modifica rutas, formularios, DTO, contadores, permisos, identidad ni comportamiento funcional. Las referencias de las revisiones se conservan mediante claves foráneas.
## Tickets, intervenciones y plantillas
Los seis registros principales (`tickets`, `ticket_revisions`, `interventions`, `intervention_revisions`, `intervention_templates`, `intervention_template_revisions`) ya no almacenan `values` en JSON. Asunto, descripción, estados, prioridad, canal, modalidad, fechas, minutos, diagnóstico, trabajo realizado, resolución, nombre y estado de la plantilla tienen columnas tradicionales según la entidad. `plannedDate` es una fecha SQL; los instantes ISO conservan su representación exacta en columnas de texto para no perder precisión del historial.
Se añaden diez tablas:
| Información | Actual | Histórica |
| --- | --- | --- |
| Afectados de tickets | `ticket_targets` | `ticket_revision_targets` |
| Afectados de intervenciones | `intervention_targets` | `intervention_revision_targets` |
| Ejecución de pasos | `intervention_steps` | `intervention_revision_steps` |
| Pasos de plantillas | `intervention_template_steps` | `intervention_template_revision_steps` |
| Procedimiento congelado | `intervention_procedures` | Procedencia inmutable de la intervención |
| Pasos originales del procedimiento | `intervention_procedure_steps` | Copia independiente del catálogo |
Un afectado tiene exactamente una referencia: persona, puesto, activo o recurso, con clave foránea real para cada tipo. Las asociaciones se identifican por propietario y posición; los pasos mantienen su UUID y un orden único dentro de cada propietario. Las claves foráneas restringen eliminar destinos referenciados, mientras que las filas dependientes pertenecen a su ticket, revisión, plantilla o procedimiento y se eliminan en cascada con su propietario.
El procedimiento conserva plantilla, revisión concreta, nombre, descripción, estado original y pasos. La base comprueba que la revisión pertenece a esa plantilla. No se sustituye esa copia por el catálogo vigente. Una intervención sencilla puede no tener procedimiento, pasos, técnico o ticket, según las reglas existentes.
Los repositorios reconstruyen los mismos Value Objects y agregados. Se mantienen las rutas, DTO, UUID, autores, fechas, contadores, reintentos idempotentes, revisión esperada, conflictos concurrentes y reglas para cerrar o reabrir. Los lectores del dashboard consultan las columnas normalizadas tanto del estado actual como de las revisiones.
## Migraciones
Las migraciones se ejecutan explícitamente al inicializar los módulos; no se utiliza `sync({ alter: true })`.
@ -54,7 +75,7 @@ Las migraciones se ejecutan explícitamente al inicializar los módulos; no se u
4. Se reconstruye el destino y se compara con el origen antes de retirar las columnas antiguas.
5. Se eliminan las columnas JSON transferidas y se refuerzan las restricciones.
El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de preparación, pero el origen se conserva hasta verificar la transferencia. La migración de recursos admite también el corte en el que las revisiones ya se transfirieron y el recurso aún conserva sus columnas de origen. Repetir las migraciones no añade revisiones ni duplica datos.
El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de preparación, pero el origen se conserva hasta verificar la transferencia. Las migraciones de tickets, intervenciones y plantillas permiten reconstruir conjuntamente registros legados y normalizados si el DDL se interrumpe entre columnas. La migración de recursos admite también el corte en el que las revisiones ya se transfirieron y el recurso aún conserva sus columnas de origen. Repetir las migraciones no añade revisiones ni duplica datos.
Las pruebas de integración utilizan exclusivamente `metis_desk_test`. Comprueban origen inválido, conservación de UUID/historial/orden/valores, referencias incompatibles, idempotencia, arranque vacío y mantenimiento del comportamiento HTTP. Las fixtures SQL que antes escribían JSON se adaptan a las tablas nuevas.
@ -62,7 +83,7 @@ Las pruebas de integración utilizan exclusivamente `metis_desk_test`. Comprueba
Antes de dar por terminada la normalización hay que implementar y verificar las filas pendientes del inventario, adaptar sus lectores de dashboard/procesos y proyecciones de compatibilidad, y repetir una auditoría del esquema real y del código de persistencia. No basta con cambiar una columna JSON a LONGTEXT ni con renombrar los documentos serializados.
## Verificación de esta revisión
## Verificación de la revisión anterior
- Suite completa: 277 pruebas superadas; espaciado: 3 pruebas superadas.
- Typecheck de servidor, activos, compañías y recursos: correcto.
@ -72,3 +93,11 @@ Antes de dar por terminada la normalización hay que implementar y verificar las
- Inicialización de desarrollo: comprobada después de guardar una copia SQL externa al repositorio en `/home/david/backups/metis-desk/`.
El typecheck global sigue encontrando el problema anterior de `locale` en `packages/shadcn-ui/src/components/calendar.tsx`. El lint/check global conserva errores de otros ficheros (principalmente código de fotos y formato pendiente). No se modifica código vendor ni cambios de interfaz ajenos a esta normalización.
## Verificación de tickets e intervenciones
- Suite completa: 277 pruebas superadas, incluida la integración HTTP/MariaDB y la transferencia del formato legado; 3 pruebas de espaciado superadas.
- Typecheck de servidor y compañías, build, Biome y ESLint de todos los ficheros modificados: correctos.
- La prueba de integración reconstruye el origen JSON exclusivamente en `metis_desk_test`, rechaza documentos y referencias incoherentes, conserva las instantáneas y contadores, comprueba restricciones de destinos y repite la migración después de simular un corte entre raíz y revisiones. También se ha ejercitado el arranque desde una base de pruebas vacía.
- Base de desarrollo migrada e inicialización verificada. Auditoría del esquema: desaparecen las columnas `values`/`procedure` de los seis registros principales y existen las diez tablas relacionadas. Copia previa: `/home/david/backups/metis-desk/20261010-133639-tickets-intervenciones.sql`.
- Los chequeos globales mantienen el error previo de `locale` en el calendario de shadcn/ui y errores de lint/formato en otros ficheros. No se modifica el código vendor ni cambios de interfaz realizados simultáneamente.

View File

@ -50,3 +50,7 @@ Pruebas de dominio/mapper, filtros y renderizado de formularios, e integración
## Intervenciones relacionadas
Desde el listado se inicia una intervención para el ticket con asunto y afectados propuestos. La ficha consulta las intervenciones relacionadas. Varias intervenciones pueden corresponder al mismo ticket; finalizar una no cierra el ticket automáticamente. Véase [Intervenciones y plantillas](intervenciones-y-plantillas.md).
## Persistencia normalizada
Los valores actuales y las revisiones se almacenan en columnas tradicionales. Los afectados y pasos se guardan en relaciones ordenadas con claves foráneas; los procedimientos de intervención conservan una copia inmutable de la plantilla y su revisión. No se utilizan columnas JSON para este ámbito. Véase [Normalización de la persistencia](normalizacion-de-la-persistencia.md#tickets-intervenciones-y-plantillas) para el inventario de tablas y las garantías de transferencia.

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSupportRecords } from "./support-record-normalization";
import { interventionColumns } from "./support-record-relational";
export async function migrateInterventions(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -32,8 +35,7 @@ export async function migrateInterventions(database: Sequelize) {
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
ticketId: fk("tickets", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
procedure: { type: DataTypes.JSON, allowNull: false },
...interventionColumns,
},
],
[
@ -44,7 +46,7 @@ export async function migrateInterventions(database: Sequelize) {
actorId: fk("users"),
ticketId: fk("tickets", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
...interventionColumns,
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
},
@ -68,4 +70,5 @@ export async function migrateInterventions(database: Sequelize) {
unique: true,
});
}
await normalizeSupportRecords(database, "interventions", "intervention_revisions");
}

View File

@ -122,8 +122,8 @@ export class InterventionPersistenceMapper {
requestKey: intervention.requestKey.toPrimitive(),
ticketId: input.ticketId,
assigneeId: input.assigneeId,
values: JSON.stringify(input),
procedure: JSON.stringify(intervention.procedure.getOrUndefined()?.toPrimitive() ?? null),
values: input,
procedure: intervention.procedure.getOrUndefined()?.toPrimitive() ?? null,
},
revisions: intervention.history.map((item, position) => ({
id: item.id.toPrimitive(),
@ -131,7 +131,7 @@ export class InterventionPersistenceMapper {
actorId: item.actorId.toPrimitive(),
ticketId: item.values.toPrimitive().ticketId,
assigneeId: item.values.toPrimitive().assigneeId,
values: JSON.stringify(item.values.toPrimitive()),
values: item.values.toPrimitive(),
recordedAt: item.recordedAt,
position,
})),

View File

@ -4,6 +4,8 @@ import { DataTypes, QueryTypes } from "sequelize";
import { interventionTemplateSeeds } from "../../domain/intervention-template-seed";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
import { normalizeSupportRecords } from "./support-record-normalization";
import { interventionTemplateColumns, writeSupportRow } from "./support-record-relational";
export async function migrateInterventionTemplates(database: Sequelize) {
const query = database.getQueryInterface();
@ -27,7 +29,7 @@ export async function migrateInterventionTemplates(database: Sequelize) {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
values: { type: DataTypes.JSON, allowNull: false },
...interventionTemplateColumns,
},
],
[
@ -41,7 +43,7 @@ export async function migrateInterventionTemplates(database: Sequelize) {
onDelete: "RESTRICT",
},
actorId: fk("users"),
values: { type: DataTypes.JSON, allowNull: false },
...interventionTemplateColumns,
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
},
@ -65,6 +67,11 @@ export async function migrateInterventionTemplates(database: Sequelize) {
unique: true,
});
}
await normalizeSupportRecords(
database,
"intervention_templates",
"intervention_template_revisions"
);
await database.transaction(async (transaction) => {
await database.query("SELECT id FROM intervention_template_lock WHERE id=1 FOR UPDATE", {
type: QueryTypes.SELECT,
@ -82,14 +89,20 @@ export async function migrateInterventionTemplates(database: Sequelize) {
}
const persisted = mapper.toPersistence(seed);
await database.query(
"INSERT INTO intervention_templates (id,companyId,requestKey,`values`) VALUES (:id,:companyId,:requestKey,:values)",
{ replacements: persisted.root, transaction }
await writeSupportRow(
database,
"intervention_templates",
persisted.root,
transaction,
"insert"
);
for (const revision of persisted.revisions) {
await database.query(
"INSERT INTO intervention_template_revisions (id,templateId,`values`,actorId,recordedAt,position) VALUES (:id,:templateId,:values,:actorId,:recordedAt,:position)",
{ replacements: revision, transaction }
await writeSupportRow(
database,
"intervention_template_revisions",
revision,
transaction,
"insert"
);
}
}

View File

@ -98,12 +98,12 @@ export class InterventionTemplatePersistenceMapper {
id: template.id.toPrimitive(),
companyId: template.companyId.getOrUndefined()?.toPrimitive() ?? null,
requestKey: template.requestKey.toPrimitive(),
values: JSON.stringify(template.values.toPrimitive()),
values: template.values.toPrimitive(),
},
revisions: template.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
templateId: template.id.toPrimitive(),
values: JSON.stringify(revision.values.toPrimitive()),
values: revision.values.toPrimitive(),
actorId: revision.actorId.getOrUndefined()?.toPrimitive() ?? null,
recordedAt: revision.recordedAt,
position,

View File

@ -10,10 +10,8 @@ import type {
InventoryEntry,
} from "../../application/dashboard.use-cases";
const OPEN_TICKET =
"JSON_UNQUOTE(JSON_EXTRACT(t.`values`,'$.state')) IN ('open','in_progress','waiting')";
const PENDING_INTERVENTION =
"JSON_UNQUOTE(JSON_EXTRACT(i.`values`,'$.state')) IN ('pending','in_progress')";
const OPEN_TICKET = "t.state IN ('open','in_progress','waiting')";
const PENDING_INTERVENTION = "i.state IN ('pending','in_progress')";
const PROFILE_REVIEW = `p.active=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND (
account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical'
OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today
@ -84,8 +82,8 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
}
private workQuery() {
return `SELECT t.id,t.companyId,c.name AS companyName,'ticket' AS kind,t.reference,JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.subject')) AS name,JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.state')) AS state,JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.priority')) AS priority,(SELECT MIN(recordedAt) FROM ticket_revisions WHERE ticketId=t.id) AS recordedAt,NULL AS actorName FROM tickets t JOIN companies c ON c.id=t.companyId WHERE ${OPEN_TICKET} AND (:companyId IS NULL OR t.companyId=:companyId)
UNION ALL SELECT i.id,i.companyId,c.name,'intervention',i.reference,JSON_UNQUOTE(JSON_EXTRACT(i.\`values\`,'$.subject')),JSON_UNQUOTE(JSON_EXTRACT(i.\`values\`,'$.state')),JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.priority')),(SELECT MIN(recordedAt) FROM intervention_revisions WHERE interventionId=i.id),NULL FROM interventions i JOIN companies c ON c.id=i.companyId LEFT JOIN tickets t ON t.id=i.ticketId WHERE ${PENDING_INTERVENTION} AND (:companyId IS NULL OR i.companyId=:companyId)`;
return `SELECT t.id,t.companyId,c.name AS companyName,'ticket' AS kind,t.reference,t.subject AS name,t.state AS state,t.priority AS priority,(SELECT MIN(recordedAt) FROM ticket_revisions WHERE ticketId=t.id) AS recordedAt,NULL AS actorName FROM tickets t JOIN companies c ON c.id=t.companyId WHERE ${OPEN_TICKET} AND (:companyId IS NULL OR t.companyId=:companyId)
UNION ALL SELECT i.id,i.companyId,c.name,'intervention',i.reference,i.subject,i.state,t.priority,(SELECT MIN(recordedAt) FROM intervention_revisions WHERE interventionId=i.id),NULL FROM interventions i JOIN companies c ON c.id=i.companyId LEFT JOIN tickets t ON t.id=i.ticketId WHERE ${PENDING_INTERVENTION} AND (:companyId IS NULL OR i.companyId=:companyId)`;
}
private async work(query: DashboardDetailsQuery) {
@ -196,7 +194,7 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
] as const) {
items.push(
...(await this.select<DashboardEntryDTO>(
`SELECT t.id,h.id AS eventId,t.companyId,c.name AS companyName,'${kind}' AS kind,t.reference,JSON_UNQUOTE(JSON_EXTRACT(h.\`values\`,'$.subject')) AS name,JSON_UNQUOTE(JSON_EXTRACT(h.\`values\`,'$.state')) AS state,NULL AS priority,h.recordedAt,u.name AS actorName FROM ${revisions} h JOIN ${table} t ON t.id=h.${foreignKey} JOIN companies c ON c.id=t.companyId LEFT JOIN users u ON u.id=h.actorId WHERE (:companyId IS NULL OR t.companyId=:companyId) ORDER BY h.recordedAt DESC,h.id DESC LIMIT 6`,
`SELECT t.id,h.id AS eventId,t.companyId,c.name AS companyName,'${kind}' AS kind,t.reference,h.subject AS name,h.state AS state,NULL AS priority,h.recordedAt,u.name AS actorName FROM ${revisions} h JOIN ${table} t ON t.id=h.${foreignKey} JOIN companies c ON c.id=t.companyId LEFT JOIN users u ON u.id=h.actorId WHERE (:companyId IS NULL OR t.companyId=:companyId) ORDER BY h.recordedAt DESC,h.id DESC LIMIT 6`,
query
))
);

View File

@ -8,6 +8,7 @@ import type { InterventionTemplate } from "../../domain/intervention-template.ag
import { InterventionTemplateError } from "../../domain/intervention-template-values";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
import { readSupportRows, writeSupportRow } from "./support-record-relational";
export class SequelizeInterventionTemplateRepository implements IInterventionTemplateRepository {
private readonly mapper = new InterventionTemplatePersistenceMapper();
@ -27,14 +28,12 @@ export class SequelizeInterventionTemplateRepository implements IInterventionTem
return rows.length > 0;
}
private async read(transaction?: Transaction) {
const rows = await this.database.query("SELECT * FROM intervention_templates", {
type: QueryTypes.SELECT,
...(transaction ? { transaction } : {}),
});
const revisions = await this.database.query<{ templateId: string }>(
"SELECT * FROM intervention_template_revisions ORDER BY position",
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
private async read(transaction: Transaction) {
const rows = await readSupportRows(this.database, "intervention_templates", transaction);
const revisions = await readSupportRows(
this.database,
"intervention_template_revisions",
transaction
);
const users = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
@ -119,21 +118,20 @@ export class SequelizeInterventionTemplateRepository implements IInterventionTem
}
const persisted = this.mapper.toPersistence(template);
if (previous) {
await this.database.query("UPDATE intervention_templates SET `values`=:values WHERE id=:id", {
replacements: persisted.root,
transaction,
});
} else {
await this.database.query(
"INSERT INTO intervention_templates (id,companyId,requestKey,`values`) VALUES (:id,:companyId,:requestKey,:values)",
{ replacements: persisted.root, transaction }
);
}
await writeSupportRow(
this.database,
"intervention_templates",
persisted.root,
transaction,
previous ? "update" : "insert"
);
for (const revision of persisted.revisions.slice(previous?.history.length ?? 0)) {
await this.database.query(
"INSERT INTO intervention_template_revisions (id,templateId,`values`,actorId,recordedAt,position) VALUES (:id,:templateId,:values,:actorId,:recordedAt,:position)",
{ replacements: revision, transaction }
await writeSupportRow(
this.database,
"intervention_template_revisions",
revision,
transaction,
"insert"
);
}
}

View File

@ -16,6 +16,7 @@ import type { Intervention } from "../../domain/intervention.aggregate";
import { InterventionError } from "../../domain/intervention-values";
import { InterventionPersistenceMapper } from "./intervention-persistence.mapper";
import { readSupportRows, writeSupportRow } from "./support-record-relational";
export class SequelizeInterventionRepository implements IInterventionRepository {
private readonly mapper = new InterventionPersistenceMapper();
@ -35,24 +36,10 @@ export class SequelizeInterventionRepository implements IInterventionRepository
if (!context.isSuccess) {
return Result.fail(new InterventionError(context.error.code));
}
const roots = await this.database.query<Record<string, unknown>>(
"SELECT id,companyId,reference,requestKey,ticketId,assigneeId,`values`,`procedure` FROM interventions" +
(company ? " WHERE companyId=:company" : ""),
{
transaction,
type: QueryTypes.SELECT,
replacements: { company: company?.toPrimitive() ?? null },
}
);
const revisions = await this.database.query<Record<string, unknown>>(
"SELECT r.id,r.interventionId,r.actorId,r.ticketId,r.assigneeId,r.`values`,r.recordedAt,r.position FROM intervention_revisions r JOIN interventions i ON i.id=r.interventionId" +
(company ? " WHERE i.companyId=:company" : ""),
{
transaction,
type: QueryTypes.SELECT,
replacements: { company: company?.toPrimitive() ?? null },
}
const roots = (await readSupportRows(this.database, "interventions", transaction)).filter(
(row) => !company || row.companyId === company.toPrimitive()
);
const revisions = await readSupportRows(this.database, "intervention_revisions", transaction);
return Result.ok({
...context.data,
@ -155,17 +142,15 @@ export class SequelizeInterventionRepository implements IInterventionRepository
}
const persisted = this.mapper.toPersistence(intervention);
await this.database.query(
previous
? "UPDATE interventions SET ticketId=:ticketId,assigneeId=:assigneeId,`values`=:values WHERE id=:id"
: "INSERT INTO interventions (id,companyId,reference,requestKey,ticketId,assigneeId,`values`,`procedure`) VALUES (:id,:companyId,:reference,:requestKey,:ticketId,:assigneeId,:values,:procedure)",
{ replacements: persisted.root, transaction }
await writeSupportRow(
this.database,
"interventions",
persisted.root,
transaction,
previous ? "update" : "insert"
);
for (const row of persisted.revisions.slice(previous?.history.length ?? 0)) {
await this.database.query(
"INSERT INTO intervention_revisions (id,interventionId,actorId,ticketId,assigneeId,`values`,recordedAt,position) VALUES (:id,:interventionId,:actorId,:ticketId,:assigneeId,:values,:recordedAt,:position)",
{ replacements: row, transaction }
);
await writeSupportRow(this.database, "intervention_revisions", row, transaction, "insert");
}
if (!previous) {
await this.database.query(

View File

@ -15,6 +15,7 @@ import type {
import type { Ticket } from "../../domain/ticket.aggregate";
import { TicketError } from "../../domain/ticket-values";
import { readSupportRows, writeSupportRow } from "./support-record-relational";
import { TicketPersistenceMapper, ticketIdentity } from "./ticket-persistence.mapper";
export class SequelizeTicketRepository implements ITicketRepository {
@ -51,25 +52,10 @@ export class SequelizeTicketRepository implements ITicketRepository {
transaction,
type: QueryTypes.SELECT,
});
const where = company ? " WHERE companyId=:companyId" : "";
const roots = await this.database.query<Record<string, unknown>>(
"SELECT id,companyId,reference,requestKey,requesterId,assigneeId,`values` FROM tickets" +
where,
{
transaction,
type: QueryTypes.SELECT,
replacements: { companyId: company?.toPrimitive() ?? null },
}
);
const revisions = await this.database.query<Record<string, unknown>>(
"SELECT r.id,r.ticketId,r.actorId,r.requesterId,r.assigneeId,r.`values`,r.recordedAt,r.position FROM ticket_revisions r JOIN tickets t ON t.id=r.ticketId" +
(company ? " WHERE t.companyId=:companyId" : ""),
{
transaction,
type: QueryTypes.SELECT,
replacements: { companyId: company?.toPrimitive() ?? null },
}
const roots = (await readSupportRows(this.database, "tickets", transaction)).filter(
(row) => !company || row.companyId === company.toPrimitive()
);
const revisions = await readSupportRows(this.database, "ticket_revisions", transaction);
const option = (item: { id: string; name: string; active: boolean }) => ({
id: ticketIdentity(item.id),
@ -185,20 +171,18 @@ export class SequelizeTicketRepository implements ITicketRepository {
private async write(ticket: Ticket, previous: Ticket | undefined, transaction: Transaction) {
const data = this.mapper.toPersistence(ticket);
await this.database.query(
previous
? "UPDATE tickets SET requesterId=:requesterId,assigneeId=:assigneeId,`values`=:values WHERE id=:id"
: "INSERT INTO tickets (id,companyId,reference,requestKey,requesterId,assigneeId,`values`) VALUES (:id,:companyId,:reference,:requestKey,:requesterId,:assigneeId,:values)",
{ replacements: data.root, transaction }
await writeSupportRow(
this.database,
"tickets",
data.root,
transaction,
previous ? "update" : "insert"
);
const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()) ?? []);
for (const row of data.revisions) {
if (!existing.has(row.id)) {
await this.database.query(
"INSERT INTO ticket_revisions (id,ticketId,actorId,requesterId,assigneeId,`values`,recordedAt,position) VALUES (:id,:ticketId,:actorId,:requesterId,:assigneeId,:values,:recordedAt,:position)",
{ replacements: row, transaction }
);
await writeSupportRow(this.database, "ticket_revisions", row, transaction, "insert");
}
}
if (!previous) {

View File

@ -0,0 +1,328 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { InterventionPersistenceMapper } from "./intervention-persistence.mapper";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
import {
type SupportTable,
readSupportRows,
supportChildren,
supportColumns,
supportIdentifier,
writeSupportRow,
} from "./support-record-relational";
import { TicketPersistenceMapper } from "./ticket-persistence.mapper";
const owned = (table: string, key = "id"): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
allowNull: false,
references: { model: table, key },
onDelete: "CASCADE",
});
const reference = (table: string): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
allowNull: true,
references: { model: table, key: "id" },
onDelete: "RESTRICT",
});
const position = { type: DataTypes.INTEGER, allowNull: false, primaryKey: true };
async function createChildren(database: Sequelize, table: SupportTable) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
const children = supportChildren(table);
if (children.targets && !tables.some((item) => item.name === children.targets)) {
await query.createTable(children.targets, {
ownerId: { ...owned(table), primaryKey: true },
position,
personId: reference("company_contacts"),
workstationId: reference("workstations"),
assetId: reference("assets"),
resourceId: reference("resources"),
});
}
if (children.targets) {
await ensureTargetConstraints(database, children.targets);
}
if (children.steps && !tables.some((item) => item.name === children.steps)) {
const execution = table === "interventions" || table === "intervention_revisions";
await query.createTable(children.steps, {
ownerId: { ...owned(table), primaryKey: true },
stepId: { type: DataTypes.UUID, allowNull: false, primaryKey: true },
position: { type: DataTypes.INTEGER, allowNull: false },
title: { type: DataTypes.STRING(200), allowNull: false },
instructions: { type: DataTypes.TEXT, allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
...(execution
? {
state: { type: DataTypes.STRING(20), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: false },
}
: {}),
});
}
if (children.steps) {
await ensureIndex(
database,
children.steps,
["ownerId", "position"],
`${children.steps}_position`
);
}
}
async function ensureIndex(database: Sequelize, table: string, columns: string[], name: string) {
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table }, type: QueryTypes.SELECT }
);
if (!indexes.some((item) => item.name === name)) {
await database.getQueryInterface().addIndex(table, columns, { unique: true, name });
}
}
async function ensureTargetConstraints(database: Sequelize, table: string) {
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table }, type: QueryTypes.SELECT }
);
const name = `${table}_one_destination`;
if (!constraints.some((item) => item.name === name)) {
await database.query(
`ALTER TABLE ${supportIdentifier(table)} ADD CONSTRAINT ${supportIdentifier(name)} CHECK ((personId IS NOT NULL)+(workstationId IS NOT NULL)+(assetId IS NOT NULL)+(resourceId IS NOT NULL)=1)`
);
}
for (const column of ["personId", "workstationId", "assetId", "resourceId"]) {
await ensureIndex(database, table, ["ownerId", column], `${table}_${column}_unique`);
}
}
async function createProcedures(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
if (!tables.some((item) => item.name === "intervention_procedures")) {
await query.createTable("intervention_procedures", {
interventionId: { ...owned("interventions"), primaryKey: true },
templateId: { ...reference("intervention_templates"), allowNull: false },
revisionId: { ...reference("intervention_template_revisions"), allowNull: false },
name: { type: DataTypes.STRING(160), allowNull: false },
description: { type: DataTypes.TEXT, allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
});
}
if (!tables.some((item) => item.name === "intervention_procedure_steps")) {
await query.createTable("intervention_procedure_steps", {
ownerId: { ...owned("intervention_procedures", "interventionId"), primaryKey: true },
stepId: { type: DataTypes.UUID, allowNull: false, primaryKey: true },
position: { type: DataTypes.INTEGER, allowNull: false },
title: { type: DataTypes.STRING(200), allowNull: false },
instructions: { type: DataTypes.TEXT, allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
});
}
await ensureIndex(
database,
"intervention_procedure_steps",
["ownerId", "position"],
"intervention_procedure_steps_position"
);
await ensureIndex(
database,
"intervention_template_revisions",
["templateId", "id"],
"intervention_template_revision_identity"
);
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='intervention_procedures'",
{ type: QueryTypes.SELECT }
);
if (!constraints.some((item) => item.name === "intervention_procedure_template_revision")) {
await database.query(
"ALTER TABLE intervention_procedures ADD CONSTRAINT intervention_procedure_template_revision FOREIGN KEY (templateId,revisionId) REFERENCES intervention_template_revisions (templateId,id) ON DELETE RESTRICT"
);
}
}
function validateHistory(
rootTable: SupportTable,
roots: readonly Record<string, unknown>[],
revisions: readonly Record<string, unknown>[]
) {
for (const root of roots) {
switch (rootTable) {
case "tickets":
new TicketPersistenceMapper().toDomain(
root,
revisions.filter((row) => row.ticketId === root.id)
);
break;
case "interventions":
new InterventionPersistenceMapper().toDomain(
root,
revisions.filter((row) => row.interventionId === root.id)
);
break;
case "intervention_templates":
new InterventionTemplatePersistenceMapper().toDomain(
root,
revisions.filter((row) => row.templateId === root.id)
);
break;
default:
throw new Error("La normalización requiere una raíz de soporte.");
}
}
const key =
rootTable === "tickets"
? "ticketId"
: rootTable === "interventions"
? "interventionId"
: "templateId";
if (revisions.some((row) => !roots.some((root) => root.id === row[key]))) {
throw new Error("Revisión de soporte huérfana.");
}
}
async function prepareTable(database: Sequelize, table: SupportTable) {
const query = database.getQueryInterface();
const original = await query.describeTable(table);
for (const [name, column] of Object.entries(supportColumns(table))) {
if (!(name in original)) {
await query.addColumn(table, name, { ...column, allowNull: true });
}
}
await createChildren(database, table);
return new Set(Object.keys(original));
}
async function tightenTable(database: Sequelize, table: SupportTable) {
const query = database.getQueryInterface();
const final = await query.describeTable(table);
for (const [name, column] of Object.entries(supportColumns(table))) {
if (
(column.allowNull === false && final[name]?.allowNull) ||
(name === "plannedDate" && final[name]?.type !== "DATE")
) {
await query.changeColumn(table, name, column);
}
}
}
async function transferRow(
database: Sequelize,
table: SupportTable,
row: Record<string, unknown>,
transaction: Transaction
) {
const raw = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} WHERE id=:id`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
);
const original = raw[0];
const expected = row.values;
if (!original || typeof expected !== "object" || !expected) {
throw new Error("Registro de soporte desaparecido.");
}
for (const name of ["requesterId", "assigneeId", "ticketId"]) {
if (name in expected && name in original && original[name] !== Reflect.get(expected, name)) {
throw new Error("Referencia de soporte inconsistente.");
}
}
await writeSupportRow(database, table, row, transaction, "update", "replace");
}
async function transferTable(
database: Sequelize,
table: SupportTable,
rows: readonly Record<string, unknown>[],
transaction: Transaction
) {
for (const row of rows) {
await transferRow(database, table, row, transaction);
}
const normalized = await readSupportRows(database, table, transaction, undefined, "normalized");
if (
rows.length !== normalized.length ||
rows.some(
(row) =>
!isDeepStrictEqual(
row,
normalized.find((item) => item.id === row.id)
)
)
) {
throw new Error("Los registros normalizados no coinciden con el origen.");
}
}
async function retireLegacyColumns(
database: Sequelize,
legacy: ReadonlyMap<SupportTable, ReadonlySet<string>>
) {
// MariaDB confirma DDL por separado: se puede reanudar aunque una columna ya se haya retirado.
for (const [table, columns] of legacy) {
for (const name of ["values", "procedure"]) {
if (columns.has(name)) {
await database.query(
`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN ${supportIdentifier(name)}`
);
}
}
}
}
export async function normalizeSupportRecords(
database: Sequelize,
rootTable: SupportTable,
revisionTable: SupportTable
) {
const legacy = new Map<SupportTable, ReadonlySet<string>>();
for (const table of [rootTable, revisionTable]) {
legacy.set(table, await prepareTable(database, table));
}
if (rootTable === "interventions") {
await createProcedures(database);
}
const transfer = [...legacy.values()].some(
(columns) => columns.has("values") || columns.has("procedure")
);
if (transfer) {
await database.transaction(async (transaction) => {
const roots = await readSupportRows(database, rootTable, transaction);
const revisions = await readSupportRows(database, revisionTable, transaction);
validateHistory(rootTable, roots, revisions);
await transferTable(database, rootTable, roots, transaction);
await transferTable(database, revisionTable, revisions, transaction);
});
await retireLegacyColumns(database, legacy);
}
for (const table of [rootTable, revisionTable]) {
await tightenTable(database, table);
}
}

View File

@ -0,0 +1,387 @@
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { InterventionProcedureSchema } from "../../../common/interventions";
import { InterventionTemplateInputSchema } from "../../domain/intervention-template-values";
import { InterventionInputSchema } from "../../domain/intervention-values";
import { TicketInputSchema, TicketTargetSchema } from "../../domain/ticket-values";
const text = (size: number): ModelAttributeColumnOptions => ({
type: DataTypes.STRING(size),
allowNull: false,
});
const note: ModelAttributeColumnOptions = { type: DataTypes.TEXT, allowNull: false };
const nullableDate: ModelAttributeColumnOptions = { type: DataTypes.STRING(30), allowNull: true };
export const ticketColumns = {
subject: text(200),
description: note,
channel: text(20),
state: text(20),
priority: text(20),
};
export const interventionColumns = {
subject: text(200),
state: text(20),
modality: text(20),
plannedDate: { type: DataTypes.DATEONLY, allowNull: true },
startedAt: nullableDate,
endedAt: nullableDate,
minutes: { type: DataTypes.INTEGER, allowNull: true },
diagnosis: note,
workPerformed: note,
resolution: note,
};
export const interventionTemplateColumns = {
name: text(160),
description: note,
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
export type SupportTable =
| "tickets"
| "ticket_revisions"
| "interventions"
| "intervention_revisions"
| "intervention_templates"
| "intervention_template_revisions";
export function supportColumns(table: SupportTable): Record<string, ModelAttributeColumnOptions> {
switch (table) {
case "tickets":
case "ticket_revisions":
return ticketColumns;
case "interventions":
case "intervention_revisions":
return interventionColumns;
case "intervention_templates":
case "intervention_template_revisions":
return interventionTemplateColumns;
}
}
export function supportChildren(table: SupportTable) {
switch (table) {
case "tickets":
return { targets: "ticket_targets", steps: null };
case "ticket_revisions":
return { targets: "ticket_revision_targets", steps: null };
case "interventions":
return { targets: "intervention_targets", steps: "intervention_steps" };
case "intervention_revisions":
return { targets: "intervention_revision_targets", steps: "intervention_revision_steps" };
case "intervention_templates":
return { targets: null, steps: "intervention_template_steps" };
case "intervention_template_revisions":
return { targets: null, steps: "intervention_template_revision_steps" };
}
}
export function supportValues(table: SupportTable, value: unknown) {
const input = typeof value === "string" ? JSON.parse(value) : value;
switch (table) {
case "tickets":
case "ticket_revisions":
return TicketInputSchema.parse(input);
case "interventions":
case "intervention_revisions":
return InterventionInputSchema.parse(input);
case "intervention_templates":
case "intervention_template_revisions":
return InterventionTemplateInputSchema.parse(input);
}
}
export function supportIdentifier(value: string) {
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
throw new Error("Identificador SQL de soporte inválido.");
}
return `\`${value}\``;
}
const scalarRecord = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]));
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
export async function writeSupportScalars(
database: Sequelize,
table: string,
values: object,
transaction: Transaction,
operation: "insert" | "update"
) {
const row = scalarRecord.parse(values);
const columns = Object.keys(row);
const sql =
operation === "insert"
? `INSERT INTO ${supportIdentifier(table)} (${columns.map(supportIdentifier).join(",")}) VALUES (${columns.map((name) => `:${name}`).join(",")})`
: `UPDATE ${supportIdentifier(table)} SET ${columns
.filter((name) => name !== "id")
.map((name) => `${supportIdentifier(name)}=:${name}`)
.join(",")} WHERE id=:id`;
await database.query(sql, { replacements: row, transaction });
}
async function readChildren(database: Sequelize, table: string, transaction: Transaction) {
return database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} ORDER BY ownerId,position`,
{ type: QueryTypes.SELECT, transaction }
);
}
function ordered(rows: readonly Record<string, unknown>[], ownerId: unknown) {
const result = rows.filter((row) => row.ownerId === ownerId);
if (result.some((row, index) => row.position !== index)) {
throw new Error("Orden de elementos de soporte inconsistente.");
}
return result;
}
function target(row: Record<string, unknown>) {
const references = [
{ kind: "person", id: row.personId },
{ kind: "workstation", id: row.workstationId },
{ kind: "asset", id: row.assetId },
{ kind: "resource", id: row.resourceId },
].filter((item) => item.id !== null);
if (references.length !== 1) {
throw new Error("Destino de soporte inconsistente.");
}
return TicketTargetSchema.parse({ kind: references[0]?.kind, targetId: references[0]?.id });
}
function step(row: Record<string, unknown>, execution: boolean) {
const base = {
id: row.stepId,
title: row.title,
instructions: row.instructions,
required: bool.parse(row.required),
};
return execution ? { ...base, state: row.state, notes: row.notes } : base;
}
// La forma en memoria sigue siendo la del dominio; ningún documento se almacena en SQL.
export async function readSupportRows(
database: Sequelize,
table: SupportTable,
transaction: Transaction,
source?: readonly Record<string, unknown>[],
mode: "compatible" | "normalized" = "compatible"
): Promise<Record<string, unknown>[]> {
const rows =
source ??
(await database.query<Record<string, unknown>>(
`SELECT s.*${table === "interventions" || table === "intervention_revisions" ? ", DATE_FORMAT(s.plannedDate, '%Y-%m-%d') AS plannedDateIso" : ""} FROM ${supportIdentifier(table)} s`,
{ type: QueryTypes.SELECT, transaction }
));
const children = supportChildren(table);
const targets = children.targets
? await readChildren(database, children.targets, transaction)
: [];
const steps = children.steps ? await readChildren(database, children.steps, transaction) : [];
const procedures =
table === "interventions"
? await database.query<Record<string, unknown>>("SELECT * FROM intervention_procedures", {
type: QueryTypes.SELECT,
transaction,
})
: [];
const procedureSteps =
table === "interventions"
? await readChildren(database, "intervention_procedure_steps", transaction)
: [];
return rows.map((row) => {
const columns = supportColumns(table);
const values =
mode === "compatible" && row.values !== undefined
? supportValues(table, row.values)
: normalizedValues(table, row, targets, steps);
const metadata = Object.fromEntries(
Object.entries(row).filter(
([name]) =>
!(name in columns) &&
name !== "values" &&
name !== "procedure" &&
name !== "plannedDateIso"
)
);
if (table !== "interventions") {
return { ...metadata, values };
}
return { ...metadata, values, procedure: readProcedure(row, procedures, procedureSteps, mode) };
});
}
function normalizedValues(
table: SupportTable,
row: Record<string, unknown>,
targets: readonly Record<string, unknown>[],
steps: readonly Record<string, unknown>[]
) {
const children = supportChildren(table);
const execution = table === "interventions" || table === "intervention_revisions";
const ticket = table === "tickets" || table === "ticket_revisions";
const scalars = Object.fromEntries(
Object.keys(supportColumns(table)).map((name) => [
name,
name === "active"
? bool.parse(row[name])
: name === "plannedDate" && "plannedDateIso" in row
? row.plannedDateIso
: row[name],
])
);
return supportValues(table, {
...scalars,
...(ticket ? { requesterId: row.requesterId, assigneeId: row.assigneeId } : {}),
...(execution ? { ticketId: row.ticketId, assigneeId: row.assigneeId } : {}),
...(children.targets ? { affected: ordered(targets, row.id).map(target) } : {}),
...(children.steps
? { steps: ordered(steps, row.id).map((item) => step(item, execution)) }
: {}),
});
}
function readProcedure(
row: Record<string, unknown>,
procedures: readonly Record<string, unknown>[],
steps: readonly Record<string, unknown>[],
mode: "compatible" | "normalized"
) {
if (mode === "compatible" && row.procedure !== undefined) {
return InterventionProcedureSchema.nullable().parse(
typeof row.procedure === "string" ? JSON.parse(row.procedure) : row.procedure
);
}
const saved = procedures.find((item) => item.interventionId === row.id);
if (!saved) {
return null;
}
return InterventionProcedureSchema.parse({
templateId: saved.templateId,
revisionId: saved.revisionId,
values: {
name: saved.name,
description: saved.description,
active: bool.parse(saved.active),
steps: ordered(steps, row.id).map((item) => step(item, false)),
},
});
}
async function replaceChildren(
database: Sequelize,
table: string,
ownerId: string,
rows: readonly object[],
transaction: Transaction
) {
await database.query(`DELETE FROM ${supportIdentifier(table)} WHERE ownerId=:ownerId`, {
replacements: { ownerId },
transaction,
});
for (const [position, row] of rows.entries()) {
await writeSupportScalars(
database,
table,
{ ownerId, position, ...row },
transaction,
"insert"
);
}
}
export async function writeSupportRow(
database: Sequelize,
table: SupportTable,
source: Record<string, unknown>,
transaction: Transaction,
operation: "insert" | "update",
procedurePolicy: "preserve" | "replace" = "preserve"
) {
const id = z.uuid().parse(source.id);
const values = supportValues(table, source.values);
const { values: _values, procedure: _procedure, ...metadata } = source;
const {
affected: _affected,
steps: _steps,
...scalars
} = z.record(z.string(), z.unknown()).parse(values);
await writeSupportScalars(database, table, { ...metadata, ...scalars }, transaction, operation);
const children = supportChildren(table);
if (children.targets && "affected" in values) {
await replaceChildren(
database,
children.targets,
id,
values.affected.map((item) => ({
personId: item.kind === "person" ? item.targetId : null,
workstationId: item.kind === "workstation" ? item.targetId : null,
assetId: item.kind === "asset" ? item.targetId : null,
resourceId: item.kind === "resource" ? item.targetId : null,
})),
transaction
);
}
if (children.steps && "steps" in values) {
await replaceChildren(
database,
children.steps,
id,
values.steps.map(({ id: stepId, ...item }) => ({ stepId, ...item })),
transaction
);
}
if (table === "interventions" && (operation === "insert" || procedurePolicy === "replace")) {
const procedure = InterventionProcedureSchema.nullable().parse(
typeof source.procedure === "string" ? JSON.parse(source.procedure) : source.procedure
);
await database.query("DELETE FROM intervention_procedures WHERE interventionId=:id", {
replacements: { id },
transaction,
});
if (procedure) {
const { steps: frozenSteps, ...frozenValues } = procedure.values;
await writeSupportScalars(
database,
"intervention_procedures",
{
interventionId: id,
templateId: procedure.templateId,
revisionId: procedure.revisionId,
...frozenValues,
},
transaction,
"insert"
);
await replaceChildren(
database,
"intervention_procedure_steps",
id,
frozenSteps.map(({ id: stepId, ...item }) => ({ stepId, ...item })),
transaction
);
}
}
}

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSupportRecords } from "./support-record-normalization";
import { ticketColumns } from "./support-record-relational";
export async function migrateTickets(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -32,7 +35,7 @@ export async function migrateTickets(database: Sequelize) {
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
requesterId: fk("company_contacts", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
...ticketColumns,
},
],
[
@ -43,7 +46,7 @@ export async function migrateTickets(database: Sequelize) {
actorId: fk("users"),
requesterId: fk("company_contacts", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
...ticketColumns,
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
},
@ -67,4 +70,5 @@ export async function migrateTickets(database: Sequelize) {
unique: true,
});
}
await normalizeSupportRecords(database, "tickets", "ticket_revisions");
}

View File

@ -113,7 +113,7 @@ export class TicketPersistenceMapper {
requestKey: ticket.requestKey.toPrimitive(),
requesterId: input.requesterId,
assigneeId: input.assigneeId,
values: JSON.stringify(input),
values: input,
},
revisions: ticket.history.map((item, position) => ({
id: item.id.toPrimitive(),
@ -121,7 +121,7 @@ export class TicketPersistenceMapper {
actorId: item.actorId.toPrimitive(),
requesterId: item.values.toPrimitive().requesterId,
assigneeId: item.values.toPrimitive().assigneeId,
values: JSON.stringify(item.values.toPrimitive()),
values: item.values.toPrimitive(),
recordedAt: item.recordedAt,
position,
})),