diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 8c9c51f..bc6de91 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -23,6 +23,7 @@ import { verifyAssets } from "./assets.integration"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; import { verifyInventory } from "./inventory.integration"; +import { verifyMail } from "./mail.integration"; import { verifyRelationships } from "./relationships.integration"; import { verifyWorkstations } from "./workstations.integration"; @@ -37,6 +38,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await once(server, "listening"); await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); await database.query("DELETE FROM http_sessions"); + await database.query("DELETE FROM mail_configuration_revisions"); + await database.query("DELETE FROM mail_configurations"); await database.query("DELETE FROM workstation_assets"); await database.query("DELETE FROM workstation_revisions"); await database.query("DELETE FROM workstations"); @@ -711,6 +714,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de secondCompany.id, database ); + await verifyMail( + (...args) => operator.request(...args), + customer.id, + secondCompany.id, + database + ); const countAfterAccess = await database.query<{ total: number }>( "SELECT COUNT(*) AS total FROM users", { type: QueryTypes.SELECT } @@ -730,6 +739,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de 401, "UNAUTHENTICATED" ); + await expectError( + await operator.request(`/mail-configurations/${customer.id}`), + 401, + "UNAUTHENTICATED" + ); + await expectError( + await operator.request(`/mail-configurations/${customer.id}`, {}), + 401, + "UNAUTHENTICATED" + ); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); await assert.rejects( diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index c79837a..7e682cc 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -16,6 +16,7 @@ export async function createIdentityRuntime( const identity = await createIdentityModule(database, environment.APP_ORIGIN); const companies = await createCompanyModule(database, identity.authenticate); const assets = await createAssetModule(database, identity.authenticate); + const mail = await companies.initializeMail(assets.services.inventory); const SequelizeStore = connectSequelize(session.Store); const store = new SequelizeStore({ db: database, @@ -40,6 +41,7 @@ export async function createIdentityRuntime( identity.register(app); companies.register(app); assets.register(app); + mail.register(app); }, middleware, stop: () => store.stopExpiringSessions(), diff --git a/apps/server/src/mail.integration.ts b/apps/server/src/mail.integration.ts new file mode 100644 index 0000000..7632d3e --- /dev/null +++ b/apps/server/src/mail.integration.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; + +import { InventoryResponseSchema } from "@metis/assets/common"; +import { + type AccessFieldsDTO, + AccessResponseSchema, + CompanyResponseSchema, + type MailConfigurationInput, + MailConfigurationResponseSchema, + MailConfigurationsResponseSchema, +} from "@metis/companies/common"; +import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; +export async function verifyMail( + request: Request, + companyId: string, + otherId: string, + database: Sequelize +) { + const path = `/mail-configurations/${companyId}`; + const accessPath = `/access/${companyId}`; + const company = CompanyResponseSchema.parse( + await (await request(`/companies/${companyId}`)).json() + ).company; + const other = CompanyResponseSchema.parse( + await (await request(`/companies/${otherId}`)).json() + ).company; + const foreignAsset = InventoryResponseSchema.parse( + await (await request(`/assets/${otherId}`)).json() + ).assets[0]; + assert.ok(foreignAsset); + const inventory = InventoryResponseSchema.parse( + await (await request(`/assets/${companyId}`)).json() + ); + const assets = inventory.assets; + for (const item of assets.slice(0, 2)) { + if (item.values.status === "retired") { + const response = await request(`/assets/${companyId}/${item.id}/update`, { + values: { ...item.values, status: "available" }, + }); + assert.equal(response.status, 200, await response.clone().text()); + } + } + const first = assets[0]; + const second = assets[1]; + const foreignPerson = other.people[0]; + assert.ok(first && second && foreignPerson); + const personFields = { + name: "Persona correo", + position: null, + email: null, + phone: null, + active: true, + isContact: false, + }; + const peopleResponse = await request(`/companies/${companyId}/people`, personFields); + assert.equal(peopleResponse.status, 201); + const person = CompanyResponseSchema.parse(await peopleResponse.json()).company.people.at(-1); + assert.ok(person); + const otherPerson = company.people.find((item) => item.active); + assert.ok(otherPerson); + const createAccess = async (values: AccessFieldsDTO) => { + const response = await request(accessPath, { values }); + assert.equal(response.status, 201, await response.clone().text()); + const item = AccessResponseSchema.parse(await response.json()).items.at(-1); + assert.ok(item); + return item; + }; + const system = await createAccess({ kind: "system", name: "Correo pruebas", active: true }); + const accountFields = { + kind: "account" as const, + systemId: system.id, + environmentId: null, + usage: "shared" as const, + active: true, + expiresOn: null, + notes: null, + }; + const account = await createAccess({ ...accountFields, login: "ventas@example.test" }); + const anotherAccount = await createAccess({ + ...accountFields, + login: "ana@example.test", + usage: "personal", + }); + const today = new Date().toISOString().slice(0, 10); + const authorization = { + kind: "account_person" as const, + personId: person.id, + startDate: today, + endDate: null, + configuration: "pending" as const, + }; + const relation = await createAccess({ ...authorization, accountId: account.id }); + await createAccess({ ...authorization, accountId: anotherAccount.id }); + await createAccess({ ...authorization, personId: otherPerson.id, accountId: account.id }); + const input: MailConfigurationInput = { + personId: person.id, + accountId: account.id, + assetId: first.id, + address: "ventas@example.test", + method: "imap", + state: "configured", + notes: null, + }; + const save = async (values: MailConfigurationInput, id?: string) => { + const response = await request(id ? `${path}/${id}/update` : path, { values }); + assert.equal(response.status, id ? 200 : 201, await response.clone().text()); + return MailConfigurationResponseSchema.parse(await response.json()).configuration; + }; + const error = async (values: unknown, code: string, id?: string, status = 409) => { + const response = await request(id ? `${path}/${id}/update` : path, { values }); + assert.equal(response.status, status, await response.clone().text()); + assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); + }; + await error({ ...input, personId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH"); + await error({ ...input, accountId: system.id }, "MAIL_SCOPE_MISMATCH"); + await error({ ...input, assetId: foreignAsset.id }, "MAIL_SCOPE_MISMATCH"); + await error({ ...input, personId: other.people[0]?.id }, "MAIL_SCOPE_MISMATCH"); + await error({ ...input, address: "invalid" }, "INVALID_REQUEST", undefined, 400); + await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); + await error({ ...input, state: "removed" }, "INVALID_MAIL_TRANSITION"); + assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); + assert.equal((await request(path, { values: input }, undefined, "bad")).status, 403); + const [a, b] = await Promise.all([ + request(path, { values: input }), + request(path, { values: input }), + ]); + assert.deepEqual([a.status, b.status].sort(), [201, 409]); + const created = MailConfigurationResponseSchema.parse( + await (a.status === 201 ? a : b).json() + ).configuration; + assert.equal(created.id[14], "7"); + assert.equal(created.history[0]?.id[14], "7"); + assert.ok(created.history[0]?.actorName); + assert.equal(created.needsReview, false); + assert.deepEqual(await save(input, created.id), created); + const personal = await save({ + ...input, + accountId: anotherAccount.id, + address: "ana@example.test", + state: "pending", + }); + const mobile = await save({ ...input, assetId: second.id, method: "exchange" }); + const shared = await save({ + ...input, + personId: otherPerson.id, + state: "pending", + method: "pop", + }); + assert.notEqual(mobile.id, created.id); + assert.notEqual(personal.id, created.id); + assert.notEqual(shared.id, created.id); + await error({ ...input, assetId: second.id }, "MAIL_TARGET_IMMUTABLE", created.id); + await error({ ...input, state: "removed" }, "INVALID_MAIL_TRANSITION", created.id); + const foreignUpdate = await request(`/mail-configurations/${otherId}/${created.id}/update`, { + values: input, + }); + assert.equal(foreignUpdate.status, 404); + const closedAccess = await request(`${accessPath}/${relation.id}/update`, { + values: { + ...authorization, + accountId: account.id, + endDate: today, + configuration: "never_configured", + }, + }); + assert.equal(closedAccess.status, 200); + const reviewed = MailConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true); + assert.equal(reviewed.find((item) => item.id === personal.id)?.needsReview, false); + await error({ ...input, method: "pop" }, "MAIL_NOT_AUTHORIZED", created.id); + const pending = await save({ ...input, state: "removal_pending" }, created.id); + const removed = await save({ ...input, state: "removed" }, created.id); + assert.equal(pending.history.length, 2); + assert.equal(removed.history.length, 3); + assert.equal(removed.history[0]?.values.state, "configured"); + assert.equal(removed.needsReview, false); + await error(input, "INVALID_MAIL_TRANSITION", removed.id); + assert.equal( + ( + await request(`/companies/${companyId}/people/${person.id}/update`, { + ...personFields, + active: false, + }) + ).status, + 200 + ); + const disabledPerson = MailConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(disabledPerson.find((item) => item.id === mobile.id)?.needsReview, true); + const never = await save({ ...personal.values, state: "never_configured" }, personal.id); + assert.equal(never.history.length, 2); + // No borrar configuraciones ni sus revisiones al terminar un permiso. + const reloaded = MailConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(reloaded.length, 4); + assert.equal(reloaded.find((item) => item.id === removed.id)?.history.length, 3); + await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", { + replacements: { id: mobile.id }, + }); + try { + assert.equal((await request(path)).status, 500); + } finally { + await database.query("UPDATE mail_configurations SET currentKey=:key WHERE id=:id", { + replacements: { + id: mobile.id, + key: [mobile.values.personId, mobile.values.accountId, mobile.values.assetId].join(":"), + }, + }); + } + assert.equal( + MailConfigurationsResponseSchema.parse( + await (await request(`/mail-configurations/${otherId}`)).json() + ).configurations.length, + 0 + ); +} diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index c9e481b..9140587 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -53,6 +53,8 @@ AssetRelationship es la raíz de un vínculo entre dos activos de una compañía Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md). +MailConfiguration es el agregado de un correo configurado en un dispositivo; MailConfigurationRevision conserva cada instantánea con UUID propio. MailConfigurationValues encapsula referencias UniqueID y dirección EmailAddress. Consultar [Correos en dispositivos](../correos-en-dispositivos.md). + Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. diff --git a/docs/correos-en-dispositivos.md b/docs/correos-en-dispositivos.md new file mode 100644 index 0000000..0e07552 --- /dev/null +++ b/docs/correos-en-dispositivos.md @@ -0,0 +1,44 @@ +# Correos en dispositivos + +Cada compañía dispone de una sección «Correos en dispositivos». Un registro relaciona una persona, una cuenta informática y un activo del inventario, con dirección de correo, método (POP, IMAP, Exchange u otro), estado y notas opcionales. Una persona puede tener varias cuentas; una cuenta puede estar en varios dispositivos, con métodos distintos. Las cuentas compartidas admiten varias personas autorizadas. + +La dirección se registra explícitamente porque el identificador de acceso de una cuenta puede ser un nombre de usuario. No se deduce que un sistema sea de correo por su nombre. La configuración no crea permisos sobre la cuenta ni modifica su titularidad. Las cuentas técnicas se excluyen. + +## Uso + +«Configurar correo» abre un modal desde la cabecera. Los selectores reutilizables ofrecen «+» para crear personas, cuentas y dispositivos sin perder lo rellenado. Guardar selecciona el nuevo elemento; cancelar conserva los datos del formulario principal. Los elementos creados permanecen si después se cancela el correo. Las altas relacionadas utilizan opciones existentes y no abren un tercer modal. + +Si falta una autorización vigente de la persona sobre la cuenta, se puede registrar desde el mismo formulario. La ayuda y los ejemplos aparecen inmediatamente antes de Guardar y Cancelar. El listado permite buscar por dirección, persona o dispositivo, editar y consultar el historial. + +## Reglas + +Persona, cuenta y dispositivo pertenecen a la misma compañía. Para crear o modificar una configuración pendiente o configurada se exige persona activa, cuenta personal o compartida activa y no caducada, sistema y entorno activos, dispositivo no retirado y una relación cuenta-persona vigente. La fecha de fin de esa relación es exclusiva, siguiendo los periodos de accesos. + +El estado técnico de la autorización general no sustituye el estado de cada dispositivo: una autorización pendiente de configurar puede justificar la preparación del correo. + +Solo existe una configuración abierta por persona, cuenta y dispositivo. La exclusividad se protege con bloqueo de compañía y clave única en MariaDB; no depende del navegador. La dirección, método y notas se pueden actualizar, conservando revisiones. Persona, cuenta y dispositivo quedan fijados desde el alta. + +Estados: +- Pendiente de configurar → Configurado o No llegó a configurarse. +- Configurado → Pendiente de retirar. +- Pendiente de retirar → Retirado. +- Retirado y No llegó a configurarse son terminales. Una nueva configuración usa otro UUID. + +No se borran registros. Un cambio equivalente no añade una revisión. Cada cambio real conserva fecha, usuario autenticado y valores anteriores con UUID v7 propio. + +Cerrar una autorización, cambiar el titular, desactivar una persona/cuenta/sistema/entorno, caducar una cuenta o retirar un activo conserva las configuraciones. La sección calcula un aviso de revisión para las que siguen abiertas y carecen de autorización vigente. La retirada sigue permitida aunque las referencias estén inactivas. El aviso no afirma que un acceso haya sido revocado físicamente: Metis Desk registra el trabajo y su comprobación. + +## Arquitectura y API + +MailConfiguration es un agregado y MailConfigurationRevision una entidad interna. MailConfigurationValues encapsula referencias UniqueID, EmailAddress y valores inmutables. La persistencia valida referencias, identidad, auditoría, secuencia y última revisión antes de rehidratar. Las migraciones idempotentes crean mail_configurations y mail_configuration_revisions con claves foráneas y exclusividad. + +El módulo de activos expone un servicio público de lectura del inventario. Compañías lo recibe mediante constructor injection; no importa repositorios ni modelos ORM de activos. Las escrituras bloquean la compañía antes de comprobar personas, cuentas, autorizaciones y dispositivos. Los lectores no adquieren bloqueos de catálogo para evitar invertir el orden de bloqueo. + +GET /api/mail-configurations/:companyId devuelve configuraciones, revisiones y needsReview calculado. +POST /api/mail-configurations/:companyId crea y POST /api/mail-configurations/:companyId/:id/update actualiza, recibiendo values. Sesión, origen válido y CSRF son obligatorios; el actor procede de la sesión. + +## Alcance + +Esta entrega registra correos en activos corporativos existentes, incluidos smartphones registrados en el inventario. El registro mínimo de dispositivos personales, listas de baja, configuraciones de impresoras/recursos, licencias y roles quedan pendientes. No almacena credenciales ni configura aplicaciones de correo automáticamente. + +Las pruebas incluyen varias cuentas y dispositivos, cuentas compartidas, fechas, exclusividad concurrente, identidad e historial, pertenencia, autenticación, origen/CSRF, retirada tras cerrar permisos y desactivar personas, corrupción de persistencia, textos es/en y ayuda inferior. El navegador se comprueba con datos de prueba aislados. diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index a7ef178..b6a1531 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -47,6 +47,20 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque const relationships = new AssetRelationshipUseCases(assetRepository); const useCases = new AssetCatalogUseCases(repository); return { + services: { + inventory: { + list: async (companyId: UniqueID) => { + const result = await assetRepository.get(companyId); + if (!result.isSuccess) { + throw new Error("Inventario no disponible."); + } + return result.data.assets.map((asset) => ({ + id: asset.id, + available: asset.values.toPrimitive().status !== "retired", + })); + }, + }, + }, register: (app: Parameters[0]) => { registerAssetCatalogRoutes(app, useCases, authenticate); registerAssetRoutes(app, inventory, authenticate); diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 00f22ba..4666e03 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -1,6 +1,7 @@ -export { catalogKeys } from "./catalog-api"; +export { AssetForm } from "./asset-form"; +export { catalogApi, catalogKeys } from "./catalog-api"; export { AssetCatalogPage } from "./catalog-page"; export { assetsEnglish, assetsSpanish } from "./i18n"; -export { inventoryKeys } from "./inventory-api"; +export { inventoryApi, inventoryKeys } from "./inventory-api"; export { AssetInventoryPage } from "./inventory-page"; export { WorkstationsPage } from "./workstations-page"; diff --git a/modules/companies/package.json b/modules/companies/package.json index 6173bc7..ff7ec83 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/web/access/access.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/mail-configuration-input.mapper.ts b/modules/companies/src/api/application/mail-configuration-input.mapper.ts new file mode 100644 index 0000000..1aadeef --- /dev/null +++ b/modules/companies/src/api/application/mail-configuration-input.mapper.ts @@ -0,0 +1,55 @@ +import { Result } from "@repo/rdx-utils"; + +import type { MailConfiguration } from "../domain/mail-configuration.aggregate"; +import { + MailConfigurationError, + type MailConfigurationInput, +} from "../domain/mail-configuration-values"; +import { MailConfigurationValues } from "../domain/value-objects/mail-configuration-values"; + +import type { MailContext } from "./mail-configuration.repository"; +import { mailAuthorized } from "./mail-configuration-policy"; +export const mapMailValues = MailConfigurationValues.create; + +export function mapMailInput( + input: MailConfigurationInput, + context: MailContext, + previous?: MailConfiguration +) { + const mapped = MailConfigurationValues.create(input); + if (!mapped.isSuccess) { + return mapped; + } + return validateMailInput(mapped.data, context, previous); +} +export function validateMailInput( + values: MailConfigurationValues, + context: MailContext, + previous?: MailConfiguration +) { + const account = context.items.find((item) => item.id.equals(values.accountId))?.values; + if ( + !context.people.some((item) => item.id.equals(values.personId)) || + account?.kind !== "account" || + !context.assets.some((item) => item.id.equals(values.assetId)) + ) { + return Result.fail(new MailConfigurationError("MAIL_SCOPE_MISMATCH")); + } + if ( + ["pending", "configured"].includes(values.state) && + !mailAuthorized(values, context) && + !previous?.values.equals(values) + ) { + return Result.fail(new MailConfigurationError("MAIL_NOT_AUTHORIZED")); + } + if ( + !values.closed && + context.configurations.some( + (item) => + !(item.id.equals(previous?.id) || item.values.closed) && item.values.sameTarget(values) + ) + ) { + return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_EXISTS")); + } + return Result.ok(values); +} diff --git a/modules/companies/src/api/application/mail-configuration-policy.ts b/modules/companies/src/api/application/mail-configuration-policy.ts new file mode 100644 index 0000000..945040e --- /dev/null +++ b/modules/companies/src/api/application/mail-configuration-policy.ts @@ -0,0 +1,38 @@ +import type { MailConfigurationValues } from "../domain/value-objects/mail-configuration-values"; + +import type { MailContext } from "./mail-configuration.repository"; +// La autorización depende del periodo, no de que se haya terminado de configurar un dispositivo. +export function mailAuthorized(values: MailConfigurationValues, context: MailContext) { + const account = context.items.find((item) => item.id.equals(values.accountId))?.values; + if ( + account?.kind !== "account" || + account.usage === "technical" || + !account.active || + (account.expiresOn !== null && account.expiresOn < context.today) || + !context.people.some((person) => person.id.equals(values.personId) && person.active) || + !context.assets.some((asset) => asset.id.equals(values.assetId) && asset.available) + ) { + return false; + } + const system = context.items.find((item) => item.id.toPrimitive() === account.systemId)?.values; + const environment = context.items.find( + (item) => item.id.toPrimitive() === account.environmentId + )?.values; + if ( + system?.kind !== "system" || + !system.active || + (account.environmentId !== null && (environment?.kind !== "environment" || !environment.active)) + ) { + return false; + } + return context.items.some((item) => { + const relation = item.values; + return ( + relation.kind === "account_person" && + relation.personId === values.personId.toPrimitive() && + relation.accountId === values.accountId.toPrimitive() && + relation.startDate <= context.today && + (relation.endDate === null || context.today < relation.endDate) + ); + }); +} diff --git a/modules/companies/src/api/application/mail-configuration-response.builder.ts b/modules/companies/src/api/application/mail-configuration-response.builder.ts new file mode 100644 index 0000000..69557eb --- /dev/null +++ b/modules/companies/src/api/application/mail-configuration-response.builder.ts @@ -0,0 +1,23 @@ +import type { MailConfigurationDTO } from "../../common/mail-configurations"; +import type { MailConfiguration } from "../domain/mail-configuration.aggregate"; + +import type { MailContext } from "./mail-configuration.repository"; +import { mailAuthorized } from "./mail-configuration-policy"; +export function buildMailResponse( + item: MailConfiguration, + context: MailContext +): MailConfigurationDTO { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + needsReview: !(item.values.closed || mailAuthorized(item.values, context)), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: context.actors.get(revision.actorId.toPrimitive()) ?? "—", + recordedAt: revision.recordedAt, + })), + }; +} diff --git a/modules/companies/src/api/application/mail-configuration.repository.ts b/modules/companies/src/api/application/mail-configuration.repository.ts new file mode 100644 index 0000000..40b704d --- /dev/null +++ b/modules/companies/src/api/application/mail-configuration.repository.ts @@ -0,0 +1,26 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { AccessDefinition } from "../domain/access/access-definition"; +import type { MailConfiguration } from "../domain/mail-configuration.aggregate"; +import type { MailConfigurationError } from "../domain/mail-configuration-values"; +export interface IMailInventoryService { + list(companyId: UniqueID): Promise[]>; +} +export type MailContext = Readonly<{ + configurations: readonly MailConfiguration[]; + people: readonly Readonly<{ id: UniqueID; active: boolean }>[]; + items: readonly Readonly<{ id: UniqueID; values: AccessDefinition }>[]; + assets: readonly Readonly<{ id: UniqueID; available: boolean }>[]; + actors: ReadonlyMap; + today: string; +}>; +export interface IMailConfigurationRepository { + get(companyId: UniqueID): Promise>; + save( + companyId: UniqueID, + action: (context: MailContext) => Result + ): Promise< + Result<{ configuration: MailConfiguration; context: MailContext }, MailConfigurationError> + >; +} diff --git a/modules/companies/src/api/application/mail-configuration.use-cases.ts b/modules/companies/src/api/application/mail-configuration.use-cases.ts new file mode 100644 index 0000000..1460c1a --- /dev/null +++ b/modules/companies/src/api/application/mail-configuration.use-cases.ts @@ -0,0 +1,49 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { MailConfiguration } from "../domain/mail-configuration.aggregate"; +import { + MailConfigurationError, + type MailConfigurationInput, +} from "../domain/mail-configuration-values"; + +import type { IMailConfigurationRepository } from "./mail-configuration.repository"; +import { mapMailValues, validateMailInput } from "./mail-configuration-input.mapper"; +export class MailConfigurationUseCases { + constructor(private readonly repository: IMailConfigurationRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new MailConfigurationError("INVALID_REQUEST"))); + } + save(companyId: string, id: string | undefined, input: MailConfigurationInput, actorId: string) { + const company = UniqueID.create(companyId); + const actor = UniqueID.create(actorId); + const identity = id ? UniqueID.create(id) : undefined; + if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) { + return Promise.resolve(Result.fail(new MailConfigurationError("INVALID_REQUEST"))); + } + return this.repository.save(company.data, (context) => { + const previous = identity?.isSuccess + ? context.configurations.find((item) => item.id.equals(identity.data)) + : undefined; + if (id && !previous) { + return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_NOT_FOUND")); + } + const values = mapMailValues(input); + if (!values.isSuccess) { + return values; + } + const now = new Date().toISOString(); + const candidate = previous + ? previous.update(values.data, actor.data, now) + : MailConfiguration.create(company.data, values.data, actor.data, now); + if (!candidate.isSuccess) { + return candidate; + } + const validated = validateMailInput(values.data, context, previous); + return validated.isSuccess ? candidate : validated; + }); + } +} diff --git a/modules/companies/src/api/domain/mail-configuration-revision.entity.ts b/modules/companies/src/api/domain/mail-configuration-revision.entity.ts new file mode 100644 index 0000000..9945ad8 --- /dev/null +++ b/modules/companies/src/api/domain/mail-configuration-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { MailConfigurationValues } from "./value-objects/mail-configuration-values"; +export type MailRevisionProps = Readonly<{ + values: MailConfigurationValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class MailConfigurationRevision extends DomainEntity { + static create(props: MailRevisionProps) { + return new MailConfigurationRevision(Object.freeze({ ...props })); + } + static rehydrate(props: MailRevisionProps, id: UniqueID) { + return new MailConfigurationRevision(Object.freeze({ ...props }), id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} diff --git a/modules/companies/src/api/domain/mail-configuration-values.ts b/modules/companies/src/api/domain/mail-configuration-values.ts new file mode 100644 index 0000000..e6c28b9 --- /dev/null +++ b/modules/companies/src/api/domain/mail-configuration-values.ts @@ -0,0 +1,32 @@ +import { z } from "zod/v4"; +export const MailMethodSchema = z.enum(["pop", "imap", "exchange", "other"]); +export const MailStateSchema = z.enum([ + "pending", + "configured", + "removal_pending", + "removed", + "never_configured", +]); +export const MailConfigurationInputSchema = z.strictObject({ + personId: z.uuid(), + accountId: z.uuid(), + assetId: z.uuid(), + address: z.email().max(254), + method: MailMethodSchema, + state: MailStateSchema, + notes: z.string().trim().max(4000).nullable(), +}); +export type MailConfigurationInput = z.infer; +export class MailConfigurationError extends Error { + constructor(public readonly code: string) { + super(code); + } +} + +export const MAIL_STATE_TRANSITIONS = { + pending: ["pending", "configured", "never_configured"], + configured: ["configured", "removal_pending"], + removal_pending: ["removal_pending", "removed"], + removed: ["removed"], + never_configured: ["never_configured"], +} as const; diff --git a/modules/companies/src/api/domain/mail-configuration.aggregate.ts b/modules/companies/src/api/domain/mail-configuration.aggregate.ts new file mode 100644 index 0000000..2bcd52b --- /dev/null +++ b/modules/companies/src/api/domain/mail-configuration.aggregate.ts @@ -0,0 +1,89 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { MailConfigurationRevision } from "./mail-configuration-revision.entity"; +import { MAIL_STATE_TRANSITIONS, MailConfigurationError } from "./mail-configuration-values"; +import type { MailConfigurationValues } from "./value-objects/mail-configuration-values"; + +type Props = Readonly<{ + companyId: UniqueID; + values: MailConfigurationValues; + history: readonly MailConfigurationRevision[]; +}>; +export class MailConfiguration extends AggregateRoot { + private constructor(props: Props, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: MailConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if ( + !( + z.iso.datetime().safeParse(recordedAt).success && + ["pending", "configured"].includes(values.state) + ) + ) { + return Result.fail(new MailConfigurationError("INVALID_MAIL_TRANSITION")); + } + return Result.ok( + new MailConfiguration({ + companyId, + values, + history: [MailConfigurationRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: Props, id: UniqueID) { + return new MailConfiguration(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: MailConfigurationValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new MailConfigurationError("MAIL_TARGET_IMMUTABLE")); + } + if ( + !MAIL_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) || + (this.values.closed && !this.values.equals(values)) + ) { + return Result.fail(new MailConfigurationError("INVALID_MAIL_TRANSITION")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new MailConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new MailConfiguration( + { + ...this.props, + values, + history: [ + ...this.history, + MailConfigurationRevision.create({ values, actorId, recordedAt }), + ], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/mail-configuration.test.ts b/modules/companies/src/api/domain/mail-configuration.test.ts new file mode 100644 index 0000000..51ac626 --- /dev/null +++ b/modules/companies/src/api/domain/mail-configuration.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { MailContext } from "../application/mail-configuration.repository"; +import { mapMailInput } from "../application/mail-configuration-input.mapper"; +import { mailAuthorized } from "../application/mail-configuration-policy"; +import { MailConfigurationPersistenceMapper } from "../infrastructure/persistence/mail-configuration-persistence.mapper"; + +import { MailConfiguration } from "./mail-configuration.aggregate"; +import type { MailConfigurationInput } from "./mail-configuration-values"; +import { MailConfigurationValues } from "./value-objects/mail-configuration-values"; + +const company = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const account = UniqueID.generateNewID(); +const asset = UniqueID.generateNewID(); +const system = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const input = { + personId: person.toPrimitive(), + accountId: account.toPrimitive(), + assetId: asset.toPrimitive(), + address: "Ana@EXAMPLE.test", + method: "imap" as const, + state: "configured" as const, + notes: null, +}; +function values(data: MailConfigurationInput = input) { + const result = MailConfigurationValues.create(data); + assert.ok(result.isSuccess); + return result.data; +} +function create() { + const result = MailConfiguration.create(company, values(), actor, "2026-10-04T10:00:00.000Z"); + assert.ok(result.isSuccess); + return result.data; +} +const context: MailContext = { + configurations: [], + people: [{ id: person, active: true }], + assets: [{ id: asset, available: true }], + actors: new Map(), + today: "2026-10-04", + items: [ + { id: system, values: { kind: "system", name: "Correo", active: true } }, + { + id: account, + values: { + kind: "account", + systemId: system.toPrimitive(), + environmentId: null, + login: "ana", + usage: "shared", + active: true, + expiresOn: null, + notes: null, + }, + }, + { + id: UniqueID.generateNewID(), + values: { + kind: "account_person", + accountId: account.toPrimitive(), + personId: person.toPrimitive(), + startDate: "2026-10-01", + endDate: null, + configuration: "pending", + }, + }, + ], +}; +test("correo: UUIDv7, normalización, no-op, historial y retirada explícita", () => { + const item = create(); + assert.equal(item.id.toPrimitive()[14], "7"); + assert.equal(item.history[0]?.id.toPrimitive()[14], "7"); + assert.equal(item.values.address.toPrimitive(), "ana@example.test"); + const unchanged = item.update(values(), actor, "2026-10-04T11:00:00.000Z"); + assert.ok(unchanged.isSuccess); + assert.equal(unchanged.data, item); + assert.equal( + item.update(values({ ...input, state: "removed" }), actor, "2026-10-04T11:00:00.000Z") + .isSuccess, + false + ); + const pendingValues = MailConfigurationValues.create({ ...input, state: "removal_pending" }); + assert.ok(pendingValues.isSuccess); + const pending = item.update(pendingValues.data, actor, "2026-10-04T11:00:00.000Z"); + assert.ok(pending.isSuccess); + const removedValues = MailConfigurationValues.create({ ...input, state: "removed" }); + assert.ok(removedValues.isSuccess); + const removed = pending.data.update(removedValues.data, actor, "2026-10-04T12:00:00.000Z"); + assert.ok(removed.isSuccess); + assert.ok(removed.data.id.equals(item.id)); + assert.equal(removed.data.history.length, 3); + assert.equal(removed.data.history[0]?.values.state, "configured"); + assert.equal(removed.data.update(values(), actor, "2026-10-04T13:00:00.000Z").isSuccess, false); +}); +test("correo: autorización vigente y ámbito; varias cuentas y dispositivos, duplicados abiertos", () => { + const item = create(); + assert.equal(mailAuthorized(item.values, context), true); + assert.equal(mapMailInput(input, context).isSuccess, true); + assert.equal(mapMailInput(input, { ...context, configurations: [item] }).isSuccess, false); + assert.equal(mapMailInput(input, { ...context, items: [] }).isSuccess, false); + assert.equal( + mapMailInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess, + false + ); + assert.equal(mapMailInput(input, { ...context, assets: [] }).isSuccess, false); + assert.equal(mailAuthorized(item.values, { ...context, today: "2026-09-30" }), false); + const expired = { + ...context, + items: context.items.map((entry) => + entry.values.kind === "account" + ? { ...entry, values: { ...entry.values, expiresOn: "2026-10-03" } } + : entry + ), + }; + assert.equal(mailAuthorized(item.values, expired), false); + assert.equal(mapMailInput({ ...input, state: "removal_pending" }, expired, item).isSuccess, true); + const anotherAsset = UniqueID.generateNewID(); + assert.equal( + mapMailInput( + { ...input, assetId: anotherAsset.toPrimitive(), method: "pop" }, + { + ...context, + configurations: [item], + assets: [...context.assets, { id: anotherAsset, available: true }], + } + ).isSuccess, + true + ); +}); +test("correo: mapper conserva identidad e historial y rechaza corrupción", () => { + const mapper = new MailConfigurationPersistenceMapper(); + const original = create(); + const persisted = mapper.toPersistence(original); + const restored = mapper.toDomain(persisted.root, persisted.revisions); + assert.ok(restored.id.equals(original.id)); + assert.ok(restored.values.equals(original.values)); + assert.ok(restored.history[0]?.id.equals(original.history[0]?.id)); + assert.throws(() => + mapper.toDomain({ ...persisted.root, personId: actor.toPrimitive() }, persisted.revisions) + ); + assert.throws(() => mapper.toDomain(persisted.root, [])); +}); diff --git a/modules/companies/src/api/domain/value-objects/mail-configuration-values.ts b/modules/companies/src/api/domain/value-objects/mail-configuration-values.ts new file mode 100644 index 0000000..33dbc62 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/mail-configuration-values.ts @@ -0,0 +1,78 @@ +import { EmailAddress, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + MailConfigurationError, + type MailConfigurationInput, + MailConfigurationInputSchema, +} from "../mail-configuration-values"; +export class MailConfigurationValues { + private constructor( + public readonly personId: UniqueID, + public readonly accountId: UniqueID, + public readonly assetId: UniqueID, + public readonly address: EmailAddress, + public readonly method: MailConfigurationInput["method"], + public readonly state: MailConfigurationInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create( + input: MailConfigurationInput + ): Result { + const parsed = MailConfigurationInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new MailConfigurationError("INVALID_REQUEST")); + } + const data = parsed.data; + const person = UniqueID.create(data.personId); + const account = UniqueID.create(data.accountId); + const asset = UniqueID.create(data.assetId); + const address = EmailAddress.create(data.address); + if (!(person.isSuccess && account.isSuccess && asset.isSuccess && address.isSuccess)) { + return Result.fail(new MailConfigurationError("INVALID_REQUEST")); + } + return Result.ok( + new MailConfigurationValues( + person.data, + account.data, + asset.data, + address.data, + data.method, + data.state, + data.notes?.trim() ? Maybe.some(data.notes.trim()) : Maybe.none() + ) + ); + } + get closed() { + return this.state === "removed" || this.state === "never_configured"; + } + sameTarget(other: MailConfigurationValues) { + return ( + this.personId.equals(other.personId) && + this.accountId.equals(other.accountId) && + this.assetId.equals(other.assetId) + ); + } + equals(other: MailConfigurationValues) { + return ( + this.sameTarget(other) && + this.address.equals(other.address) && + this.method === other.method && + this.state === other.state && + this.notes.getOrUndefined() === other.notes.getOrUndefined() + ); + } + toPrimitive(): MailConfigurationInput { + return { + personId: this.personId.toPrimitive(), + accountId: this.accountId.toPrimitive(), + assetId: this.assetId.toPrimitive(), + address: this.address.toPrimitive(), + method: this.method, + state: this.state, + notes: this.notes.getOrUndefined() ?? null, + }; + } +} diff --git a/modules/companies/src/api/infrastructure/http/mail-configuration.routes.ts b/modules/companies/src/api/infrastructure/http/mail-configuration.routes.ts new file mode 100644 index 0000000..e5920df --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/mail-configuration.routes.ts @@ -0,0 +1,81 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { + MailItemParamsSchema, + MailParamsSchema, + SaveMailRequestSchema, +} from "../../../common/mail-configurations"; +import type { MailConfigurationUseCases } from "../../application/mail-configuration.use-cases"; +import { buildMailResponse } from "../../application/mail-configuration-response.builder"; + +function fail(response: Response, code: string) { + response + .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409) + .json({ code, requestId: response.getHeader("x-request-id") }); +} +export function registerMailRoutes( + app: Application, + useCases: MailConfigurationUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:companyId", (request, response, next) => { + const params = MailParamsSchema.safeParse(request.params); + if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + return; + } + useCases + .get(params.data.companyId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.json({ + configurations: result.data.configurations.map((item) => + buildMailResponse(item, result.data) + ), + }); + }) + .catch(next); + }); + for (const path of ["/:companyId", "/:companyId/:id/update"]) { + router.post(path, (request, response, next) => { + const params = (path.endsWith("update") ? MailItemParamsSchema : MailParamsSchema).safeParse( + request.params + ); + const body = SaveMailRequestSchema.safeParse(request.body); + const actor = z + .object({ userId: z.uuid() }) + .safeParse("session" in request ? request.session : undefined); + if ( + !( + params.success && + body.success && + actor.success && + z.strictObject({}).safeParse(request.query).success + ) + ) { + fail(response, "INVALID_REQUEST"); + return; + } + const id = + "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; + useCases + .save(params.data.companyId, id, body.data.values, actor.data.userId) + .then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + return; + } + response.status(id ? 200 : 201).json({ + configuration: buildMailResponse(result.data.configuration, result.data.context), + }); + }) + .catch(next); + }); + } + app.use("/api/mail-configurations", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts b/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts new file mode 100644 index 0000000..646649e --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts @@ -0,0 +1,45 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateMailConfigurations(database: Sequelize) { + const query = database.getQueryInterface(); + const rows = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const reference = (table: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + if (!rows.some((row) => row.name === "mail_configurations")) { + await query.createTable("mail_configurations", { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: reference("companies"), + personId: reference("company_contacts"), + accountId: reference("access_items"), + assetId: reference("assets"), + values: { type: DataTypes.JSON, allowNull: false }, + currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true }, + }); + } + if (!rows.some((row) => row.name === "mail_configuration_revisions")) { + await query.createTable("mail_configuration_revisions", { + id: { type: DataTypes.UUID, primaryKey: true }, + configurationId: reference("mail_configurations"), + position: { type: DataTypes.INTEGER, allowNull: false }, + actorId: reference("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }); + } + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mail_configuration_revisions'", + { type: QueryTypes.SELECT } + ); + if (!indexes.some((index) => index.name === "mail_revision_position")) { + await query.addIndex("mail_configuration_revisions", ["configurationId", "position"], { + unique: true, + name: "mail_revision_position", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts new file mode 100644 index 0000000..2e3f4a2 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts @@ -0,0 +1,106 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { z } from "zod/v4"; + +import { MailConfiguration } from "../../domain/mail-configuration.aggregate"; +import { MailConfigurationRevision } from "../../domain/mail-configuration-revision.entity"; +import { MailConfigurationInputSchema } from "../../domain/mail-configuration-values"; +import { MailConfigurationValues } from "../../domain/value-objects/mail-configuration-values"; +export function mailIdentity(value: string) { + const id = UniqueID.create(value); + if (!id.isSuccess) { + throw new Error("Identidad de correo persistida inconsistente."); + } + return id.data; +} +export function persistedJson(value: unknown): unknown { + return typeof value === "string" ? JSON.parse(value) : value; +} +export type MailRow = { + id: string; + companyId: string; + personId: string; + accountId: string; + assetId: string; + values: unknown; + currentKey: string | null; +}; +export type MailRevisionRow = { + id: string; + configurationId: string; + position: number; + actorId: string; + recordedAt: string; + values: unknown; +}; +function valuesOf(raw: unknown) { + const values = MailConfigurationValues.create( + MailConfigurationInputSchema.parse(persistedJson(raw)) + ); + if (!values.isSuccess) { + throw new Error("Valores de correo persistidos inconsistentes."); + } + return values.data; +} +export class MailConfigurationPersistenceMapper { + toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) { + const values = valuesOf(row.values); + const ordered = [...revisions].sort((a, b) => a.position - b.position); + const history = ordered.map((revision, position) => { + const revisionValues = valuesOf(revision.values); + if ( + revision.position !== position || + !z.iso.datetime().safeParse(revision.recordedAt).success || + revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") || + !values.sameTarget(revisionValues) + ) { + throw new Error("Historial de correo persistido inconsistente."); + } + return MailConfigurationRevision.rehydrate( + { + values: revisionValues, + actorId: mailIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + mailIdentity(revision.id) + ); + }); + if ( + !history.at(-1)?.values.equals(values) || + row.personId !== values.personId.toPrimitive() || + row.accountId !== values.accountId.toPrimitive() || + row.assetId !== values.assetId.toPrimitive() || + row.currentKey !== + (values.closed ? null : [row.personId, row.accountId, row.assetId].join(":")) + ) { + throw new Error("Referencias de correo persistidas inconsistentes."); + } + return MailConfiguration.rehydrate( + { companyId: mailIdentity(row.companyId), values, history }, + mailIdentity(row.id) + ); + } + toPersistence(item: MailConfiguration) { + const values = item.values.toPrimitive(); + return { + root: { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + personId: values.personId, + accountId: values.accountId, + assetId: values.assetId, + values: JSON.stringify(values), + currentKey: item.values.closed + ? null + : [values.personId, values.accountId, values.assetId].join(":"), + }, + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + configurationId: item.id.toPrimitive(), + position, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: JSON.stringify(revision.values.toPrimitive()), + })), + }; + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts new file mode 100644 index 0000000..a1679be --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts @@ -0,0 +1,131 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { + IMailConfigurationRepository, + IMailInventoryService, + MailContext, +} from "../../application/mail-configuration.repository"; +import { AccessDefinitionSchema } from "../../domain/access/access-definition"; +import { MailConfigurationError } from "../../domain/mail-configuration-values"; + +import { + MailConfigurationPersistenceMapper, + type MailRevisionRow, + type MailRow, + mailIdentity, + persistedJson, +} from "./mail-configuration-persistence.mapper"; +export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository { + private readonly mapper = new MailConfigurationPersistenceMapper(); + constructor( + private readonly database: Sequelize, + private readonly inventory: IMailInventoryService + ) {} + private async load( + company: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const companyId = company.toPrimitive(); + // El bloqueo de compañía precede a las lecturas: los escritores de personas, cuentas y activos usan el mismo bloqueo. + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + if (!companies.length) { + return Result.fail(new MailConfigurationError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM mail_configurations WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM mail_configuration_revisions r JOIN mail_configurations c ON c.id=r.configurationId WHERE c.companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const items = await this.database.query<{ id: string; values: unknown }>( + "SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const actors = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + const context: MailContext = { + configurations: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((revision) => revision.configurationId === row.id) + ) + ), + people: people.map((person) => ({ + id: mailIdentity(person.id), + active: Boolean(person.active), + })), + items: items.map((item) => ({ + id: mailIdentity(item.id), + values: AccessDefinitionSchema.parse(persistedJson(item.values)), + })), + assets: await this.inventory.list(company), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + today: new Date().toISOString().slice(0, 10), + }; + if ( + context.configurations.some( + (item) => + !( + context.people.some((person) => person.id.equals(item.values.personId)) && + context.items.some( + (account) => + account.id.equals(item.values.accountId) && account.values.kind === "account" + ) && + context.assets.some((asset) => asset.id.equals(item.values.assetId)) + ) + ) + ) { + throw new Error("Pertenencia de correo persistida inconsistente."); + } + return Result.ok(context); + } + get(companyId: UniqueID) { + return this.database.transaction((transaction) => this.load(companyId, transaction, false)); + } + save(companyId: UniqueID, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const context = await this.load(companyId, transaction, true); + if (!context.isSuccess) { + return context; + } + const candidate = action(context.data); + if (!candidate.isSuccess) { + return candidate; + } + const previous = context.data.configurations.find((item) => + item.id.equals(candidate.data.id) + ); + if (previous !== candidate.data) { + const record = this.mapper.toPersistence(candidate.data); + await this.database.query( + "INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)", + { replacements: record.root, transaction } + ); + const stored = new Set( + previous?.history.map((revision) => revision.id.toPrimitive()) ?? [] + ); + for (const revision of record.revisions.filter((item) => !stored.has(item.id))) { + await this.database.query( + "INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)", + { replacements: revision, transaction } + ); + } + } + return Result.ok({ configuration: candidate.data, context: context.data }); + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index f962ef1..3ce78e7 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -4,8 +4,11 @@ import type { Sequelize } from "sequelize"; import { AccessUseCases } from "./application/access.use-cases"; import { CompanyUseCases } from "./application/company.use-cases"; import { CompanyInputMapper } from "./application/company-input.mapper"; +import type { IMailInventoryService } from "./application/mail-configuration.repository"; +import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases"; import { registerAccessRoutes } from "./infrastructure/http/access.routes"; import { registerCompanyRoutes } from "./infrastructure/http/company.routes"; +import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes"; import { AccessPersistenceMapper, SequelizeAccessRepository, @@ -13,10 +16,12 @@ import { } from "./infrastructure/persistence/access-persistence"; import { initializeCompanyModels } from "./infrastructure/persistence/company.model"; import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper"; +import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration"; import { migrateCompanies } from "./infrastructure/persistence/migrate-companies"; import { migratePeople } from "./infrastructure/persistence/migrate-people"; import { initializePeopleModels } from "./infrastructure/persistence/people.model"; import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository"; +import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository"; export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) { await migrateCompanies(database); @@ -30,6 +35,16 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req ); const useCases = new CompanyUseCases(repository, new CompanyInputMapper()); return { + initializeMail: async (inventory: IMailInventoryService) => { + await migrateMailConfigurations(database); + const mail = new MailConfigurationUseCases( + new SequelizeMailConfigurationRepository(database, inventory) + ); + return { + register: (app: Parameters[0]) => + registerMailRoutes(app, mail, authenticate), + }; + }, register: (app: Parameters[0]) => { registerCompanyRoutes(app, useCases, authenticate); registerAccessRoutes(app, access, authenticate); diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 90a74d9..0319f14 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -5,3 +5,4 @@ export * from "./dto/response/company.response.dto"; export * from "./dto/shared/access.dto"; export * from "./dto/shared/company.dto"; export * from "./dto/shared/people.dto"; +export * from "./mail-configurations"; diff --git a/modules/companies/src/common/mail-configurations.ts b/modules/companies/src/common/mail-configurations.ts new file mode 100644 index 0000000..5488ee7 --- /dev/null +++ b/modules/companies/src/common/mail-configurations.ts @@ -0,0 +1,36 @@ +import { z } from "zod/v4"; + +import { MailConfigurationInputSchema } from "../api/domain/mail-configuration-values"; + +export type { MailConfigurationInput } from "../api/domain/mail-configuration-values"; +export { + MailConfigurationInputSchema, + MailMethodSchema, + MailStateSchema, +} from "../api/domain/mail-configuration-values"; +export const MailParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const MailItemParamsSchema = MailParamsSchema.extend({ id: z.uuid() }); +export const SaveMailRequestSchema = z.strictObject({ values: MailConfigurationInputSchema }); +export const MailRevisionSchema = z.strictObject({ + id: z.uuid(), + values: MailConfigurationInputSchema, + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), +}); +export const MailConfigurationSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: MailConfigurationInputSchema, + history: z.array(MailRevisionSchema).min(1), + needsReview: z.boolean(), +}); +export const MailConfigurationsResponseSchema = z.strictObject({ + configurations: z.array(MailConfigurationSchema), +}); +export const MailConfigurationResponseSchema = z.strictObject({ + configuration: MailConfigurationSchema, +}); +export type MailConfigurationDTO = z.infer; + +export { MAIL_STATE_TRANSITIONS } from "../api/domain/mail-configuration-values"; diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index 12ecdbb..4b9adc9 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -7,6 +7,7 @@ import { BriefcaseIcon, KeyRoundIcon, LayoutTemplateIcon, + MailIcon, MapPinIcon, UsersIcon, } from "@repo/shadcn-ui/lib/icons"; @@ -18,6 +19,7 @@ import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../common"; import { AccessWorkspacePage } from "../access/access-workspace"; import { companyApi, companyKeys } from "../api/company-api"; +import { MailConfigurationsPage } from "../mail/mail-configurations-page"; import { CompanyFeedback } from "./company-feedback"; import { CompanyForm } from "./company-form"; @@ -106,7 +108,8 @@ type CompanyTab = | "access" | "assets" | "inventory" - | "workstations"; + | "workstations" + | "mail"; export function CompanyDetail({ id, @@ -126,9 +129,7 @@ export function CompanyDetail({ const { t: accessTranslation } = useTranslation("access"); const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) }); const [editOpen, setEditOpen] = useState(false); - const [tab, setTab] = useState< - "people" | "departments" | "sites" | "access" | "assets" | "inventory" | "workstations" - >(embedded ? "inventory" : "people"); + const [tab, setTab] = useState(embedded ? "inventory" : "people"); const company = query.data?.company; const layout = embedded ? { @@ -205,6 +206,7 @@ export function CompanyDetail({ ], ["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon], ["access", accessTranslation("title"), KeyRoundIcon], + ["mail", t("mail.title"), MailIcon], ["assets", assetTranslation("companyTitle"), LayoutTemplateIcon], ] as const ).map(([section, label, Icon]) => ( @@ -250,6 +252,8 @@ function CompanySection({ return ( ); + case "mail": + return ; case "workstations": return ; case "inventory": diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index d775a45..05ae499 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -1,4 +1,7 @@ +import { mailEnglish, mailSpanish } from "./mail/mail-i18n"; + export const companiesSpanish = { + mail: mailSpanish, companySpaces: "Espacios de compañías", globalCatalogs: "Catálogos globales", supportWorkspace: "Tu espacio de soporte", @@ -113,6 +116,7 @@ export const companiesSpanish = { }, }; export const companiesEnglish = { + mail: mailEnglish, companySpaces: "Company spaces", globalCatalogs: "Global catalogs", supportWorkspace: "Your support workspace", diff --git a/modules/companies/src/web/mail/mail-api.ts b/modules/companies/src/web/mail/mail-api.ts new file mode 100644 index 0000000..8f974da --- /dev/null +++ b/modules/companies/src/web/mail/mail-api.ts @@ -0,0 +1,43 @@ +import { z } from "zod/v4"; + +import { + type MailConfigurationInput, + MailConfigurationResponseSchema, + MailConfigurationsResponseSchema, +} from "../../common/mail-configurations"; +export const mailKeys = { company: (id: string) => ["mail-configurations", id] }; +export class MailApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +async function request( + companyId: string, + values?: MailConfigurationInput, + token?: string, + id?: string +) { + const response = await fetch( + `/api/mail-configurations/${companyId}${id ? `/${id}/update` : ""}`, + { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, + ...(values ? { body: JSON.stringify({ values }) } : {}), + } + ); + const json: unknown = await response.json(); + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(json); + throw new MailApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return json; +} +export const mailApi = { + async get(companyId: string) { + return MailConfigurationsResponseSchema.parse(await request(companyId)); + }, + async save(companyId: string, values: MailConfigurationInput, token: string, id?: string) { + return MailConfigurationResponseSchema.parse(await request(companyId, values, token, id)); + }, +}; diff --git a/modules/companies/src/web/mail/mail-configuration-form.tsx b/modules/companies/src/web/mail/mail-configuration-form.tsx new file mode 100644 index 0000000..f4f5a09 --- /dev/null +++ b/modules/companies/src/web/mail/mail-configuration-form.tsx @@ -0,0 +1,289 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { inventoryApi, inventoryKeys } from "@metis/assets/client"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; +import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useId, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { AccessItemDTO, CompanyDTO } from "../../common"; +import { + MAIL_STATE_TRANSITIONS, + type MailConfigurationDTO, + type MailConfigurationInput, + MailConfigurationInputSchema, +} from "../../common/mail-configurations"; +import { accessApi, accessKeys } from "../access/access-api"; +import { accessLabel } from "../access/access-labels"; + +import { mailApi, mailKeys } from "./mail-api"; +import { MailFeedback } from "./mail-feedback"; +import { MailQuickEditor } from "./mail-quick-editor"; + +export type QuickKind = "person" | "account" | "asset" | "authorization"; +export function MailConfigurationForm({ + company, + csrfToken, + configuration, + onSaved, +}: { + company: CompanyDTO; + csrfToken: string; + configuration?: MailConfigurationDTO | undefined; + onSaved: () => void; +}) { + const { t } = useTranslation("companies"); + const client = useQueryClient(); + const prefix = useId(); + const [quick, setQuick] = useState(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const access = useQuery({ + queryKey: accessKeys.scope(company.id), + queryFn: () => accessApi.request(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(MailConfigurationInputSchema), + defaultValues: configuration?.values ?? { + personId: "", + accountId: "", + assetId: "", + address: "", + method: "imap", + state: "pending", + notes: null, + }, + }); + const values = form.watch(); + const items = access.data?.items ?? []; + const accounts = items.filter( + (item) => item.values.kind === "account" && item.values.usage !== "technical" + ); + const authorized = hasAuthorization(items, values); + const mutation = useMutation({ + mutationFn: (input: MailConfigurationInput) => + mailApi.save(company.id, input, csrfToken, configuration?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: mailKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const fixed = !!configuration; + const closed = + configuration && ["removed", "never_configured"].includes(configuration.values.state); + const disabled = mutation.isPending || !!closed; + const selected = (field: "personId" | "accountId" | "assetId", value: string) => { + form.setValue(field, value); + }; + const created = (field: "personId" | "accountId" | "assetId", id?: string) => { + if (mounted.current && id) { + form.setValue(field, id); + setQuick(undefined); + } + }; + const selectors = [ + { + field: "personId", + label: "person", + create: "newPerson", + kind: "person", + options: company.people + .filter((item) => item.active || item.id === values.personId) + .map((item) => ({ value: item.id, label: item.name })), + }, + { + field: "accountId", + label: "account", + create: "newAccount", + kind: "account", + options: accounts.map((item) => ({ + value: item.id, + label: accessLabel(item, items, company.people), + })), + }, + { + field: "assetId", + label: "asset", + create: "newAsset", + kind: "asset", + options: (inventory.data?.assets ?? []) + .filter((item) => item.values.status !== "retired" || item.id === values.assetId) + .map((item) => ({ value: item.id, label: item.values.name })), + }, + ] as const; + return ( + <> +
mutation.mutate(input))}> +
+ {selectors.map((selector) => ( +
+ + setQuick(selector.kind), + } + } + disabled={fixed || disabled || !access.data || !inventory.data} + id={`${prefix}-${selector.field}`} + label={t(`mail.${selector.label}`)} + onValueChange={(value) => selected(selector.field, value)} + options={selector.options} + placeholder={t("mail.choose")} + required + value={values[selector.field]} + /> +
+ ))} +
+ + +
+
+ + { + const parsed = MailConfigurationInputSchema.shape.method.safeParse(value); + if (parsed.success) { + form.setValue("method", parsed.data); + } + }} + options={MailConfigurationInputSchema.shape.method.options.map((value) => ({ + value, + label: t(`mail.${value}`), + }))} + placeholder={t("mail.choose")} + required + value={values.method} + /> +
+
+ + { + const parsed = MailConfigurationInputSchema.shape.state.safeParse(value); + if (parsed.success) { + form.setValue("state", parsed.data); + } + }} + options={allowedStates(configuration).map((value) => ({ + value, + label: t(`mail.${value}`), + }))} + placeholder={t("mail.choose")} + required + value={values.state} + /> +
+
+ {values.personId && values.accountId && !authorized && !closed && ( +
+

{t("mail.noAuthorization")}

+ +
+ )} +
+ +