diff --git a/README.md b/README.md index bab881a..3702612 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Aplicación interna para soporte técnico a pequeñas empresas. - [Inventario de activos](docs/inventario-de-activos.md) - [Puestos de trabajo](docs/puestos-de-trabajo.md) - [Perfiles de trabajo](docs/perfiles-de-trabajo.md) +- [Buzones y correo por perfil](docs/correos-en-dispositivos.md) - [Componentes de interfaz](docs/componentes-de-interfaz.md) - [Alcance y siguientes entregas](docs/plan-de-desarrollo.md) -Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. También incluye inventario configurable, puestos con activos exclusivos y compartidos, y la base de perfiles de trabajo por persona y dispositivo. Las configuraciones del entorno y los tickets se incorporarán progresivamente. +Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. También incluye inventario configurable, puestos con activos exclusivos y compartidos, perfiles de trabajo por persona y dispositivo, buzones independientes y correo POP/IMAP por perfil con periodos de uso e historial. Las configuraciones del entorno y los tickets se incorporarán progresivamente. diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 4385c33..a41e562 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -41,6 +41,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de 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 mailbox_use_revisions"); + await database.query("DELETE FROM mailbox_uses"); + await database.query("DELETE FROM mailbox_revisions"); + await database.query("DELETE FROM mailboxes"); await database.query("DELETE FROM work_profile_revisions"); await database.query("DELETE FROM work_profiles"); await database.query("DELETE FROM workstation_assets"); diff --git a/apps/server/src/mail.integration.ts b/apps/server/src/mail.integration.ts index 7632d3e..5eaaffb 100644 --- a/apps/server/src/mail.integration.ts +++ b/apps/server/src/mail.integration.ts @@ -1,15 +1,19 @@ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { InventoryResponseSchema } from "@metis/assets/common"; import { - type AccessFieldsDTO, AccessResponseSchema, CompanyResponseSchema, type MailConfigurationInput, MailConfigurationResponseSchema, MailConfigurationsResponseSchema, + type MailboxInput, + type MailboxUseInput, + MailboxesResponseSchema, + WorkProfileResponseSchema, } from "@metis/companies/common"; -import type { Sequelize } from "sequelize"; +import { QueryTypes, type Sequelize } from "sequelize"; import { z } from "zod/v4"; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise; @@ -20,33 +24,29 @@ export async function verifyMail( database: Sequelize ) { const path = `/mail-configurations/${companyId}`; - const accessPath = `/access/${companyId}`; + const boxPath = `/mailboxes/${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( + const assets = 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" }, + ).assets; + const first = assets[0]; + const second = assets[1]; + const otherPerson = company.people.find((item) => item.active); + const foreignPerson = other.people[0]; + assert.ok(first && second && otherPerson && foreignPerson); + for (const asset of [first, second]) { + if (asset.values.status === "retired") { + const response = await request(`/assets/${companyId}/${asset.id}/update`, { + values: { ...asset.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, @@ -59,65 +59,100 @@ export async function verifyMail( 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 }); + const today = new Date().toISOString().slice(0, 10); + const profile = async (personId: string, assetId: string) => { + const response = await request(`/work-profiles/${companyId}`, { + values: { + name: `Perfil correo ${assetId}`, + personId, + assetId, + accountId: null, + active: true, + notes: null, + }, + }); assert.equal(response.status, 201, await response.clone().text()); - const item = AccessResponseSchema.parse(await response.json()).items.at(-1); - assert.ok(item); - return item; + return WorkProfileResponseSchema.parse(await response.json()).profile; }; - 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, + const primary = await profile(person.id, first.id); + const mobileProfile = await profile(person.id, second.id); + const sharedProfile = await profile(otherPerson.id, first.id); + const saveBox = async (values: MailboxInput, id?: string) => { + const response = await request(boxPath + (id ? `/${id}/update` : ""), { values }); + assert.equal(response.status, id ? 200 : 201, await response.clone().text()); + const data = MailboxesResponseSchema.parse(await response.json()); + const box = data.mailboxes.find((item) => item.id === data.savedId); + assert.ok(box); + return box; + }; + const saveUse = async (values: MailboxUseInput, id?: string) => { + const response = await request(`${boxPath}/uses${id ? `/${id}/update` : ""}`, { values }); + assert.equal(response.status, id ? 200 : 201, await response.clone().text()); + const data = MailboxesResponseSchema.parse(await response.json()); + const use = data.uses.find((item) => item.id === data.savedId); + assert.ok(use); + return use; + }; + const boxFields: MailboxInput = { + address: "ventas@example.test", + kind: "corporate", + context: null, + accountId: null, 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 box = await saveBox(boxFields); + const external = await saveBox({ + ...boxFields, + address: "ana@outside.test", + kind: "external", + context: "Colaboración", }); - 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 useFields = { mailboxId: box.id, personId: person.id, startDate: today, endDate: null }; + const authorization = await saveUse(useFields); + await saveUse({ ...useFields, mailboxId: external.id }); + await saveUse({ ...useFields, personId: otherPerson.id }); + assert.equal(box.id[14], "7"); + assert.equal(box.values.accountId, null); + assert.deepEqual(await saveBox(boxFields, box.id), box); + const duplicateBox = await request(boxPath, { + values: { ...boxFields, address: "VENTAS@EXAMPLE.TEST" }, + }); + assert.equal(duplicateBox.status, 409); + assert.equal((await request(`${boxPath}/uses`, { values: useFields })).status, 409); + assert.equal( + (await request(`${boxPath}/uses`, { values: { ...useFields, personId: foreignPerson.id } })) + .status, + 409 + ); + assert.equal((await request(boxPath, { values: { ...boxFields, address: "bad" } })).status, 400); + assert.equal( + ( + await request(boxPath, { + values: { ...boxFields, address: "other@example.test", accountId: foreignPerson.id }, + }) + ).status, + 409 + ); const input: MailConfigurationInput = { - personId: person.id, - accountId: account.id, - assetId: first.id, - address: "ventas@example.test", + profileId: primary.id, + mailboxId: box.id, method: "imap", state: "configured", notes: null, }; const save = async (values: MailConfigurationInput, id?: string) => { - const response = await request(id ? `${path}/${id}/update` : path, { values }); + const response = await request(path + (id ? `/${id}/update` : ""), { 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 }); + const response = await request(path + (id ? `/${id}/update` : ""), { 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, profileId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH"); + await error({ ...input, mailboxId: foreignPerson.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"); @@ -136,37 +171,27 @@ export async function verifyMail( 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 personal = await save({ ...input, mailboxId: external.id, state: "pending" }); + const mobile = await save({ ...input, profileId: mobileProfile.id, method: "exchange" }); const shared = await save({ ...input, - personId: otherPerson.id, + profileId: sharedProfile.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); + assert.equal(new Set([created.id, personal.id, mobile.id, shared.id]).size, 4); + await error( + { ...input, profileId: mobileProfile.id, state: "removed" }, + "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); + assert.equal( + (await request(`/mail-configurations/${otherId}/${created.id}/update`, { values: input })) + .status, + 404 + ); + await saveUse({ ...useFields, endDate: today }, authorization.id); const reviewed = MailConfigurationsResponseSchema.parse( await (await request(path)).json() ).configurations; @@ -180,27 +205,112 @@ export async function verifyMail( 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 deactivate = await request(`/companies/${companyId}/people/${person.id}/update`, { + ...personFields, + active: false, + }); + assert.equal(deactivate.status, 200); + const inactive = MailConfigurationsResponseSchema.parse( + await (await request(path)).json() + ).configurations; + assert.equal(inactive.find((item) => item.id === mobile.id)?.needsReview, true); + const never = await save( + { ...input, mailboxId: external.id, state: "never_configured" }, + personal.id ); - 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( + + // Simula un registro previo: no se reescriben sus UUID ni sus revisiones al vincularlo. + const active = await request(`/companies/${companyId}/people/${person.id}/update`, personFields); + assert.equal(active.status, 200); + await saveUse( + { ...useFields, mailboxId: external.id, startDate: today, endDate: today }, + undefined + ); + const systemResponse = await request(`/access/${companyId}`, { + values: { kind: "system", name: "Correo legado", active: true }, + }); + assert.equal(systemResponse.status, 201); + const system = AccessResponseSchema.parse(await systemResponse.json()).items.at(-1); + assert.ok(system); + const accountResponse = await request(`/access/${companyId}`, { + values: { + kind: "account", + systemId: system.id, + environmentId: null, + usage: "shared", + login: "legado", + active: true, + expiresOn: null, + notes: null, + }, + }); + assert.equal(accountResponse.status, 201); + const account = AccessResponseSchema.parse(await accountResponse.json()).items.at(-1); + assert.ok(account); + const legacyId = randomUUID(); + const revisionId = randomUUID(); + const actorId = created.history[0]?.actorId; + assert.ok(actorId); + const legacyValues = { + personId: person.id, + accountId: account.id, + assetId: first.id, + address: "legacy@outside.test", + method: "imap", + state: "configured", + notes: "Conservar", + }; + const raw = JSON.stringify(legacyValues); + await database.query( + "INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)", + { + replacements: { + id: legacyId, + companyId, + ...legacyValues, + values: raw, + key: [person.id, account.id, first.id].join(":"), + }, + } + ); + await database.query( + "INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,0,:actorId,:recordedAt,:values)", + { + replacements: { + id: revisionId, + configurationId: legacyId, + actorId, + recordedAt: new Date().toISOString(), + values: raw, + }, + } + ); + const legacy = 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); + ).configurations.find((item) => item.id === legacyId); + assert.ok(legacy?.legacy); + assert.equal(legacy.needsReview, true); + await error({ ...input, mailboxId: external.id }, "MAIL_LEGACY_TARGET_MISMATCH", legacyId); + const legacyBox = await saveBox({ + ...boxFields, + address: legacyValues.address, + kind: "external", + }); + await saveUse({ ...useFields, mailboxId: legacyBox.id }); + const adopted = await save({ ...input, mailboxId: legacyBox.id, notes: "Conservar" }, legacyId); + assert.equal(adopted.id, legacyId); + assert.equal(adopted.legacy, false); + assert.equal(adopted.history[0]?.id, revisionId); + assert.equal(adopted.history.length, 2); + assert.deepEqual(adopted.history[0]?.values, legacyValues); + const persisted = await database.query<{ values: string }>( + "SELECT `values` FROM mail_configuration_revisions WHERE id=:id", + { replacements: { id: revisionId }, type: QueryTypes.SELECT } + ); + const original = persisted[0]?.values; + assert.ok(original); + assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues); await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", { replacements: { id: mobile.id }, }); @@ -208,10 +318,7 @@ export async function verifyMail( 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(":"), - }, + replacements: { id: mobile.id, key: `${mobileProfile.id}:${box.id}` }, }); } assert.equal( diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 3fb79ad..7512f70 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -55,7 +55,9 @@ Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUI WorkProfile es la raíz del perfil de una persona en un dispositivo; WorkProfileRevision conserva cada instantánea con UUID propio. WorkProfileValues encapsula Name, referencias UniqueID y opcionales Maybe. Consultar [Perfiles de trabajo](../perfiles-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). +Mailbox y MailboxUse son raíces de buzón y periodo de uso por persona. MailboxRevision y MailboxUseRevision son entidades internas con UUID propio. MailboxValues y MailboxUseValues encapsulan EmailAddress, referencias UniqueID, CalendarDate y opcionales Maybe. + +MailConfiguration es el agregado de un buzón configurado en un perfil; 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. diff --git a/docs/correos-en-dispositivos.md b/docs/correos-en-dispositivos.md index 8a56c57..d06f4ef 100644 --- a/docs/correos-en-dispositivos.md +++ b/docs/correos-en-dispositivos.md @@ -1,48 +1,62 @@ -# Correos en dispositivos +# Buzones y correo por perfil -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. +Un buzón identifica una dirección de correo, independiente de la cuenta informática usada para iniciar sesión en Windows, un dominio o Microsoft 365. Una persona puede utilizar varios buzones y un buzón puede estar configurado en varios perfiles, con un método distinto en cada uno. -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. +## Buzones -## Uso +La sección «Buzones de correo» permite crear y editar dirección, tipo corporativo o externo, contexto opcional, estado activo, notas y una cuenta informática relacionada opcional. El dominio de la dirección no determina su propiedad: una compañía puede trabajar con varios dominios y las personas pueden utilizar correos externos personales, de clientes, proveedores o colaboraciones. -«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. +La cuenta relacionada es solo una referencia documental. No concede acceso ni es obligatoria. Su desactivación o caducidad no sustituye la revisión del buzón y sus usos. Dirección y contexto se pueden actualizar con historial; el tipo queda fijado desde el alta. La dirección se normaliza y es única dentro de la compañía, incluso en buzones inactivos. -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. +«Vincular persona» registra un periodo de uso. En un buzón corporativo representa una autorización documentada; en uno externo documenta el uso sin atribuir su propiedad a la compañía. El inicio está incluido y el día de fin está excluido. Se admite finalizar el mismo día, sin periodo vigente. No se solapan periodos de la misma persona y buzón; varias personas pueden utilizar el mismo buzón simultáneamente. -## Reglas +Persona, buzón e inicio quedan fijos. Finalizar conserva el historial; un periodo finalizado no se reabre ni modifica. Para otro periodo se crea una nueva vinculación. Se pueden cerrar usos aunque la persona o el buzón ya estén inactivos. -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. +## Configuración por perfil -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. +«Correos en dispositivos» relaciona **perfil de trabajo + buzón**, método (POP, IMAP, Exchange u otro), estado y notas. El perfil identifica la persona y el equipo. No se solicita otra cuenta informática ni se vuelve a introducir la dirección. También se puede consultar y configurar el correo desde cada fila de «Perfiles de trabajo». -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. +Por ejemplo, el perfil de Ana en su portátil puede tener agarcia@uecko.com por IMAP, administracion@pino.com por IMAP y pedidos@uecko.com por POP. Ese mismo buzón podría configurarse en su smartphone mediante otro perfil. El cliente de correo y otras necesidades del perfil se incorporarán en entregas posteriores. + +El alta abre un modal desde la cabecera. Los selectores reutilizables ofrecen «+» para crear perfiles y buzones sin perder el formulario principal. Si falta el periodo de uso, una acción explícita permite registrarlo. El modal adicional utiliza opciones existentes; no abre un tercer nivel. Guardar selecciona el elemento creado; cancelar conserva los valores del padre. Una alta ya guardada permanece aunque se cancele el correo. La ayuda aparece inmediatamente antes de Guardar y Cancelar. + +## Reglas y revisión + +Todas las referencias pertenecen a la compañía. Para crear una configuración pendiente o configurada se exige perfil y buzón activos, persona activa, equipo no retirado y uso vigente del buzón por esa persona. Cambiar el método o pasar a configurado vuelve a comprobar la disponibilidad. Se permiten notas y retirada aunque ya se haya perdido la disponibilidad. + +Solo existe una configuración abierta por perfil y buzón. El bloqueo de compañía y una clave única protegen la exclusividad concurrente. Ambos destinos quedan fijos tras el alta. No se borran registros; un cambio equivalente no añade revisión. 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. +- Retirado y No llegó a configurarse son terminales. Una nueva configuración utiliza 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. +Finalizar el uso, desactivar la persona o el buzón, archivar el perfil o retirar el equipo conserva las configuraciones y marca para revisión las que sigan abiertas. No se revocan accesos ni se modifica físicamente el equipo automáticamente. La dirección actual se muestra desde el buzón; las revisiones conservan la dirección conocida al registrarlas. -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. +## Compatibilidad con registros anteriores + +La migración amplía referencias y admite el formato anterior en lectura. No genera buzones, no atribuye su propiedad y no inventa perfiles. Conserva UUID, valores y revisiones anteriores. + +Los registros abiertos anteriores aparecen como pendientes de vinculación. Editar permite elegir o crear un buzón con la misma dirección y un perfil de la misma persona y dispositivo. Se conserva el UUID del registro y se añade una revisión; las anteriores mantienen su formato y cuenta informática original. Los registros terminales anteriores siguen disponibles en el historial. ## 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. +Mailbox y MailboxUse son AggregateRoot; sus revisiones son DomainEntity. MailboxValues y MailboxUseValues son Value Objects inmutables, con EmailAddress, UniqueID, CalendarDate y Maybe. MailConfiguration conserva su agregado y revisiones, con referencias al perfil y al buzón. Las altas generan UUID v7; actualizar y rehidratar conservan la identidad. -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. +Las migraciones explícitas e idempotentes crean mailboxes, mailbox_uses y sus revisiones, y amplían mail_configurations. La persistencia verifica referencias, pertenencia, historial ordenado, última revisión y claves derivadas. Las escrituras son transaccionales, bloqueando primero la compañía. El inventario se consulta mediante el servicio público de activos, inyectado en compañías. -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. +- GET /api/mailboxes/:companyId devuelve buzones, usos e historial. +- POST /api/mailboxes/:companyId y POST /api/mailboxes/:companyId/:id/update reciben values del buzón. +- POST /api/mailboxes/:companyId/uses y POST /api/mailboxes/:companyId/uses/:id/update reciben values del periodo. +- GET /api/mail-configurations/:companyId devuelve configuraciones, revisiones, legacy y needsReview calculado. +- POST /api/mail-configurations/:companyId y POST /api/mail-configurations/:companyId/:id/update reciben values con profileId, mailboxId, method, state y notes. -## Alcance +Sesión obligatoria; las escrituras exigen origen válido y CSRF. El actor se obtiene de la sesión. No se almacenan contraseñas. -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. +## Alcance y pruebas -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. +Incluye buzones corporativos y externos, varios buzones por persona, varios perfiles por buzón, periodos de uso, revisión de retirada y conservación del legado. Los dispositivos externos personales, programas, carpetas, impresoras, certificados, listas de baja, licencias y roles quedan pendientes. -## Evolución acordada - -El contrato implementado en esta entrega exige una cuenta informática. Está pendiente adaptarlo a buzones independientes, corporativos o externos, y configuraciones por perfil; la cuenta informática dejará de ser obligatoria. La base de [perfiles de trabajo](perfiles-de-trabajo.md) ya está disponible. No se han migrado ni vinculado automáticamente los correos existentes a perfiles. +Las pruebas cubren fechas y solapes, permisos vigentes, múltiples buzones/perfiles/personas, UUID e historial, idempotencia, ámbito, concurrencia, protección HTTP, retirada, vinculación explícita del legado, corrupción persistida y textos es/en con ayuda inferior. diff --git a/docs/perfiles-de-trabajo.md b/docs/perfiles-de-trabajo.md index 7dd48e6..6e15220 100644 --- a/docs/perfiles-de-trabajo.md +++ b/docs/perfiles-de-trabajo.md @@ -40,6 +40,8 @@ GET /api/work-profiles/:companyId devuelve los perfiles y sus avisos. POST /api/ Las pruebas cubren cuentas opcionales, parejas vigentes únicas, múltiples personas/dispositivos, UUID, idempotencia, identidad funcional fija, revisiones, disponibilidad, caducidad, autorizaciones, aislamiento, concurrencia, persistencia y protección HTTP. Las pruebas de interfaz comprueban traducciones, ausencia de formularios junto a listados, altas rápidas y ayuda inferior. -Esta entrega establece la base del perfil. Todavía no incorpora buzones independientes ni configura correo, carpetas, programas, impresoras o certificados desde el perfil. La ficha muestra explícitamente que esas configuraciones no están registradas; no calcula una preparación ficticia. El módulo actual de correos mantiene su contrato anterior hasta su adaptación, preservando datos e historial. La vista completa Entorno actual sigue pendiente y el mapa visual queda aplazado. +La base del perfil incorpora ahora [buzones y configuraciones de correo](correos-en-dispositivos.md). Cada perfil puede tener varios buzones y cada configuración conserva su método y estado. La acción «Correos en dispositivos» de la fila abre la consulta filtrada del perfil. No se confunde la cuenta de inicio de sesión con el buzón. + +La configuración de carpetas, programas, impresoras y certificados, y la vista completa Entorno actual siguen pendientes. El mapa visual queda aplazado. La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector compartido, su incorporación visual y la cancelación sin persistir cambios. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 491a6ce..7ea0bd9 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -43,9 +43,13 @@ Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos Persona y dispositivo fijos, cuenta de inicio de sesión opcional, perfiles vigentes y archivados, múltiples personas/dispositivos, historial y avisos de revisión. Consultar [Perfiles de trabajo](perfiles-de-trabajo.md). La vista completa Entorno actual y sus configuraciones se incorporarán progresivamente; el mapa visual queda aplazado. +## Buzones y correo por perfil: implementado + +Buzones independientes corporativos o externos, cuenta informática opcional, periodos de uso por persona, configuraciones POP/IMAP por perfil y avisos de revisión. Los registros anteriores mantienen UUID e historial y se vinculan explícitamente. Consultar [Buzones y correo por perfil](correos-en-dispositivos.md). + ## Siguientes entregas: orden actualizado -1. Adaptación de correos al perfil y buzones independientes: correos con POP/IMAP, carpetas compartidas, impresoras y recursos. +1. Configuraciones del perfil: carpetas compartidas, impresoras y recursos; después programas y certificados. 2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. 3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. diff --git a/modules/companies/package.json b/modules/companies/package.json index 6ffe865..94d3971 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/api/domain/mail-configuration.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.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/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.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 index 1aadeef..f2fcdd4 100644 --- a/modules/companies/src/api/application/mail-configuration-input.mapper.ts +++ b/modules/companies/src/api/application/mail-configuration-input.mapper.ts @@ -1,3 +1,4 @@ +import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { MailConfiguration } from "../domain/mail-configuration.aggregate"; @@ -9,36 +10,50 @@ import { MailConfigurationValues } from "../domain/value-objects/mail-configurat 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); + const mailboxId = UniqueID.create(input.mailboxId); + const profileId = UniqueID.create(input.profileId); + if (!(mailboxId.isSuccess && profileId.isSuccess)) { + return Result.fail(new MailConfigurationError("INVALID_REQUEST")); + } + const mailbox = context.mailboxes.find((item) => item.id.equals(mailboxId.data)); + const profile = context.profiles.find((item) => item.id.equals(profileId.data)); + if (!(mailbox && profile)) { + return Result.fail(new MailConfigurationError("MAIL_SCOPE_MISMATCH")); + } + const mapped = MailConfigurationValues.create({ + ...input, + profileId: profile.id.toPrimitive(), + mailboxId: mailbox.id.toPrimitive(), + address: mailbox.values.address.toPrimitive(), + }); 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)) + previous?.values.legacy && + (previous.values.legacy.personId !== profile.values.personId.toPrimitive() || + previous.values.legacy.assetId !== profile.values.assetId.toPrimitive() || + !previous.values.address.equals(mailbox.values.address)) ) { - return Result.fail(new MailConfigurationError("MAIL_SCOPE_MISMATCH")); + return Result.fail(new MailConfigurationError("MAIL_LEGACY_TARGET_MISMATCH")); + } + const values = mapped.data; + if (previous?.values.closed && !previous.values.equals(values)) { + return Result.fail(new MailConfigurationError("INVALID_MAIL_TRANSITION")); } if ( ["pending", "configured"].includes(values.state) && !mailAuthorized(values, context) && - !previous?.values.equals(values) + (!previous || + previous.values.legacy || + previous.values.state !== values.state || + previous.values.method !== values.method) ) { return Result.fail(new MailConfigurationError("MAIL_NOT_AUTHORIZED")); } @@ -46,10 +61,12 @@ export function validateMailInput( !values.closed && context.configurations.some( (item) => - !(item.id.equals(previous?.id) || item.values.closed) && item.values.sameTarget(values) + item.id.toPrimitive() !== previous?.id.toPrimitive() && + !item.values.closed && + item.values.sameTarget(values) ) ) { return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_EXISTS")); } - return Result.ok(values); + return mapped; } diff --git a/modules/companies/src/api/application/mail-configuration-policy.ts b/modules/companies/src/api/application/mail-configuration-policy.ts index 945040e..c279fd3 100644 --- a/modules/companies/src/api/application/mail-configuration-policy.ts +++ b/modules/companies/src/api/application/mail-configuration-policy.ts @@ -1,38 +1,29 @@ 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; + const profile = context.profiles.find((item) => + item.id.equals(values.profileId.getOrUndefined()) + ); + const mailbox = context.mailboxes.find((item) => + item.id.equals(values.mailboxId.getOrUndefined()) + ); 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) + !( + profile && + profile.values.active && + mailbox && + mailbox.values.active && + context.people.some((person) => person.id.equals(profile.values.personId) && person.active) && + context.assets.some((asset) => asset.id.equals(profile.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) - ); - }); + return context.uses.some( + (use) => + use.values.mailboxId.equals(mailbox.id) && + use.values.personId.equals(profile.values.personId) && + use.values.current(context.today) + ); } diff --git a/modules/companies/src/api/application/mail-configuration-response.builder.ts b/modules/companies/src/api/application/mail-configuration-response.builder.ts index 69557eb..4f51cf5 100644 --- a/modules/companies/src/api/application/mail-configuration-response.builder.ts +++ b/modules/companies/src/api/application/mail-configuration-response.builder.ts @@ -11,6 +11,7 @@ export function buildMailResponse( id: item.id.toPrimitive(), companyId: item.companyId.toPrimitive(), values: item.values.toPrimitive(), + legacy: !!item.values.legacy, needsReview: !(item.values.closed || mailAuthorized(item.values, context)), history: item.history.map((revision) => ({ id: revision.id.toPrimitive(), diff --git a/modules/companies/src/api/application/mail-configuration.repository.ts b/modules/companies/src/api/application/mail-configuration.repository.ts index 40b704d..0876748 100644 --- a/modules/companies/src/api/application/mail-configuration.repository.ts +++ b/modules/companies/src/api/application/mail-configuration.repository.ts @@ -1,16 +1,20 @@ 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"; +import type { WorkProfile } from "../domain/work-profile.aggregate"; + +import type { MailboxContext } from "./mailbox.repository"; 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 }>[]; + mailboxes: MailboxContext["mailboxes"]; + uses: MailboxContext["uses"]; + profiles: readonly WorkProfile[]; assets: readonly Readonly<{ id: UniqueID; available: boolean }>[]; actors: ReadonlyMap; today: string; diff --git a/modules/companies/src/api/application/mail-configuration.use-cases.ts b/modules/companies/src/api/application/mail-configuration.use-cases.ts index 1460c1a..41f1971 100644 --- a/modules/companies/src/api/application/mail-configuration.use-cases.ts +++ b/modules/companies/src/api/application/mail-configuration.use-cases.ts @@ -8,7 +8,7 @@ import { } from "../domain/mail-configuration-values"; import type { IMailConfigurationRepository } from "./mail-configuration.repository"; -import { mapMailValues, validateMailInput } from "./mail-configuration-input.mapper"; +import { mapMailInput } from "./mail-configuration-input.mapper"; export class MailConfigurationUseCases { constructor(private readonly repository: IMailConfigurationRepository) {} get(companyId: string) { @@ -31,7 +31,7 @@ export class MailConfigurationUseCases { if (id && !previous) { return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_NOT_FOUND")); } - const values = mapMailValues(input); + const values = mapMailInput(input, context, previous); if (!values.isSuccess) { return values; } @@ -42,8 +42,7 @@ export class MailConfigurationUseCases { if (!candidate.isSuccess) { return candidate; } - const validated = validateMailInput(values.data, context, previous); - return validated.isSuccess ? candidate : validated; + return candidate; }); } } diff --git a/modules/companies/src/api/application/mailbox-input.mapper.ts b/modules/companies/src/api/application/mailbox-input.mapper.ts new file mode 100644 index 0000000..5efccfc --- /dev/null +++ b/modules/companies/src/api/application/mailbox-input.mapper.ts @@ -0,0 +1,60 @@ +import { Result } from "@repo/rdx-utils"; + +import type { Mailbox } from "../domain/mailbox.aggregate"; +import type { MailboxUse } from "../domain/mailbox-use.aggregate"; +import { MailboxError, type MailboxInput, type MailboxUseInput } from "../domain/mailbox-values"; +import { MailboxUseValues } from "../domain/value-objects/mailbox-use-values"; +import { MailboxValues } from "../domain/value-objects/mailbox-values"; + +import type { MailboxContext } from "./mailbox.repository"; + +export function mapMailboxInput(input: MailboxInput, context: MailboxContext, previous?: Mailbox) { + const values = MailboxValues.create(input); + if (!values.isSuccess) { + return values; + } + if ( + context.mailboxes.some( + (item) => + item.id.toPrimitive() !== previous?.id.toPrimitive() && + item.values.address.equals(values.data.address) + ) + ) { + return Result.fail(new MailboxError("MAILBOX_EXISTS")); + } + const accountId = values.data.accountId.getOrUndefined(); + if ( + accountId && + !context.items.some((item) => item.id.equals(accountId) && item.values.kind === "account") + ) { + return Result.fail(new MailboxError("MAILBOX_SCOPE_MISMATCH")); + } + return values; +} +export function mapMailboxUseInput( + input: MailboxUseInput, + context: MailboxContext, + previous?: MailboxUse +) { + const values = MailboxUseValues.create(input); + if (!values.isSuccess) { + return values; + } + const mailbox = context.mailboxes.find((item) => item.id.equals(values.data.mailboxId)); + const person = context.people.find((item) => item.id.equals(values.data.personId)); + if (!(mailbox && person)) { + return Result.fail(new MailboxError("MAILBOX_SCOPE_MISMATCH")); + } + if (!(previous || (mailbox.values.active && person.active))) { + return Result.fail(new MailboxError("MAILBOX_UNAVAILABLE")); + } + if ( + context.uses.some( + (item) => + item.id.toPrimitive() !== previous?.id.toPrimitive() && item.values.overlaps(values.data) + ) + ) { + return Result.fail(new MailboxError("MAILBOX_USE_OVERLAP")); + } + return values; +} diff --git a/modules/companies/src/api/application/mailbox-response.builder.ts b/modules/companies/src/api/application/mailbox-response.builder.ts new file mode 100644 index 0000000..8ade62d --- /dev/null +++ b/modules/companies/src/api/application/mailbox-response.builder.ts @@ -0,0 +1,31 @@ +import type { MailboxDTO, MailboxUseDTO } from "../../common/mailboxes"; + +import type { MailboxContext } from "./mailbox.repository"; +export function buildMailboxResponse(context: MailboxContext) { + const actorName = (id: string) => context.actors.get(id) ?? id; + const mailboxes: MailboxDTO[] = context.mailboxes.map((item) => ({ + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: actorName(revision.actorId.toPrimitive()), + recordedAt: revision.recordedAt, + })), + })); + const uses: MailboxUseDTO[] = context.uses.map((item) => ({ + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: item.values.toPrimitive(), + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + values: revision.values.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: actorName(revision.actorId.toPrimitive()), + recordedAt: revision.recordedAt, + })), + })); + return { mailboxes, uses }; +} diff --git a/modules/companies/src/api/application/mailbox.repository.ts b/modules/companies/src/api/application/mailbox.repository.ts new file mode 100644 index 0000000..3ba331b --- /dev/null +++ b/modules/companies/src/api/application/mailbox.repository.ts @@ -0,0 +1,22 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { AccessDefinition } from "../domain/access/access-definition"; +import type { Mailbox } from "../domain/mailbox.aggregate"; +import type { MailboxUse } from "../domain/mailbox-use.aggregate"; +import type { MailboxError } from "../domain/mailbox-values"; +export type MailboxContext = Readonly<{ + mailboxes: readonly Mailbox[]; + uses: readonly MailboxUse[]; + people: readonly { id: UniqueID; active: boolean }[]; + items: readonly { id: UniqueID; values: AccessDefinition }[]; + actors: ReadonlyMap; + today: string; +}>; +export interface IMailboxRepository { + get(companyId: UniqueID): Promise>; + save( + companyId: UniqueID, + action: (context: MailboxContext) => Result + ): Promise>; +} diff --git a/modules/companies/src/api/application/mailbox.use-cases.ts b/modules/companies/src/api/application/mailbox.use-cases.ts new file mode 100644 index 0000000..b601737 --- /dev/null +++ b/modules/companies/src/api/application/mailbox.use-cases.ts @@ -0,0 +1,79 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { Mailbox } from "../domain/mailbox.aggregate"; +import { MailboxUse } from "../domain/mailbox-use.aggregate"; +import { MailboxError, type MailboxInput, type MailboxUseInput } from "../domain/mailbox-values"; + +import type { IMailboxRepository, MailboxContext } from "./mailbox.repository"; +import { mapMailboxInput, mapMailboxUseInput } from "./mailbox-input.mapper"; + +function saveMailbox( + company: UniqueID, + actor: UniqueID, + id: UniqueID | undefined, + input: MailboxInput, + context: MailboxContext, + now: string +) { + const previous = context.mailboxes.find((item) => item.id.equals(id)); + if (id && !previous) { + return Result.fail(new MailboxError("MAILBOX_NOT_FOUND")); + } + const values = mapMailboxInput(input, context, previous); + if (!values.isSuccess) { + return values; + } + return previous + ? previous.update(values.data, actor, now) + : Mailbox.create(company, values.data, actor, now); +} +function saveUse( + company: UniqueID, + actor: UniqueID, + id: UniqueID | undefined, + input: MailboxUseInput, + context: MailboxContext, + now: string +) { + const previous = context.uses.find((item) => item.id.equals(id)); + if (id && !previous) { + return Result.fail(new MailboxError("MAILBOX_USE_NOT_FOUND")); + } + const values = mapMailboxUseInput(input, context, previous); + if (!values.isSuccess) { + return values; + } + return previous + ? previous.update(values.data, actor, now) + : MailboxUse.create(company, values.data, actor, now); +} +export class MailboxUseCases { + constructor(private readonly repository: IMailboxRepository) {} + get(companyId: string) { + const company = UniqueID.create(companyId); + return company.isSuccess + ? this.repository.get(company.data) + : Promise.resolve(Result.fail(new MailboxError("INVALID_REQUEST"))); + } + save( + companyId: string, + id: string | undefined, + input: MailboxInput | MailboxUseInput, + 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 MailboxError("INVALID_REQUEST"))); + } + const target = identity?.isSuccess ? identity.data : undefined; + return this.repository.save(company.data, (context) => { + const now = new Date().toISOString(); + return "address" in input + ? saveMailbox(company.data, actor.data, target, input, context, now) + : saveUse(company.data, actor.data, target, input, context, now); + }); + } +} diff --git a/modules/companies/src/api/domain/mail-configuration-values.ts b/modules/companies/src/api/domain/mail-configuration-values.ts index e6c28b9..cfcc734 100644 --- a/modules/companies/src/api/domain/mail-configuration-values.ts +++ b/modules/companies/src/api/domain/mail-configuration-values.ts @@ -7,7 +7,7 @@ export const MailStateSchema = z.enum([ "removed", "never_configured", ]); -export const MailConfigurationInputSchema = z.strictObject({ +export const LegacyMailConfigurationSchema = z.strictObject({ personId: z.uuid(), accountId: z.uuid(), assetId: z.uuid(), @@ -16,7 +16,21 @@ export const MailConfigurationInputSchema = z.strictObject({ state: MailStateSchema, notes: z.string().trim().max(4000).nullable(), }); + +export const MailConfigurationInputSchema = z.strictObject({ + profileId: z.uuid(), + mailboxId: z.uuid(), + method: MailMethodSchema, + state: MailStateSchema, + notes: z.string().trim().max(4000).nullable(), +}); +export const MailSnapshotSchema = z.union([ + MailConfigurationInputSchema.extend({ address: z.email().max(254) }), + LegacyMailConfigurationSchema, +]); export type MailConfigurationInput = z.infer; +export type MailSnapshot = z.infer; + export class MailConfigurationError extends Error { constructor(public readonly code: string) { super(code); diff --git a/modules/companies/src/api/domain/mail-configuration.aggregate.ts b/modules/companies/src/api/domain/mail-configuration.aggregate.ts index 2bcd52b..6263feb 100644 --- a/modules/companies/src/api/domain/mail-configuration.aggregate.ts +++ b/modules/companies/src/api/domain/mail-configuration.aggregate.ts @@ -24,7 +24,8 @@ export class MailConfiguration extends AggregateRoot { if ( !( z.iso.datetime().safeParse(recordedAt).success && - ["pending", "configured"].includes(values.state) + ["pending", "configured"].includes(values.state) && + !values.legacy ) ) { return Result.fail(new MailConfigurationError("INVALID_MAIL_TRANSITION")); @@ -57,7 +58,7 @@ export class MailConfiguration extends AggregateRoot { if (this.values.equals(values)) { return Result.ok(this); } - if (!this.values.sameTarget(values)) { + if (!(this.values.sameTarget(values) || (this.values.legacy && !values.legacy))) { return Result.fail(new MailConfigurationError("MAIL_TARGET_IMMUTABLE")); } if ( diff --git a/modules/companies/src/api/domain/mail-configuration.test.ts b/modules/companies/src/api/domain/mail-configuration.test.ts index 51ac626..f04581c 100644 --- a/modules/companies/src/api/domain/mail-configuration.test.ts +++ b/modules/companies/src/api/domain/mail-configuration.test.ts @@ -9,130 +9,184 @@ 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 { MailConfigurationRevision } from "./mail-configuration-revision.entity"; +import type { MailConfigurationInput, MailSnapshot } from "./mail-configuration-values"; +import { Mailbox } from "./mailbox.aggregate"; +import { MailboxUse } from "./mailbox-use.aggregate"; import { MailConfigurationValues } from "./value-objects/mail-configuration-values"; +import { MailboxUseValues } from "./value-objects/mailbox-use-values"; +import { MailboxValues } from "./value-objects/mailbox-values"; +import { WorkProfileValues } from "./value-objects/work-profile-values"; +import { WorkProfile } from "./work-profile.aggregate"; 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 = { +const now = "2026-10-04T10:00:00.000Z"; +function profile(device = asset) { + const values = WorkProfileValues.create({ + name: "Ana portátil", + personId: person.toPrimitive(), + assetId: device.toPrimitive(), + accountId: null, + active: true, + notes: null, + }); + assert.ok(values.isSuccess); + const result = WorkProfile.create(company, values.data, actor, now); + assert.ok(result.isSuccess); + return result.data; +} +function mailbox(address = "Ana@EXAMPLE.test") { + const values = MailboxValues.create({ + address, + kind: "external", + context: "Cliente", + accountId: null, + active: true, + notes: null, + }); + assert.ok(values.isSuccess); + const result = Mailbox.create(company, values.data, actor, now); + assert.ok(result.isSuccess); + return result.data; +} +const box = mailbox(); +const workProfile = profile(); +const useValues = MailboxUseValues.create({ + mailboxId: box.id.toPrimitive(), personId: person.toPrimitive(), - accountId: account.toPrimitive(), - assetId: asset.toPrimitive(), - address: "Ana@EXAMPLE.test", - method: "imap" as const, - state: "configured" as const, + startDate: "2026-10-01", + endDate: null, +}); +assert.ok(useValues.isSuccess); +const use = MailboxUse.create(company, useValues.data, actor, now); +assert.ok(use.isSuccess); +const context: MailContext = { + configurations: [], + people: [{ id: person, active: true }], + assets: [{ id: asset, available: true }], + profiles: [workProfile], + mailboxes: [box], + uses: [use.data], + actors: new Map(), + today: "2026-10-04", +}; +const input: MailConfigurationInput = { + profileId: workProfile.id.toPrimitive(), + mailboxId: box.id.toPrimitive(), + method: "imap", + state: "configured", notes: null, }; -function values(data: MailConfigurationInput = input) { +function values(data: MailSnapshot = { ...input, address: "Ana@EXAMPLE.test" }) { 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"); + const result = MailConfiguration.create(company, values(), actor, now); 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", () => { +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); + const same = item.update(values(), actor, now); + assert.ok(same.isSuccess); + assert.equal(same.data, item); assert.equal( - item.update(values({ ...input, state: "removed" }), actor, "2026-10-04T11:00:00.000Z") + item.update(values({ ...input, address: "ana@example.test", state: "removed" }), actor, now) .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"); + const pending = item.update( + values({ ...input, address: "ana@example.test", state: "removal_pending" }), + actor, + now + ); 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"); + const removed = pending.data.update( + values({ ...input, address: "ana@example.test", state: "removed" }), + actor, + now + ); 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); + assert.equal(removed.data.update(values(), actor, now).isSuccess, false); }); -test("correo: autorización vigente y ámbito; varias cuentas y dispositivos, duplicados abiertos", () => { +test("Correo: buzón independiente, vigencia, perfiles, varios buzones y duplicados abiertos", () => { const item = create(); assert.equal(mailAuthorized(item.values, context), true); - assert.equal(mapMailInput(input, context).isSuccess, true); + assert.ok(mapMailInput(input, context).isSuccess); + assert.ok( + mapMailInput( + { + ...input, + profileId: input.profileId.toUpperCase(), + mailboxId: input.mailboxId.toUpperCase(), + }, + context + ).isSuccess + ); assert.equal(mapMailInput(input, { ...context, configurations: [item] }).isSuccess, false); - assert.equal(mapMailInput(input, { ...context, items: [] }).isSuccess, false); + for (const missing of [ + { profiles: [] }, + { mailboxes: [] }, + { uses: [] }, + { people: [{ id: person, active: false }] }, + { assets: [] }, + ]) { + assert.equal(mapMailInput(input, { ...context, ...missing }).isSuccess, false); + } + assert.equal(mailAuthorized(item.values, { ...context, today: "2026-09-30" }), false); + assert.ok( + mapMailInput({ ...input, state: "removal_pending" }, { ...context, uses: [] }, item).isSuccess + ); + assert.ok(mapMailInput({ ...input, notes: "Revisar" }, { ...context, uses: [] }, item).isSuccess); assert.equal( - mapMailInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess, + mapMailInput({ ...input, method: "pop" }, { ...context, uses: [] }, item).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( + const device = UniqueID.generateNewID(); + const secondProfile = profile(device); + const otherBox = mailbox("pedidos@example.test"); + assert.ok( mapMailInput( - { ...input, assetId: anotherAsset.toPrimitive(), method: "pop" }, + { ...input, profileId: secondProfile.id.toPrimitive() }, { ...context, configurations: [item], - assets: [...context.assets, { id: anotherAsset, available: true }], + profiles: [workProfile, secondProfile], + assets: [...context.assets, { id: device, available: true }], } - ).isSuccess, - true + ).isSuccess + ); + const anotherUse = MailboxUseValues.create({ + ...useValues.data.toPrimitive(), + mailboxId: otherBox.id.toPrimitive(), + }); + assert.ok(anotherUse.isSuccess); + const another = MailboxUse.create(company, anotherUse.data, actor, now); + assert.ok(another.isSuccess); + assert.ok( + mapMailInput( + { ...input, mailboxId: otherBox.id.toPrimitive() }, + { + ...context, + configurations: [item], + mailboxes: [box, otherBox], + uses: [use.data, another.data], + } + ).isSuccess ); }); -test("correo: mapper conserva identidad e historial y rechaza corrupción", () => { +test("Correo: persistencia conserva identidad e historial y rechaza corrupción", () => { const mapper = new MailConfigurationPersistenceMapper(); const original = create(); const persisted = mapper.toPersistence(original); @@ -141,7 +195,48 @@ test("correo: mapper conserva identidad e historial y rechaza corrupción", () = 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) + mapper.toDomain({ ...persisted.root, profileId: actor.toPrimitive() }, persisted.revisions) ); assert.throws(() => mapper.toDomain(persisted.root, [])); }); +test("Correo: vinculación explícita del legado conserva UUID e historial, sin inferir propiedad", () => { + const legacyValues = values({ + personId: person.toPrimitive(), + assetId: asset.toPrimitive(), + accountId: actor.toPrimitive(), + address: "ana@example.test", + method: "imap", + state: "configured", + notes: null, + }); + assert.equal(MailConfiguration.create(company, legacyValues, actor, now).isSuccess, false); + const legacy = MailConfiguration.rehydrate( + { + companyId: company, + values: legacyValues, + history: [ + MailConfigurationRevision.create({ values: legacyValues, actorId: actor, recordedAt: now }), + ], + }, + UniqueID.generateNewID() + ); + assert.ok(mapMailInput(input, context, legacy).isSuccess); + const wrong = mailbox("wrong@example.test"); + assert.equal( + mapMailInput( + { ...input, mailboxId: wrong.id.toPrimitive() }, + { ...context, mailboxes: [wrong] }, + legacy + ).isSuccess, + false + ); + const updated = legacy.update(values(), actor, now); + assert.ok(updated.isSuccess); + assert.ok(updated.data.id.equals(legacy.id)); + const mapper = new MailConfigurationPersistenceMapper(); + const stored = mapper.toPersistence(updated.data); + const restored = mapper.toDomain(stored.root, stored.revisions); + assert.equal(restored.history.length, 2); + assert.ok(restored.history[0]?.values.legacy); + assert.equal(restored.values.legacy, undefined); +}); diff --git a/modules/companies/src/api/domain/mailbox-revision.entity.ts b/modules/companies/src/api/domain/mailbox-revision.entity.ts new file mode 100644 index 0000000..f209ce5 --- /dev/null +++ b/modules/companies/src/api/domain/mailbox-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { MailboxValues } from "./value-objects/mailbox-values"; +export type MailboxRevisionProps = Readonly<{ + values: MailboxValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class MailboxRevision extends DomainEntity { + static create(props: MailboxRevisionProps) { + return new MailboxRevision(Object.freeze({ ...props })); + } + static rehydrate(props: MailboxRevisionProps, id: UniqueID) { + return new MailboxRevision(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/mailbox-use-revision.entity.ts b/modules/companies/src/api/domain/mailbox-use-revision.entity.ts new file mode 100644 index 0000000..77652f7 --- /dev/null +++ b/modules/companies/src/api/domain/mailbox-use-revision.entity.ts @@ -0,0 +1,25 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; + +import type { MailboxUseValues } from "./value-objects/mailbox-use-values"; +export type MailboxUseRevisionProps = Readonly<{ + values: MailboxUseValues; + actorId: UniqueID; + recordedAt: string; +}>; +export class MailboxUseRevision extends DomainEntity { + static create(props: MailboxUseRevisionProps) { + return new MailboxUseRevision(Object.freeze({ ...props })); + } + static rehydrate(props: MailboxUseRevisionProps, id: UniqueID) { + return new MailboxUseRevision(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/mailbox-use.aggregate.ts b/modules/companies/src/api/domain/mailbox-use.aggregate.ts new file mode 100644 index 0000000..c5b361b --- /dev/null +++ b/modules/companies/src/api/domain/mailbox-use.aggregate.ts @@ -0,0 +1,78 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { MailboxUseRevision } from "./mailbox-use-revision.entity"; +import { MailboxError } from "./mailbox-values"; +import type { MailboxUseValues } from "./value-objects/mailbox-use-values"; + +type MailboxUseProps = Readonly<{ + companyId: UniqueID; + values: MailboxUseValues; + history: readonly MailboxUseRevision[]; +}>; +export class MailboxUse extends AggregateRoot { + private constructor(props: MailboxUseProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: MailboxUseValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + return Result.ok( + new MailboxUse({ + companyId, + values, + history: [MailboxUseRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: MailboxUseProps, id: UniqueID) { + return new MailboxUse(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: MailboxUseValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (!this.values.sameTarget(values)) { + return Result.fail(new MailboxError("MAILBOX_USE_TARGET_IMMUTABLE")); + } + if (this.values.endDate.getOrUndefined()) { + return Result.fail(new MailboxError("MAILBOX_USE_CLOSED")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + return Result.ok( + new MailboxUse( + { + ...this.props, + values, + history: [...this.history, MailboxUseRevision.create({ values, actorId, recordedAt })], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/mailbox-values.ts b/modules/companies/src/api/domain/mailbox-values.ts new file mode 100644 index 0000000..4631856 --- /dev/null +++ b/modules/companies/src/api/domain/mailbox-values.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v4"; +export const MailboxInputSchema = z.strictObject({ + address: z.email().max(254), + kind: z.enum(["corporate", "external"]), + context: z.string().trim().max(255).nullable(), + accountId: z.uuid().nullable(), + active: z.boolean(), + notes: z.string().trim().max(4000).nullable(), +}); +export type MailboxInput = z.infer; +export const MailboxUseInputSchema = z.strictObject({ + mailboxId: z.uuid(), + personId: z.uuid(), + startDate: z.iso.date(), + endDate: z.iso.date().nullable(), +}); +export type MailboxUseInput = z.infer; +export class MailboxError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/mailbox.aggregate.ts b/modules/companies/src/api/domain/mailbox.aggregate.ts new file mode 100644 index 0000000..37b6e54 --- /dev/null +++ b/modules/companies/src/api/domain/mailbox.aggregate.ts @@ -0,0 +1,75 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import { MailboxRevision } from "./mailbox-revision.entity"; +import { MailboxError } from "./mailbox-values"; +import type { MailboxValues } from "./value-objects/mailbox-values"; + +type MailboxProps = Readonly<{ + companyId: UniqueID; + values: MailboxValues; + history: readonly MailboxRevision[]; +}>; +export class Mailbox extends AggregateRoot { + private constructor(props: MailboxProps, id?: UniqueID) { + super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); + } + static create( + companyId: UniqueID, + values: MailboxValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (!z.iso.datetime().safeParse(recordedAt).success) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + return Result.ok( + new Mailbox({ + companyId, + values, + history: [MailboxRevision.create({ values, actorId, recordedAt })], + }) + ); + } + static rehydrate(props: MailboxProps, id: UniqueID) { + return new Mailbox(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update( + values: MailboxValues, + actorId: UniqueID, + recordedAt: string + ): Result { + if (this.values.equals(values)) { + return Result.ok(this); + } + if (this.values.kind !== values.kind) { + return Result.fail(new MailboxError("MAILBOX_KIND_IMMUTABLE")); + } + if ( + !z.iso.datetime().safeParse(recordedAt).success || + recordedAt < (this.history.at(-1)?.recordedAt ?? "") + ) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + return Result.ok( + new Mailbox( + { + ...this.props, + values, + history: [...this.history, MailboxRevision.create({ values, actorId, recordedAt })], + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/mailbox.test.ts b/modules/companies/src/api/domain/mailbox.test.ts new file mode 100644 index 0000000..3efb518 --- /dev/null +++ b/modules/companies/src/api/domain/mailbox.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import type { MailboxContext } from "../application/mailbox.repository"; +import { mapMailboxInput, mapMailboxUseInput } from "../application/mailbox-input.mapper"; +import { MailboxPersistenceMapper } from "../infrastructure/persistence/mailbox-persistence.mapper"; + +import { Mailbox } from "./mailbox.aggregate"; +import { MailboxUse } from "./mailbox-use.aggregate"; +import { MailboxUseValues } from "./value-objects/mailbox-use-values"; +import { MailboxValues } from "./value-objects/mailbox-values"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const person = UniqueID.generateNewID(); +const now = "2026-10-05T10:00:00.000Z"; +const fields = { + address: "Ana@Example.test", + kind: "corporate" as const, + context: null, + accountId: null, + active: true, + notes: null, +}; +function values(data = fields) { + const r = MailboxValues.create(data); + assert.ok(r.isSuccess); + return r.data; +} +function create() { + const r = Mailbox.create(company, values(), actor, now); + assert.ok(r.isSuccess); + return r.data; +} +const box = create(); +const ctx: MailboxContext = { + mailboxes: [box], + uses: [], + people: [{ id: person, active: true }], + items: [], + actors: new Map(), + today: "2026-10-05", +}; +const period = { + mailboxId: box.id.toPrimitive(), + personId: person.toPrimitive(), + startDate: "2026-10-01", + endDate: null, +}; +function useValues( + data: { mailboxId: string; personId: string; startDate: string; endDate: string | null } = period +) { + const r = MailboxUseValues.create(data); + assert.ok(r.isSuccess); + return r.data; +} +test("Buzón: identidad, dirección única, cuenta opcional, ámbito y cambios auditados", () => { + assert.equal(box.id.toPrimitive()[14], "7"); + assert.equal(box.values.address.toPrimitive(), "ana@example.test"); + const noOp = box.update(values(), actor, now); + assert.ok(noOp.isSuccess); + assert.equal(noOp.data, box); + const changed = box.update(values({ ...fields, active: false }), actor, now); + assert.ok(changed.isSuccess); + assert.equal(changed.data.history.length, 2); + assert.equal(changed.data.history[0]?.values.active, true); + assert.equal(mapMailboxInput(fields, ctx).isSuccess, false); + assert.ok(mapMailboxInput(fields, ctx, box).isSuccess); + assert.equal( + mapMailboxInput({ ...fields, accountId: actor.toPrimitive() }, ctx).isSuccess, + false + ); + const external = MailboxValues.create({ ...fields, kind: "external" }); + assert.ok(external.isSuccess); + assert.equal(box.update(external.data, actor, now).isSuccess, false); +}); +test("Uso de buzón: fechas inclusiva/exclusiva, mismo día, solapes, varias personas e historial", () => { + const value = useValues(); + assert.equal(value.current("2026-10-01"), true); + assert.equal(value.current("2026-09-30"), false); + const closed = useValues({ ...period, endDate: "2026-10-05" }); + assert.equal(closed.current("2026-10-05"), false); + assert.equal(closed.overlaps(useValues({ ...period, startDate: "2026-10-05" })), false); + assert.equal(closed.overlaps(useValues({ ...period, startDate: "2026-10-04" })), true); + assert.equal(value.overlaps(useValues({ ...period, endDate: period.startDate })), false); + assert.equal(MailboxUseValues.create({ ...period, endDate: "2026-09-30" }).isSuccess, false); + const result = MailboxUse.create(company, value, actor, now); + assert.ok(result.isSuccess); + assert.equal(mapMailboxUseInput(period, { ...ctx, uses: [result.data] }).isSuccess, false); + assert.ok( + mapMailboxUseInput( + { ...period, personId: actor.toPrimitive() }, + { ...ctx, people: [{ id: actor, active: true }], uses: [result.data] } + ).isSuccess + ); + const update = result.data.update(closed, actor, now); + assert.ok(update.isSuccess); + assert.ok(update.data.id.equals(result.data.id)); + assert.equal(update.data.history.length, 2); + assert.equal(update.data.update(value, actor, now).isSuccess, false); + assert.ok( + mapMailboxUseInput( + { ...period, endDate: "2026-10-05" }, + { ...ctx, people: [{ id: person, active: false }] }, + result.data + ).isSuccess + ); +}); +test("Buzones: mapper conserva identidad y revisiones, rechaza corrupción", () => { + const mapper = new MailboxPersistenceMapper(); + const stored = mapper.toPersistence(box); + const root = { + ...stored, + accountId: null, + addressKey: `${company.toPrimitive()}:ana@example.test`, + }; + const restored = mapper.toDomain(root, stored.revisions); + assert.ok(restored.id.equals(box.id)); + assert.throws(() => mapper.toDomain({ ...root, addressKey: "invalid" }, stored.revisions)); + const use = MailboxUse.create(company, useValues(), actor, now); + assert.ok(use.isSuccess); + const rows = mapper.toPersistence(use.data); + const useRoot = { ...rows, mailboxId: box.id.toPrimitive(), personId: person.toPrimitive() }; + assert.ok(mapper.toUseDomain(useRoot, rows.revisions).id.equals(use.data.id)); + assert.throws(() => + mapper.toUseDomain({ ...useRoot, personId: actor.toPrimitive() }, rows.revisions) + ); +}); 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 index 33dbc62..ccc7418 100644 --- a/modules/companies/src/api/domain/value-objects/mail-configuration-values.ts +++ b/modules/companies/src/api/domain/value-objects/mail-configuration-values.ts @@ -3,56 +3,67 @@ import { Maybe, Result } from "@repo/rdx-utils"; import { MailConfigurationError, - type MailConfigurationInput, - MailConfigurationInputSchema, + type MailSnapshot, + MailSnapshotSchema, } from "../mail-configuration-values"; + export class MailConfigurationValues { private constructor( - public readonly personId: UniqueID, - public readonly accountId: UniqueID, - public readonly assetId: UniqueID, + public readonly data: Readonly, public readonly address: EmailAddress, - public readonly method: MailConfigurationInput["method"], - public readonly state: MailConfigurationInput["state"], - public readonly notes: Maybe + public readonly profileId: Maybe, + public readonly mailboxId: Maybe ) { Object.freeze(this); } - static create( - input: MailConfigurationInput - ): Result { - const parsed = MailConfigurationInputSchema.safeParse(input); + static create(input: MailSnapshot): Result { + const parsed = MailSnapshotSchema.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)) { + const address = EmailAddress.create(parsed.data.address); + const profile = "profileId" in parsed.data ? UniqueID.create(parsed.data.profileId) : undefined; + const mailbox = "mailboxId" in parsed.data ? UniqueID.create(parsed.data.mailboxId) : undefined; + if (!address.isSuccess || (profile && !profile.isSuccess) || (mailbox && !mailbox.isSuccess)) { return Result.fail(new MailConfigurationError("INVALID_REQUEST")); } return Result.ok( new MailConfigurationValues( - person.data, - account.data, - asset.data, + Object.freeze({ + ...parsed.data, + address: address.data.toPrimitive(), + notes: parsed.data.notes?.trim() || null, + }), address.data, - data.method, - data.state, - data.notes?.trim() ? Maybe.some(data.notes.trim()) : Maybe.none() + profile?.isSuccess ? Maybe.some(profile.data) : Maybe.none(), + mailbox?.isSuccess ? Maybe.some(mailbox.data) : Maybe.none() ) ); } + get legacy() { + return "personId" in this.data ? this.data : undefined; + } + get method() { + return this.data.method; + } + get state() { + return this.data.state; + } get closed() { return this.state === "removed" || this.state === "never_configured"; } sameTarget(other: MailConfigurationValues) { + if (this.legacy && other.legacy) { + return ( + this.legacy.personId === other.legacy.personId && + this.legacy.accountId === other.legacy.accountId && + this.legacy.assetId === other.legacy.assetId + ); + } return ( - this.personId.equals(other.personId) && - this.accountId.equals(other.accountId) && - this.assetId.equals(other.assetId) + !(this.legacy || other.legacy) && + this.profileId.getOrUndefined()?.equals(other.profileId.getOrUndefined()) === true && + this.mailboxId.getOrUndefined()?.equals(other.mailboxId.getOrUndefined()) === true ); } equals(other: MailConfigurationValues) { @@ -61,18 +72,25 @@ export class MailConfigurationValues { this.address.equals(other.address) && this.method === other.method && this.state === other.state && - this.notes.getOrUndefined() === other.notes.getOrUndefined() + this.data.notes === other.data.notes ); } - toPrimitive(): MailConfigurationInput { + toPrimitive(): MailSnapshot { + if (this.legacy) { + return { ...this.legacy }; + } + const profileId = this.profileId.getOrUndefined(); + const mailboxId = this.mailboxId.getOrUndefined(); + if (!(profileId && mailboxId)) { + throw new Error("Referencias de correo inconsistentes."); + } return { - personId: this.personId.toPrimitive(), - accountId: this.accountId.toPrimitive(), - assetId: this.assetId.toPrimitive(), + profileId: profileId.toPrimitive(), + mailboxId: mailboxId.toPrimitive(), address: this.address.toPrimitive(), method: this.method, state: this.state, - notes: this.notes.getOrUndefined() ?? null, + notes: this.data.notes, }; } } diff --git a/modules/companies/src/api/domain/value-objects/mailbox-use-values.ts b/modules/companies/src/api/domain/value-objects/mailbox-use-values.ts new file mode 100644 index 0000000..54059e9 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/mailbox-use-values.ts @@ -0,0 +1,72 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { MailboxError, type MailboxUseInput, MailboxUseInputSchema } from "../mailbox-values"; + +import { CalendarDate } from "./calendar-date"; + +export class MailboxUseValues { + private constructor( + public readonly mailboxId: UniqueID, + public readonly personId: UniqueID, + public readonly startDate: CalendarDate, + public readonly endDate: Maybe + ) { + Object.freeze(this); + } + static create(input: MailboxUseInput): Result { + const parsed = MailboxUseInputSchema.safeParse(input); + if (!parsed.success || (input.endDate !== null && input.endDate < input.startDate)) { + return Result.fail(new MailboxError("INVALID_PERIOD")); + } + const mailbox = UniqueID.create(input.mailboxId); + const person = UniqueID.create(input.personId); + const start = CalendarDate.create(input.startDate); + const end = input.endDate ? CalendarDate.create(input.endDate) : undefined; + if (!(mailbox.isSuccess && person.isSuccess && start.isSuccess) || (end && !end.isSuccess)) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + return Result.ok( + new MailboxUseValues( + mailbox.data, + person.data, + start.data, + end?.isSuccess ? Maybe.some(end.data) : Maybe.none() + ) + ); + } + toPrimitive(): MailboxUseInput { + return { + mailboxId: this.mailboxId.toPrimitive(), + personId: this.personId.toPrimitive(), + startDate: this.startDate.toPrimitive(), + endDate: this.endDate.getOrUndefined()?.toPrimitive() ?? null, + }; + } + sameTarget(other: MailboxUseValues) { + return ( + this.mailboxId.equals(other.mailboxId) && + this.personId.equals(other.personId) && + this.startDate.equals(other.startDate) + ); + } + equals(other: MailboxUseValues) { + return this.sameTarget(other) && this.toPrimitive().endDate === other.toPrimitive().endDate; + } + current(today: string) { + const data = this.toPrimitive(); + return data.startDate <= today && (data.endDate === null || today < data.endDate); + } + overlaps(other: MailboxUseValues) { + const a = this.toPrimitive(); + const b = other.toPrimitive(); + return ( + a.startDate !== a.endDate && + b.startDate !== b.endDate && + this.mailboxId.equals(other.mailboxId) && + this.personId.equals(other.personId) && + (b.endDate === null || a.startDate < b.endDate) && + (a.endDate === null || b.startDate < a.endDate) + ); + } +} diff --git a/modules/companies/src/api/domain/value-objects/mailbox-values.ts b/modules/companies/src/api/domain/value-objects/mailbox-values.ts new file mode 100644 index 0000000..a772094 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/mailbox-values.ts @@ -0,0 +1,60 @@ +import { EmailAddress, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { MailboxError, type MailboxInput, MailboxInputSchema } from "../mailbox-values"; + +export class MailboxValues { + private constructor( + public readonly address: EmailAddress, + public readonly kind: MailboxInput["kind"], + public readonly context: Maybe, + public readonly accountId: Maybe, + public readonly active: boolean, + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create(input: MailboxInput): Result { + const parsed = MailboxInputSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + const address = EmailAddress.create(parsed.data.address); + const account = parsed.data.accountId ? UniqueID.create(parsed.data.accountId) : undefined; + if (!address.isSuccess || (account && !account.isSuccess)) { + return Result.fail(new MailboxError("INVALID_REQUEST")); + } + return Result.ok( + new MailboxValues( + address.data, + parsed.data.kind, + parsed.data.context ? Maybe.some(parsed.data.context) : Maybe.none(), + account?.isSuccess ? Maybe.some(account.data) : Maybe.none(), + parsed.data.active, + parsed.data.notes ? Maybe.some(parsed.data.notes) : Maybe.none() + ) + ); + } + toPrimitive(): MailboxInput { + return { + address: this.address.toPrimitive(), + kind: this.kind, + context: this.context.getOrUndefined() ?? null, + accountId: this.accountId.getOrUndefined()?.toPrimitive() ?? null, + active: this.active, + notes: this.notes.getOrUndefined() ?? null, + }; + } + equals(other: MailboxValues) { + const a = this.toPrimitive(); + const b = other.toPrimitive(); + return ( + a.address === b.address && + a.kind === b.kind && + a.context === b.context && + a.accountId === b.accountId && + a.active === b.active && + a.notes === b.notes + ); + } +} diff --git a/modules/companies/src/api/infrastructure/http/mailbox.routes.ts b/modules/companies/src/api/infrastructure/http/mailbox.routes.ts new file mode 100644 index 0000000..01dd91c --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/mailbox.routes.ts @@ -0,0 +1,82 @@ +import { type Application, type RequestHandler, type Response, Router } from "express"; +import { z } from "zod/v4"; + +import { MailItemParamsSchema, MailParamsSchema } from "../../../common/mail-configurations"; +import { MailboxRequestSchema, MailboxUseRequestSchema } from "../../../common/mailboxes"; +import type { MailboxUseCases } from "../../application/mailbox.use-cases"; +import { buildMailboxResponse } from "../../application/mailbox-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 registerMailboxRoutes( + app: Application, + useCases: MailboxUseCases, + 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(buildMailboxResponse(result.data)); + }) + .catch(next); + }); + for (const route of [ + { path: "/:companyId", params: MailParamsSchema, body: MailboxRequestSchema }, + { path: "/:companyId/:id/update", params: MailItemParamsSchema, body: MailboxRequestSchema }, + { path: "/:companyId/uses", params: MailParamsSchema, body: MailboxUseRequestSchema }, + { + path: "/:companyId/uses/:id/update", + params: MailItemParamsSchema, + body: MailboxUseRequestSchema, + }, + ]) { + router.post(route.path, (request, response, next) => { + const params = route.params.safeParse(request.params); + const body = route.body.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({ + ...buildMailboxResponse(result.data.context), + savedId: result.data.item.id.toPrimitive(), + }); + }) + .catch(next); + }); + } + app.use("/api/mailboxes", 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 index 646649e..328472b 100644 --- a/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/mail-configuration-migration.ts @@ -15,9 +15,11 @@ export async function migrateMailConfigurations(database: Sequelize) { 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"), + personId: { ...reference("company_contacts"), allowNull: true }, + accountId: { ...reference("access_items"), allowNull: true }, + assetId: { ...reference("assets"), allowNull: true }, + profileId: { ...reference("work_profiles"), allowNull: true }, + mailboxId: { ...reference("mailboxes"), allowNull: true }, values: { type: DataTypes.JSON, allowNull: false }, currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true }, }); @@ -32,6 +34,27 @@ export async function migrateMailConfigurations(database: Sequelize) { values: { type: DataTypes.JSON, allowNull: false }, }); } + + const columns = await query.describeTable("mail_configurations"); + for (const [column, table] of [ + ["personId", "company_contacts"], + ["accountId", "access_items"], + ["assetId", "assets"], + ["profileId", "work_profiles"], + ["mailboxId", "mailboxes"], + ] as const) { + if (!columns[column]) { + await query.addColumn("mail_configurations", column, { + ...reference(table), + allowNull: true, + }); + } else if (!columns[column].allowNull) { + await query.changeColumn("mail_configurations", column, { + type: DataTypes.UUID, + allowNull: true, + }); + } + } 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 } 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 index 2e3f4a2..f7cf4ee 100644 --- a/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/mail-configuration-persistence.mapper.ts @@ -3,7 +3,7 @@ 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 { MailSnapshotSchema } from "../../domain/mail-configuration-values"; import { MailConfigurationValues } from "../../domain/value-objects/mail-configuration-values"; export function mailIdentity(value: string) { const id = UniqueID.create(value); @@ -18,9 +18,11 @@ export function persistedJson(value: unknown): unknown { export type MailRow = { id: string; companyId: string; - personId: string; - accountId: string; - assetId: string; + personId: string | null; + accountId: string | null; + assetId: string | null; + profileId: string | null; + mailboxId: string | null; values: unknown; currentKey: string | null; }; @@ -33,14 +35,28 @@ export type MailRevisionRow = { values: unknown; }; function valuesOf(raw: unknown) { - const values = MailConfigurationValues.create( - MailConfigurationInputSchema.parse(persistedJson(raw)) - ); + const values = MailConfigurationValues.create(MailSnapshotSchema.parse(persistedJson(raw))); if (!values.isSuccess) { throw new Error("Valores de correo persistidos inconsistentes."); } return values.data; } +function mailReferences(values: MailConfigurationValues) { + const legacy = values.legacy; + const profileId = values.profileId.getOrUndefined()?.toPrimitive() ?? null; + const mailboxId = values.mailboxId.getOrUndefined()?.toPrimitive() ?? null; + const target = legacy + ? [legacy.personId, legacy.accountId, legacy.assetId] + : [profileId, mailboxId]; + return { + profileId, + mailboxId, + personId: legacy?.personId ?? null, + accountId: legacy?.accountId ?? null, + assetId: legacy?.assetId ?? null, + currentKey: values.closed ? null : target.join(":"), + }; +} export class MailConfigurationPersistenceMapper { toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) { const values = valuesOf(row.values); @@ -50,8 +66,7 @@ export class MailConfigurationPersistenceMapper { if ( revision.position !== position || !z.iso.datetime().safeParse(revision.recordedAt).success || - revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") || - !values.sameTarget(revisionValues) + revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") ) { throw new Error("Historial de correo persistido inconsistente."); } @@ -64,13 +79,27 @@ export class MailConfigurationPersistenceMapper { mailIdentity(revision.id) ); }); + + let previous: MailConfigurationValues | undefined; + for (const revision of history) { + if ( + previous && + !previous.sameTarget(revision.values) && + !(previous.legacy && !revision.values.legacy) + ) { + throw new Error("Destino de correo histórico inconsistente."); + } + previous = revision.values; + } + const references = mailReferences(values); 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(":")) + row.profileId !== references.profileId || + row.mailboxId !== references.mailboxId || + row.personId !== references.personId || + row.accountId !== references.accountId || + row.assetId !== references.assetId || + row.currentKey !== references.currentKey ) { throw new Error("Referencias de correo persistidas inconsistentes."); } @@ -85,13 +114,8 @@ export class MailConfigurationPersistenceMapper { root: { id: item.id.toPrimitive(), companyId: item.companyId.toPrimitive(), - personId: values.personId, - accountId: values.accountId, - assetId: values.assetId, + ...mailReferences(item.values), 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(), diff --git a/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts b/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts new file mode 100644 index 0000000..e0db75a --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/mailbox-migration.ts @@ -0,0 +1,68 @@ +import { DataTypes, QueryTypes, type Sequelize } from "sequelize"; +export async function migrateMailboxes(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 } + ); + const fk = (table: string, nullable = false) => ({ + type: DataTypes.UUID, + allowNull: nullable, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", + }); + const root = { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: fk("companies"), + values: { type: DataTypes.JSON, allowNull: false }, + }; + for (const [table, columns] of [ + [ + "mailboxes", + { + ...root, + accountId: fk("access_items", true), + addressKey: { type: DataTypes.STRING(300), allowNull: false, unique: true }, + }, + ], + ["mailbox_uses", { ...root, mailboxId: fk("mailboxes"), personId: fk("company_contacts") }], + [ + "mailbox_revisions", + { + id: root.id, + itemId: fk("mailboxes"), + position: { type: DataTypes.INTEGER, allowNull: false }, + values: root.values, + actorId: fk("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + }, + ], + [ + "mailbox_use_revisions", + { + id: root.id, + itemId: fk("mailbox_uses"), + position: { type: DataTypes.INTEGER, allowNull: false }, + values: root.values, + actorId: fk("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + }, + ], + ] as const) { + if (!tables.some((row) => row.name === table)) { + await query.createTable(table, columns); + } + } + for (const table of ["mailbox_revisions", "mailbox_use_revisions"]) { + 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 === `${table}_position`)) { + await query.addIndex(table, ["itemId", "position"], { + unique: true, + name: `${table}_position`, + }); + } + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts new file mode 100644 index 0000000..b853ee1 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/mailbox-persistence.mapper.ts @@ -0,0 +1,142 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { z } from "zod/v4"; + +import { Mailbox } from "../../domain/mailbox.aggregate"; +import { MailboxRevision } from "../../domain/mailbox-revision.entity"; +import { MailboxUse } from "../../domain/mailbox-use.aggregate"; +import { MailboxUseRevision } from "../../domain/mailbox-use-revision.entity"; +import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-values"; +import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values"; +import { MailboxValues } from "../../domain/value-objects/mailbox-values"; +export function mailboxIdentity(raw: string) { + const value = UniqueID.create(raw); + if (!value.isSuccess) { + throw new Error("Identidad de buzón persistida inválida."); + } + return value.data; +} +function json(raw: unknown): unknown { + return typeof raw === "string" ? JSON.parse(raw) : raw; +} +export type MailboxRow = { + id: string; + companyId: string; + values: unknown; + accountId: string | null; + addressKey: string; +}; +export type MailboxUseRow = { + id: string; + companyId: string; + values: unknown; + mailboxId: string; + personId: string; +}; +export type MailboxRevisionRow = { + id: string; + itemId: string; + position: number; + values: unknown; + actorId: string; + recordedAt: string; +}; +function ordered(rows: readonly MailboxRevisionRow[]) { + const ordered = [...rows].sort((a, b) => a.position - b.position); + for (const [position, row] of ordered.entries()) { + if ( + row.position !== position || + !z.iso.datetime().safeParse(row.recordedAt).success || + row.recordedAt < (ordered[position - 1]?.recordedAt ?? "") + ) { + throw new Error("Historial de buzón inconsistente."); + } + } + return ordered; +} +function mailboxValues(raw: unknown) { + const result = MailboxValues.create(MailboxInputSchema.parse(json(raw))); + if (!result.isSuccess) { + throw new Error("Buzón persistido inválido."); + } + return result.data; +} +function mailboxUseValues(raw: unknown) { + const result = MailboxUseValues.create(MailboxUseInputSchema.parse(json(raw))); + if (!result.isSuccess) { + throw new Error("Uso de buzón persistido inválido."); + } + return result.data; +} +export class MailboxPersistenceMapper { + toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) { + const values = mailboxValues(row.values); + const history = ordered(revisions).map((revision) => + MailboxRevision.rehydrate( + { + values: mailboxValues(revision.values), + actorId: mailboxIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + mailboxIdentity(revision.id) + ) + ); + if ( + !history.at(-1)?.values.equals(values) || + history.some((revision) => revision.values.kind !== values.kind) || + row.accountId !== values.toPrimitive().accountId || + row.addressKey !== `${row.companyId}:${values.address.toPrimitive()}` + ) { + throw new Error("Referencias de buzón inconsistentes."); + } + return Mailbox.rehydrate( + { companyId: mailboxIdentity(row.companyId), values, history }, + mailboxIdentity(row.id) + ); + } + toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) { + const values = mailboxUseValues(row.values); + const history = ordered(revisions).map((revision) => + MailboxUseRevision.rehydrate( + { + values: mailboxUseValues(revision.values), + actorId: mailboxIdentity(revision.actorId), + recordedAt: revision.recordedAt, + }, + mailboxIdentity(revision.id) + ) + ); + if ( + !history.at(-1)?.values.equals(values) || + history.some((revision, index) => { + const previous = history[index - 1]?.values; + return ( + !revision.values.sameTarget(values) || + (previous?.endDate.getOrUndefined() !== undefined && !previous.equals(revision.values)) + ); + }) || + row.mailboxId !== values.mailboxId.toPrimitive() || + row.personId !== values.personId.toPrimitive() + ) { + throw new Error("Referencias de uso de buzón inconsistentes."); + } + return MailboxUse.rehydrate( + { companyId: mailboxIdentity(row.companyId), values, history }, + mailboxIdentity(row.id) + ); + } + toPersistence(item: Mailbox | MailboxUse) { + return { + id: item.id.toPrimitive(), + companyId: item.companyId.toPrimitive(), + values: JSON.stringify(item.values.toPrimitive()), + revisions: item.history.map((revision, position) => ({ + id: revision.id.toPrimitive(), + itemId: item.id.toPrimitive(), + position, + values: JSON.stringify(revision.values.toPrimitive()), + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + })), + }; + } +} 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 index a1679be..21852b5 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-mail-configuration.repository.ts @@ -7,21 +7,94 @@ import type { IMailInventoryService, MailContext, } from "../../application/mail-configuration.repository"; -import { AccessDefinitionSchema } from "../../domain/access/access-definition"; +import type { MailboxContext } from "../../application/mailbox.repository"; import { MailConfigurationError } from "../../domain/mail-configuration-values"; +import type { MailConfigurationValues } from "../../domain/value-objects/mail-configuration-values"; import { MailConfigurationPersistenceMapper, type MailRevisionRow, type MailRow, mailIdentity, - persistedJson, } from "./mail-configuration-persistence.mapper"; +import type { SequelizeMailboxRepository } from "./sequelize-mailbox.repository"; +import { + type ProfileRevisionRow, + type ProfileRow, + WorkProfilePersistenceMapper, +} from "./work-profile-persistence.mapper"; + +function validateLink( + values: MailConfigurationValues, + legacy: MailConfigurationValues["legacy"], + context: MailContext +) { + if (!legacy) { + return; + } + const profile = context.profiles.find((item) => + item.id.equals(values.profileId.getOrUndefined()) + ); + if ( + !profile || + profile.values.personId.toPrimitive() !== legacy.personId || + profile.values.assetId.toPrimitive() !== legacy.assetId || + values.address.toPrimitive() !== legacy.address.toLowerCase() + ) { + throw new Error("Vinculación histórica de correo inconsistente."); + } +} +function checkScope( + values: MailConfigurationValues, + context: MailContext, + accounts: MailboxContext["items"] +) { + const legacy = values.legacy; + if (legacy) { + return ( + context.people.some((item) => item.id.toPrimitive() === legacy.personId) && + context.assets.some((item) => item.id.toPrimitive() === legacy.assetId) && + accounts.some( + (item) => item.id.toPrimitive() === legacy.accountId && item.values.kind === "account" + ) + ); + } + return ( + context.profiles.some((item) => item.id.equals(values.profileId.getOrUndefined())) && + context.mailboxes.some((item) => item.id.equals(values.mailboxId.getOrUndefined())) + ); +} +function validateReferences(context: MailContext, accounts: MailboxContext["items"]) { + for (const profile of context.profiles) { + if ( + !( + context.people.some((item) => item.id.equals(profile.values.personId)) && + context.assets.some((item) => item.id.equals(profile.values.assetId)) + ) + ) { + throw new Error("Pertenencia de perfil persistida inconsistente."); + } + } + for (const configuration of context.configurations) { + let previousLegacy: MailConfigurationValues["legacy"]; + for (const revision of configuration.history) { + if (!revision.values.legacy) { + validateLink(revision.values, previousLegacy, context); + } + previousLegacy = revision.values.legacy; + if (!checkScope(revision.values, context, accounts)) { + throw new Error("Pertenencia de correo persistida inconsistente."); + } + } + } +} + export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository { private readonly mapper = new MailConfigurationPersistenceMapper(); constructor( private readonly database: Sequelize, - private readonly inventory: IMailInventoryService + private readonly inventory: IMailInventoryService, + private readonly mailboxRepository: SequelizeMailboxRepository ) {} private async load( company: UniqueID, @@ -49,8 +122,17 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR "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", + + const mailboxContext = await this.mailboxRepository.load(company, transaction, false); + if (!mailboxContext.isSuccess) { + return Result.fail(new MailConfigurationError(mailboxContext.error.code)); + } + const profiles = await this.database.query( + "SELECT * FROM work_profiles WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const profileRevisions = await this.database.query( + "SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId", { replacements: { companyId }, transaction, type: QueryTypes.SELECT } ); const actors = await this.database.query<{ id: string; name: string }>( @@ -68,29 +150,20 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR id: mailIdentity(person.id), active: Boolean(person.active), })), - items: items.map((item) => ({ - id: mailIdentity(item.id), - values: AccessDefinitionSchema.parse(persistedJson(item.values)), - })), + mailboxes: mailboxContext.data.mailboxes, + uses: mailboxContext.data.uses, + profiles: profiles.map((row) => + new WorkProfilePersistenceMapper().toDomain( + row, + profileRevisions.filter((revision) => revision.profileId === row.id) + ) + ), 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."); - } + + validateReferences(context, mailboxContext.data.items); return Result.ok(context); } get(companyId: UniqueID) { @@ -112,7 +185,7 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR 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)", + "INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:profileId,:mailboxId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),personId=VALUES(personId),accountId=VALUES(accountId),assetId=VALUES(assetId),profileId=VALUES(profileId),mailboxId=VALUES(mailboxId)", { replacements: record.root, transaction } ); const stored = new Set( diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts new file mode 100644 index 0000000..ea8013b --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-mailbox.repository.ts @@ -0,0 +1,195 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { QueryTypes, type Sequelize, type Transaction } from "sequelize"; + +import type { IMailboxRepository, MailboxContext } from "../../application/mailbox.repository"; +import { AccessDefinitionSchema } from "../../domain/access/access-definition"; +import { Mailbox } from "../../domain/mailbox.aggregate"; +import type { MailboxUse } from "../../domain/mailbox-use.aggregate"; +import { MailboxError } from "../../domain/mailbox-values"; + +import { + MailboxPersistenceMapper, + type MailboxRevisionRow, + type MailboxRow, + type MailboxUseRow, + mailboxIdentity, +} from "./mailbox-persistence.mapper"; + +export class SequelizeMailboxRepository implements IMailboxRepository { + private readonly mapper = new MailboxPersistenceMapper(); + constructor(private readonly database: Sequelize) {} + async load( + companyId: UniqueID, + transaction: Transaction, + write: boolean + ): Promise> { + const replacements = { companyId: companyId.toPrimitive() }; + const companies = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`, + { replacements, transaction, type: QueryTypes.SELECT } + ); + if (!companies.length) { + return Result.fail(new MailboxError("COMPANY_NOT_FOUND")); + } + const rows = await this.database.query( + "SELECT * FROM mailboxes WHERE companyId=:companyId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "SELECT r.* FROM mailbox_revisions r JOIN mailboxes m ON m.id=r.itemId WHERE m.companyId=:companyId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + const useRows = await this.database.query( + "SELECT * FROM mailbox_uses WHERE companyId=:companyId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + const useRevisions = await this.database.query( + "SELECT r.* FROM mailbox_use_revisions r JOIN mailbox_uses m ON m.id=r.itemId WHERE m.companyId=:companyId", + { replacements, transaction, type: QueryTypes.SELECT } + ); + const people = await this.database.query<{ id: string; active: boolean }>( + "SELECT id,active FROM company_contacts WHERE companyId=:companyId", + { replacements, 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, 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: MailboxContext = { + mailboxes: rows.map((row) => + this.mapper.toDomain( + row, + revisions.filter((revision) => revision.itemId === row.id) + ) + ), + uses: useRows.map((row) => + this.mapper.toUseDomain( + row, + useRevisions.filter((revision) => revision.itemId === row.id) + ) + ), + people: people.map((person) => ({ + id: mailboxIdentity(person.id), + active: Boolean(person.active), + })), + items: items.map((item) => ({ + id: mailboxIdentity(item.id), + values: AccessDefinitionSchema.parse( + typeof item.values === "string" ? JSON.parse(item.values) : item.values + ), + })), + actors: new Map(actors.map((actor) => [actor.id, actor.name])), + today: new Date().toISOString().slice(0, 10), + }; + if ( + context.mailboxes.some((mailbox) => + mailbox.history.some((revision) => { + const account = revision.values.accountId.getOrUndefined(); + return ( + account && + !context.items.some((item) => item.id.equals(account) && item.values.kind === "account") + ); + }) + ) || + context.uses.some( + (use) => + !( + context.mailboxes.some((item) => item.id.equals(use.values.mailboxId)) && + context.people.some((item) => item.id.equals(use.values.personId)) + ) + ) + ) { + throw new Error("Ámbito de buzón persistido inconsistente."); + } + return Result.ok(context); + } + private async saveRevisions( + item: Mailbox | MailboxUse, + previous: Mailbox | MailboxUse | undefined, + transaction: Transaction + ) { + const row = this.mapper.toPersistence(item); + const existing = new Set(previous?.history.map((revision) => revision.id.toPrimitive())); + for (const revision of row.revisions.filter((revision) => !existing.has(revision.id))) { + const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions"; + await this.database.query( + `INSERT INTO ${table} (id,itemId,position,actorId,recordedAt,\`values\`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)`, + { replacements: revision, transaction } + ); + } + } + 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 loaded = await this.load(companyId, transaction, true); + if (!loaded.isSuccess) { + return loaded; + } + const candidate = action(loaded.data); + if (!candidate.isSuccess) { + return candidate; + } + const item = candidate.data; + const previous = + item instanceof Mailbox + ? loaded.data.mailboxes.find((old) => old.id.equals(item.id)) + : loaded.data.uses.find((old) => old.id.equals(item.id)); + if (previous !== item) { + const row = this.mapper.toPersistence(item); + if (item instanceof Mailbox) { + const values = item.values.toPrimitive(); + await this.database.query( + "INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)", + { + replacements: { + id: row.id, + companyId: row.companyId, + values: row.values, + accountId: values.accountId, + addressKey: `${row.companyId}:${values.address}`, + }, + transaction, + } + ); + } else { + const values = item.values.toPrimitive(); + await this.database.query( + "INSERT INTO mailbox_uses (id,companyId,mailboxId,personId,`values`) VALUES (:id,:companyId,:mailboxId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)", + { + replacements: { + id: row.id, + companyId: row.companyId, + values: row.values, + mailboxId: values.mailboxId, + personId: values.personId, + }, + transaction, + } + ); + } + await this.saveRevisions(item, previous, transaction); + } + return Result.ok({ + item, + context: { + ...loaded.data, + mailboxes: + item instanceof Mailbox + ? [...loaded.data.mailboxes.filter((old) => !old.id.equals(item.id)), item] + : loaded.data.mailboxes, + uses: + item instanceof Mailbox + ? loaded.data.uses + : [...loaded.data.uses.filter((old) => !old.id.equals(item.id)), item], + }, + }); + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 92a2cdf..5d0c580 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -6,11 +6,13 @@ 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 { MailboxUseCases } from "./application/mailbox.use-cases"; import type { IWorkProfileInventoryService } from "./application/work-profile.repository"; import { WorkProfileUseCases } from "./application/work-profile.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 { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes"; import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes"; import { AccessPersistenceMapper, @@ -20,11 +22,13 @@ import { import { initializeCompanyModels } from "./infrastructure/persistence/company.model"; import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper"; import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration"; +import { migrateMailboxes } from "./infrastructure/persistence/mailbox-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"; +import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository"; import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository"; import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration"; @@ -51,13 +55,19 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req }; }, initializeMail: async (inventory: IMailInventoryService) => { + await migrateWorkProfiles(database); + await migrateMailboxes(database); await migrateMailConfigurations(database); + const mailboxRepository = new SequelizeMailboxRepository(database); + const mailboxes = new MailboxUseCases(mailboxRepository); const mail = new MailConfigurationUseCases( - new SequelizeMailConfigurationRepository(database, inventory) + new SequelizeMailConfigurationRepository(database, inventory, mailboxRepository) ); return { - register: (app: Parameters[0]) => - registerMailRoutes(app, mail, authenticate), + register: (app: Parameters[0]) => { + registerMailRoutes(app, mail, authenticate); + registerMailboxRoutes(app, mailboxes, authenticate); + }, }; }, register: (app: Parameters[0]) => { diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 6a4a6c2..3e4634a 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -6,4 +6,5 @@ export * from "./dto/shared/access.dto"; export * from "./dto/shared/company.dto"; export * from "./dto/shared/people.dto"; export * from "./mail-configurations"; +export * from "./mailboxes"; export * from "./work-profiles"; diff --git a/modules/companies/src/common/mail-configurations.ts b/modules/companies/src/common/mail-configurations.ts index 5488ee7..21422f9 100644 --- a/modules/companies/src/common/mail-configurations.ts +++ b/modules/companies/src/common/mail-configurations.ts @@ -1,6 +1,9 @@ import { z } from "zod/v4"; -import { MailConfigurationInputSchema } from "../api/domain/mail-configuration-values"; +import { + MailConfigurationInputSchema, + MailSnapshotSchema, +} from "../api/domain/mail-configuration-values"; export type { MailConfigurationInput } from "../api/domain/mail-configuration-values"; export { @@ -13,7 +16,7 @@ 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, + values: MailSnapshotSchema, actorId: z.uuid(), actorName: z.string().min(1), recordedAt: z.iso.datetime(), @@ -21,9 +24,10 @@ export const MailRevisionSchema = z.strictObject({ export const MailConfigurationSchema = z.strictObject({ id: z.uuid(), companyId: z.uuid(), - values: MailConfigurationInputSchema, + values: MailSnapshotSchema, history: z.array(MailRevisionSchema).min(1), needsReview: z.boolean(), + legacy: z.boolean(), }); export const MailConfigurationsResponseSchema = z.strictObject({ configurations: z.array(MailConfigurationSchema), diff --git a/modules/companies/src/common/mailboxes.ts b/modules/companies/src/common/mailboxes.ts new file mode 100644 index 0000000..56f7ad9 --- /dev/null +++ b/modules/companies/src/common/mailboxes.ts @@ -0,0 +1,34 @@ +import { z } from "zod/v4"; + +import { MailboxInputSchema, MailboxUseInputSchema } from "../api/domain/mailbox-values"; + +export type { MailboxInput, MailboxUseInput } from "../api/domain/mailbox-values"; + +export { MailboxInputSchema, MailboxUseInputSchema }; +export const MailboxRequestSchema = z.strictObject({ values: MailboxInputSchema }); +export const MailboxUseRequestSchema = z.strictObject({ values: MailboxUseInputSchema }); +const audit = { + id: z.uuid(), + actorId: z.uuid(), + actorName: z.string().min(1), + recordedAt: z.iso.datetime(), +}; +export const MailboxSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: MailboxInputSchema, + history: z.array(z.strictObject({ ...audit, values: MailboxInputSchema })).min(1), +}); +export const MailboxUseSchema = z.strictObject({ + id: z.uuid(), + companyId: z.uuid(), + values: MailboxUseInputSchema, + history: z.array(z.strictObject({ ...audit, values: MailboxUseInputSchema })).min(1), +}); +export const MailboxesResponseSchema = z.strictObject({ + mailboxes: z.array(MailboxSchema), + uses: z.array(MailboxUseSchema), + savedId: z.uuid().optional(), +}); +export type MailboxDTO = z.infer; +export type MailboxUseDTO = z.infer; diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index 9e55eaf..c1f2a3e 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -20,6 +20,7 @@ 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 { MailboxesPage } from "../mail/mailboxes-page"; import { WorkProfilesPage } from "../profiles/work-profiles-page"; import { CompanyFeedback } from "./company-feedback"; @@ -110,6 +111,7 @@ type CompanyTab = | "assets" | "inventory" | "workstations" + | "mailboxes" | "mail" | "profiles"; @@ -209,6 +211,7 @@ export function CompanyDetail({ ], ["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon], ["access", accessTranslation("title"), KeyRoundIcon], + ["mailboxes", t("mailbox.title"), MailIcon], ["mail", t("mail.title"), MailIcon], ["assets", assetTranslation("companyTitle"), LayoutTemplateIcon], ] as const @@ -257,6 +260,8 @@ function CompanySection({ ); case "profiles": return ; + case "mailboxes": + return ; case "mail": return ; case "workstations": diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index 9e58057..4749273 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -1,8 +1,10 @@ import { mailEnglish, mailSpanish } from "./mail/mail-i18n"; +import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n"; import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n"; export const companiesSpanish = { mail: mailSpanish, + mailbox: mailboxSpanish, profiles: profilesSpanish, companySpaces: "Espacios de compañías", globalCatalogs: "Catálogos globales", @@ -119,6 +121,7 @@ export const companiesSpanish = { }; export const companiesEnglish = { mail: mailEnglish, + mailbox: mailboxEnglish, profiles: profilesEnglish, companySpaces: "Company spaces", globalCatalogs: "Global catalogs", diff --git a/modules/companies/src/web/mail/mail-configuration-form.tsx b/modules/companies/src/web/mail/mail-configuration-form.tsx index 2826158..023ba2a 100644 --- a/modules/companies/src/web/mail/mail-configuration-form.tsx +++ b/modules/companies/src/web/mail/mail-configuration-form.tsx @@ -2,8 +2,7 @@ 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 { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog"; 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"; @@ -12,38 +11,40 @@ 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 type { 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 { - ConfigurationQuickEditor, - type ConfigurationQuickKind, -} from "../components/configuration-quick-editor"; +import type { MailboxDTO, MailboxUseDTO } from "../../common/mailboxes"; +import type { WorkProfileDTO } from "../../common/work-profiles"; +import { profileApi, profileKeys } from "../profiles/work-profile-api"; +import { WorkProfileForm } from "../profiles/work-profile-form"; import { mailApi, mailKeys } from "./mail-api"; import { MailFeedback } from "./mail-feedback"; +import { mailboxApi, mailboxKeys } from "./mailbox-api"; +import { MailboxForm } from "./mailbox-form"; +import { MailboxUseForm } from "./mailbox-use-form"; export function MailConfigurationForm({ company, csrfToken, configuration, onSaved, + initialProfileId = "", }: { company: CompanyDTO; csrfToken: string; configuration?: MailConfigurationDTO | undefined; onSaved: () => void; + initialProfileId?: string; }) { const { t } = useTranslation("companies"); const client = useQueryClient(); const prefix = useId(); - const [quick, setQuick] = useState(); const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -51,32 +52,35 @@ export function MailConfigurationForm({ mounted.current = false; }; }, []); - const access = useQuery({ - queryKey: accessKeys.scope(company.id), - queryFn: () => accessApi.request(company.id), + const [quick, setQuick] = useState<"mailbox" | "profile" | "use">(); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const mailboxes = useQuery({ + queryKey: mailboxKeys.company(company.id), + queryFn: () => mailboxApi.request(company.id), }); const inventory = useQuery({ queryKey: inventoryKeys.company(company.id), queryFn: () => inventoryApi.get(company.id), }); + const existing = configuration?.values; + const legacy = legacyOf(existing); const form = useForm({ resolver: zodResolver(MailConfigurationInputSchema), - defaultValues: configuration?.values ?? { - personId: "", - accountId: "", - assetId: "", - address: "", - method: "imap", - state: "pending", - notes: null, - }, + defaultValues: configurationDefaults(existing, initialProfileId), }); const values = form.watch(); - const items = access.data?.items ?? []; - const accounts = items.filter( - (item) => item.values.kind === "account" && item.values.usage !== "technical" + const selectedProfile = profiles.data?.profiles.find((item) => item.id === values.profileId); + const selectedMailbox = mailboxes.data?.mailboxes.find((item) => item.id === values.mailboxId); + const today = new Date().toISOString().slice(0, 10); + const authorized = hasMailboxUse( + mailboxes.data?.uses ?? [], + values.mailboxId, + selectedProfile?.values.personId, + today ); - const authorized = hasAuthorization(items, values); const mutation = useMutation({ mutationFn: (input: MailConfigurationInput) => mailApi.save(company.id, input, csrfToken, configuration?.id), @@ -87,160 +91,140 @@ export function MailConfigurationForm({ } }, }); - 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; + const fixed = configuration?.legacy === false; + const states = configurationStates(configuration); + const profileLabel = (id: string) => + profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id; + const personLabel = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; + const assetLabel = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const disabled = mutation.isPending; + const ready = [profiles.data, mailboxes.data, inventory.data].every(Boolean); return ( <>
mutation.mutate(input))}> -
- {selectors.map((selector) => ( -
- +
+ {legacy && ( +

+ {t("mail.legacyLink")} {legacy.address} · {personLabel(legacy.personId)} ·{" "} + {assetLabel(legacy.assetId)} +

+ )} +
+
+ + setQuick("profile") } + } + disabled={fixed || !!initialProfileId || !ready} + id={`${prefix}-profile`} + label={t("mail.profile")} + onValueChange={(id) => form.setValue("profileId", id)} + options={profileOptions( + profiles.data?.profiles ?? [], + values.profileId, + legacy + ).map((item) => ({ + value: item.id, + label: + profileLabel(item.id) + + " · " + + personLabel(item.values.personId) + + " · " + + assetLabel(item.values.assetId), + }))} + placeholder={t("mail.choose")} + required + value={values.profileId} + /> +
+
+ setQuick(selector.kind), - } + : { label: t("mailbox.new"), onClick: () => setQuick("mailbox") } } - 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} + disabled={fixed || !ready} + id={`${prefix}-mailbox`} + label={t("mailbox.titleSingle")} + onValueChange={(id) => form.setValue("mailboxId", id)} + options={mailboxOptions( + mailboxes.data?.mailboxes ?? [], + values.mailboxId, + legacy + ).map((item) => ({ value: item.id, label: item.values.address }))} placeholder={t("mail.choose")} required - value={values[selector.field]} + value={values.mailboxId} + /> +
+
+ + { + const parsed = MailConfigurationInputSchema.shape.method.safeParse(value); + if (parsed.success) { + form.setValue("method", parsed.data); + } + }} + options={MailConfigurationInputSchema.shape.method.options.map((method) => ({ + value: method, + label: t(`mail.${method}`), + }))} + placeholder={t("mail.choose")} + required + value={values.method} + /> +
+
+ + { + const parsed = MailConfigurationInputSchema.shape.state.safeParse(value); + if (parsed.success) { + form.setValue("state", parsed.data); + } + }} + options={states.map((state) => ({ value: state, label: t(`mail.${state}`) }))} + placeholder={t("mail.choose")} + required + value={values.state} />
- ))} -
- -
-
- - { - 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")}

- -
- )} -
- -