feat(mailbox): implement mailbox and mailbox use management features
- Add mailbox persistence mapper for domain object mapping. - Create Sequelize repository for mailbox data access. - Define common mailbox schemas and types for validation. - Implement mailbox API for frontend communication. - Develop mailbox form component for creating and editing mailboxes. - Add internationalization support for mailbox labels and messages. - Create mailbox use form for managing mailbox usage periods. - Build mailboxes page to display and manage mailboxes and their usage.
This commit is contained in:
parent
3e6066b79b
commit
6375fa1b32
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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<Response>;
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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<readonly Readonly<{ id: UniqueID; available: boolean }>[]>;
|
||||
}
|
||||
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<string, string>;
|
||||
today: string;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
22
modules/companies/src/api/application/mailbox.repository.ts
Normal file
22
modules/companies/src/api/application/mailbox.repository.ts
Normal file
@ -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<string, string>;
|
||||
today: string;
|
||||
}>;
|
||||
export interface IMailboxRepository {
|
||||
get(companyId: UniqueID): Promise<Result<MailboxContext, MailboxError>>;
|
||||
save(
|
||||
companyId: UniqueID,
|
||||
action: (context: MailboxContext) => Result<Mailbox | MailboxUse, MailboxError>
|
||||
): Promise<Result<{ item: Mailbox | MailboxUse; context: MailboxContext }, MailboxError>>;
|
||||
}
|
||||
79
modules/companies/src/api/application/mailbox.use-cases.ts
Normal file
79
modules/companies/src/api/application/mailbox.use-cases.ts
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<typeof MailConfigurationInputSchema>;
|
||||
export type MailSnapshot = z.infer<typeof MailSnapshotSchema>;
|
||||
|
||||
export class MailConfigurationError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
|
||||
@ -24,7 +24,8 @@ export class MailConfiguration extends AggregateRoot<Props> {
|
||||
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<Props> {
|
||||
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 (
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
25
modules/companies/src/api/domain/mailbox-revision.entity.ts
Normal file
25
modules/companies/src/api/domain/mailbox-revision.entity.ts
Normal file
@ -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<MailboxRevisionProps> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<MailboxUseRevisionProps> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
78
modules/companies/src/api/domain/mailbox-use.aggregate.ts
Normal file
78
modules/companies/src/api/domain/mailbox-use.aggregate.ts
Normal file
@ -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<MailboxUseProps> {
|
||||
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<MailboxUse, MailboxError> {
|
||||
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<MailboxUse, MailboxError> {
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
22
modules/companies/src/api/domain/mailbox-values.ts
Normal file
22
modules/companies/src/api/domain/mailbox-values.ts
Normal file
@ -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<typeof MailboxInputSchema>;
|
||||
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<typeof MailboxUseInputSchema>;
|
||||
export class MailboxError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
75
modules/companies/src/api/domain/mailbox.aggregate.ts
Normal file
75
modules/companies/src/api/domain/mailbox.aggregate.ts
Normal file
@ -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<MailboxProps> {
|
||||
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<Mailbox, MailboxError> {
|
||||
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<Mailbox, MailboxError> {
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
130
modules/companies/src/api/domain/mailbox.test.ts
Normal file
130
modules/companies/src/api/domain/mailbox.test.ts
Normal file
@ -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)
|
||||
);
|
||||
});
|
||||
@ -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<MailSnapshot>,
|
||||
public readonly address: EmailAddress,
|
||||
public readonly method: MailConfigurationInput["method"],
|
||||
public readonly state: MailConfigurationInput["state"],
|
||||
public readonly notes: Maybe<string>
|
||||
public readonly profileId: Maybe<UniqueID>,
|
||||
public readonly mailboxId: Maybe<UniqueID>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(
|
||||
input: MailConfigurationInput
|
||||
): Result<MailConfigurationValues, MailConfigurationError> {
|
||||
const parsed = MailConfigurationInputSchema.safeParse(input);
|
||||
static create(input: MailSnapshot): Result<MailConfigurationValues, MailConfigurationError> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CalendarDate>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: MailboxUseInput): Result<MailboxUseValues, MailboxError> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<string>,
|
||||
public readonly accountId: Maybe<UniqueID>,
|
||||
public readonly active: boolean,
|
||||
public readonly notes: Maybe<string>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: MailboxInput): Result<MailboxValues, MailboxError> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<ProfileRow>(
|
||||
"SELECT * FROM work_profiles WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const profileRevisions = await this.database.query<ProfileRevisionRow>(
|
||||
"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(
|
||||
|
||||
@ -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<Result<MailboxContext, MailboxError>> {
|
||||
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<MailboxRow>(
|
||||
"SELECT * FROM mailboxes WHERE companyId=:companyId",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<MailboxRevisionRow>(
|
||||
"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<MailboxUseRow>(
|
||||
"SELECT * FROM mailbox_uses WHERE companyId=:companyId",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const useRevisions = await this.database.query<MailboxRevisionRow>(
|
||||
"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<IMailboxRepository["save"]>[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],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<typeof registerMailRoutes>[0]) =>
|
||||
registerMailRoutes(app, mail, authenticate),
|
||||
register: (app: Parameters<typeof registerMailRoutes>[0]) => {
|
||||
registerMailRoutes(app, mail, authenticate);
|
||||
registerMailboxRoutes(app, mailboxes, authenticate);
|
||||
},
|
||||
};
|
||||
},
|
||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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),
|
||||
|
||||
34
modules/companies/src/common/mailboxes.ts
Normal file
34
modules/companies/src/common/mailboxes.ts
Normal file
@ -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<typeof MailboxSchema>;
|
||||
export type MailboxUseDTO = z.infer<typeof MailboxUseSchema>;
|
||||
@ -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 <WorkProfilesPage company={company} csrfToken={csrfToken} />;
|
||||
case "mailboxes":
|
||||
return <MailboxesPage company={company} csrfToken={csrfToken} />;
|
||||
case "mail":
|
||||
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "workstations":
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<ConfigurationQuickKind>();
|
||||
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<MailConfigurationInput>({
|
||||
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 (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{selectors.map((selector) => (
|
||||
<div className="space-y-2" key={selector.field}>
|
||||
<Label htmlFor={`${prefix}-${selector.field}`}>{t(`mail.${selector.label}`)}</Label>
|
||||
<fieldset className="space-y-4" disabled={disabled}>
|
||||
{legacy && (
|
||||
<p className="rounded-md border p-3 text-sm">
|
||||
{t("mail.legacyLink")} {legacy.address} · {personLabel(legacy.personId)} ·{" "}
|
||||
{assetLabel(legacy.assetId)}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed || initialProfileId
|
||||
? undefined
|
||||
: { label: t("profiles.new"), onClick: () => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-mailbox`}>{t("mailbox.titleSingle")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed
|
||||
? undefined
|
||||
: {
|
||||
label: t(`mail.${selector.create}`),
|
||||
onClick: () => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-method`}>{t("mail.method")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-method`}
|
||||
label={t("mail.method")}
|
||||
onValueChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
onValueChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-address`}>{t("mail.address")}</Label>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
id={`${prefix}-address`}
|
||||
maxLength={254}
|
||||
required
|
||||
type="email"
|
||||
{...form.register("address")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-method`}>{t("mail.method")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={disabled}
|
||||
id={`${prefix}-method`}
|
||||
label={t("mail.method")}
|
||||
onValueChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={disabled}
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
onValueChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.personId && values.accountId && !authorized && !closed && (
|
||||
<div className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<p>{t("mail.noAuthorization")}</p>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => setQuick("authorization")}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("mail.authorize")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<Textarea
|
||||
disabled={disabled}
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
onChange={(event) => form.setValue("notes", event.target.value || null)}
|
||||
rows={3}
|
||||
value={values.notes ?? ""}
|
||||
<MailboxUsagePrompt
|
||||
authorized={authorized ?? false}
|
||||
mailbox={selectedMailbox}
|
||||
onRegister={() => setQuick("use")}
|
||||
profile={selectedProfile}
|
||||
/>
|
||||
</div>
|
||||
{configuration && <p className="text-sm text-muted-foreground">{t("mail.fixed")}</p>}
|
||||
{(access.isPending || inventory.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
onChange={(event) => form.setValue("notes", event.target.value || null)}
|
||||
rows={3}
|
||||
value={values.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{fixed && <p className="text-sm text-muted-foreground">{t("mail.fixed")}</p>}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p role="alert">{t("mail.errors.INVALID_REQUEST")}</p>
|
||||
)}
|
||||
<MailFeedback error={mutation.error ?? access.error ?? inventory.error} />
|
||||
<MailFeedback
|
||||
error={mutation.error ?? profiles.error ?? mailboxes.error ?? inventory.error}
|
||||
/>
|
||||
<ContextHelp
|
||||
description={t("mail.description")}
|
||||
examples={t("mail.examples")}
|
||||
@ -250,42 +234,178 @@ export function MailConfigurationForm({
|
||||
title={t("mail.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={disabled || !access.data || !inventory.data} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
<Button disabled={disabled || !ready} type="submit">
|
||||
{t(disabled ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<ConfigurationQuickEditor
|
||||
<MailQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
data={access.data}
|
||||
initialAddress={legacy?.address ?? ""}
|
||||
kind={quick}
|
||||
mailbox={selectedMailbox}
|
||||
onClose={() => setQuick(undefined)}
|
||||
onCreated={created}
|
||||
values={values}
|
||||
onCreated={(field, id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue(field, id);
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
profile={selectedProfile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function hasAuthorization(items: readonly AccessItemDTO[], values: MailConfigurationInput) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return items.some((item) => {
|
||||
const relation = item.values;
|
||||
return (
|
||||
relation.kind === "account_person" &&
|
||||
relation.personId === values.personId &&
|
||||
relation.accountId === values.accountId &&
|
||||
relation.startDate <= today &&
|
||||
(relation.endDate === null || today < relation.endDate)
|
||||
);
|
||||
});
|
||||
function configurationDefaults(
|
||||
existing: MailConfigurationDTO["values"] | undefined,
|
||||
initialProfileId: string
|
||||
): MailConfigurationInput {
|
||||
return {
|
||||
profileId: existing && "profileId" in existing ? existing.profileId : initialProfileId,
|
||||
mailboxId: existing && "mailboxId" in existing ? existing.mailboxId : "",
|
||||
method: existing?.method ?? "imap",
|
||||
state: existing?.state ?? "pending",
|
||||
notes: existing?.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function allowedStates(configuration?: MailConfigurationDTO) {
|
||||
function MailQuickEditor({
|
||||
kind,
|
||||
company,
|
||||
csrfToken,
|
||||
profile,
|
||||
mailbox,
|
||||
initialAddress,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
kind: "profile" | "mailbox" | "use";
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
profile: WorkProfileDTO | undefined;
|
||||
mailbox: MailboxDTO | undefined;
|
||||
initialAddress: string;
|
||||
onClose: () => void;
|
||||
onCreated: (field: "profileId" | "mailboxId", id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const titles = { mailbox: "mailbox.new", profile: "profiles.new", use: "mailbox.linkPerson" };
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(titles[kind])}
|
||||
wide
|
||||
>
|
||||
{kind === "mailbox" && (
|
||||
<MailboxForm
|
||||
allowQuickCreate={false}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialAddress={initialAddress}
|
||||
onSaved={(id) => onCreated("mailboxId", id)}
|
||||
/>
|
||||
)}
|
||||
{kind === "profile" && (
|
||||
<WorkProfileForm
|
||||
allowQuickCreate={false}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => onCreated("profileId", id)}
|
||||
/>
|
||||
)}
|
||||
{kind === "use" && profile && mailbox && (
|
||||
<MailboxUseForm
|
||||
allowQuickCreate={false}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialMailboxId={mailbox.id}
|
||||
initialPersonId={profile.values.personId}
|
||||
onSaved={onClose}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function legacyOf(existing: MailConfigurationDTO["values"] | undefined) {
|
||||
return existing && "personId" in existing ? existing : undefined;
|
||||
}
|
||||
function profileOptions(
|
||||
items: readonly WorkProfileDTO[],
|
||||
selected: string,
|
||||
legacy: ReturnType<typeof legacyOf>
|
||||
) {
|
||||
return items.filter(
|
||||
(item) =>
|
||||
(item.values.active || item.id === selected || Boolean(legacy)) &&
|
||||
(!legacy ||
|
||||
(item.values.personId === legacy.personId && item.values.assetId === legacy.assetId))
|
||||
);
|
||||
}
|
||||
function mailboxOptions(
|
||||
items: readonly MailboxDTO[],
|
||||
selected: string,
|
||||
legacy: ReturnType<typeof legacyOf>
|
||||
) {
|
||||
return items.filter(
|
||||
(item) =>
|
||||
(item.values.active || item.id === selected || Boolean(legacy)) &&
|
||||
(!legacy || item.values.address.toLowerCase() === legacy.address.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function configurationStates(configuration: MailConfigurationDTO | undefined) {
|
||||
return configuration
|
||||
? MAIL_STATE_TRANSITIONS[configuration.values.state]
|
||||
: ["pending", "configured"];
|
||||
}
|
||||
function MailboxUsagePrompt({
|
||||
profile,
|
||||
mailbox,
|
||||
authorized,
|
||||
onRegister,
|
||||
}: {
|
||||
profile: WorkProfileDTO | undefined;
|
||||
mailbox: MailboxDTO | undefined;
|
||||
authorized: boolean;
|
||||
onRegister: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
if (!(profile && mailbox) || authorized) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border p-3 text-sm space-y-2">
|
||||
<p>{t("mailbox.useMissing")}</p>
|
||||
<Button onClick={onRegister} type="button" variant="outline">
|
||||
{t(mailbox.values.kind === "corporate" ? "mailbox.authorize" : "mailbox.recordUse")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasMailboxUse(
|
||||
uses: readonly MailboxUseDTO[],
|
||||
mailboxId: string,
|
||||
personId: string | undefined,
|
||||
today: string
|
||||
) {
|
||||
return uses.some(
|
||||
(use) =>
|
||||
use.values.mailboxId === mailboxId &&
|
||||
use.values.personId === personId &&
|
||||
use.values.startDate <= today &&
|
||||
(use.values.endDate === null || today < use.values.endDate)
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,16 +8,21 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import type { MailConfigurationDTO } from "../../common/mail-configurations";
|
||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||
|
||||
import { mailApi, mailKeys } from "./mail-api";
|
||||
import { MailConfigurationForm } from "./mail-configuration-form";
|
||||
import { MailFeedback } from "./mail-feedback";
|
||||
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
||||
export function MailConfigurationsPage({
|
||||
company,
|
||||
csrfToken,
|
||||
initialProfileId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialProfileId?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
@ -31,22 +36,53 @@ export function MailConfigurationsPage({
|
||||
queryKey: inventoryKeys.company(company.id),
|
||||
queryFn: () => inventoryApi.get(company.id),
|
||||
});
|
||||
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 configurations = query.data?.configurations ?? [];
|
||||
const edited = configurations.find((item) => item.id === editor);
|
||||
const viewed = configurations.find((item) => item.id === view);
|
||||
const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id;
|
||||
const asset = (id: string) =>
|
||||
inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id;
|
||||
const filtered = configurations.filter((item) =>
|
||||
[item.values.address, person(item.values.personId), asset(item.values.assetId)].some((value) =>
|
||||
value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
|
||||
const target = (values: MailConfigurationDTO["values"]) => {
|
||||
if ("personId" in values) {
|
||||
return `${person(values.personId)} · ${asset(values.assetId)}`;
|
||||
}
|
||||
const profile = profiles.data?.profiles.find((item) => item.id === values.profileId);
|
||||
return profile
|
||||
? `${profile.values.name} · ${person(profile.values.personId)} · ${asset(profile.values.assetId)}`
|
||||
: values.profileId;
|
||||
};
|
||||
const address = (item: MailConfigurationDTO) => {
|
||||
const values = item.values;
|
||||
return "mailboxId" in values
|
||||
? (mailboxes.data?.mailboxes.find((mailbox) => mailbox.id === values.mailboxId)?.values
|
||||
.address ?? values.address)
|
||||
: values.address;
|
||||
};
|
||||
const filtered = configurations.filter(
|
||||
(item) =>
|
||||
(!initialProfileId ||
|
||||
("profileId" in item.values && item.values.profileId === initialProfileId)) &&
|
||||
[address(item), target(item.values)].some((value) =>
|
||||
value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("mail.title")}</h2>
|
||||
<Button disabled={!(query.data && inventory.data)} onClick={() => setEditor("new")}>
|
||||
<Button
|
||||
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("mail.new")}
|
||||
</Button>
|
||||
</header>
|
||||
@ -56,12 +92,19 @@ export function MailConfigurationsPage({
|
||||
placeholder={t("mail.search")}
|
||||
value={search}
|
||||
/>
|
||||
{(query.isPending || inventory.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<MailFeedback error={query.error ?? inventory.error} />
|
||||
{(query.isError || inventory.isError) && (
|
||||
{(query.isPending || inventory.isPending || profiles.isPending || mailboxes.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
<MailFeedback error={query.error ?? inventory.error ?? profiles.error ?? mailboxes.error} />
|
||||
{(query.isError || inventory.isError || profiles.isError || mailboxes.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), inventory.refetch()]).catch(() => undefined);
|
||||
Promise.all([
|
||||
query.refetch(),
|
||||
inventory.refetch(),
|
||||
profiles.refetch(),
|
||||
mailboxes.refetch(),
|
||||
]).catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
@ -80,12 +123,11 @@ export function MailConfigurationsPage({
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
||||
<div className="min-w-0 break-words">
|
||||
<h3 className="font-medium">
|
||||
{item.values.address} · {t(`mail.${item.values.method}`)}
|
||||
{address(item)} · {t(`mail.${item.values.method}`)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{person(item.values.personId)} · {asset(item.values.assetId)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{target(item.values)}</p>
|
||||
<p className="mt-1 text-sm">{t(`mail.${item.values.state}`)}</p>
|
||||
{item.legacy && <p className="mt-2 text-sm text-amber-700">{t("mail.legacy")}</p>}
|
||||
{item.needsReview && (
|
||||
<p className="mt-2 text-sm text-amber-700">{t("mail.review")}</p>
|
||||
)}
|
||||
@ -122,6 +164,7 @@ export function MailConfigurationsPage({
|
||||
company={company}
|
||||
configuration={edited}
|
||||
csrfToken={csrfToken}
|
||||
initialProfileId={initialProfileId ?? ""}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
@ -152,9 +195,7 @@ export function MailConfigurationsPage({
|
||||
{revision.values.address} · {t(`mail.${revision.values.method}`)} ·{" "}
|
||||
{t(`mail.${revision.values.state}`)}
|
||||
</p>
|
||||
<p>
|
||||
{person(revision.values.personId)} · {asset(revision.values.assetId)}
|
||||
</p>
|
||||
<p>{target(revision.values)}</p>
|
||||
{revision.values.notes && (
|
||||
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
export const mailSpanish = {
|
||||
title: "Correos en dispositivos",
|
||||
new: "Configurar correo",
|
||||
profile: "Perfil de trabajo",
|
||||
legacy: "Registro anterior pendiente de vincular a buzón y perfil.",
|
||||
legacyLink:
|
||||
"Selecciona el perfil de esta persona en este dispositivo y un buzón con la misma dirección. Se conservará el historial anterior:",
|
||||
edit: "Editar configuración",
|
||||
empty: "Todavía no hay correos registrados en dispositivos.",
|
||||
search: "Buscar correo, persona o dispositivo…",
|
||||
@ -21,7 +25,7 @@ export const mailSpanish = {
|
||||
removal_pending: "Pendiente de retirar",
|
||||
removed: "Retirado",
|
||||
never_configured: "No llegó a configurarse",
|
||||
review: "Revisar acceso: autorización, persona, cuenta o dispositivo no vigente.",
|
||||
review: "Revisar uso: buzón, perfil, persona, dispositivo o periodo de uso no vigente.",
|
||||
noAuthorization:
|
||||
"Autoriza esta cuenta para la persona antes de guardar una configuración activa.",
|
||||
authorize: "Autorizar cuenta para esta persona",
|
||||
@ -36,19 +40,30 @@ export const mailSpanish = {
|
||||
examples:
|
||||
"Ana: ana@empresa.es por IMAP en su portátil y por Exchange en su móvil; ventas@empresa.es también en el portátil.",
|
||||
guidance:
|
||||
"La autorización de la cuenta se gestiona por separado. Al retirar un correo, confirma primero que se ha eliminado del dispositivo. No introduzcas contraseñas.",
|
||||
"El uso del buzón por la persona se registra por separado y no exige una cuenta informática. Al retirar un correo, confirma primero que se ha eliminado del dispositivo. No introduzcas contraseñas.",
|
||||
scope:
|
||||
"Puedes registrar varias cuentas por persona y varios dispositivos por cuenta. Los cambios conservan el historial.",
|
||||
"Puedes registrar varios buzones por persona y varios perfiles por buzón. Los cambios conservan el historial.",
|
||||
fixed:
|
||||
"Persona, cuenta y dispositivo quedan fijados. Para cambiar alguno, retira esta configuración y crea otra.",
|
||||
"Buzón y perfil quedan fijados. Para cambiar alguno, retira esta configuración y crea otra.",
|
||||
errors: {
|
||||
MAIL_LEGACY_TARGET_MISMATCH:
|
||||
"La vinculación debe conservar la persona, dispositivo y dirección del registro anterior.",
|
||||
MAILBOX_EXISTS: "Ya existe un buzón con esta dirección.",
|
||||
MAILBOX_SCOPE_MISMATCH: "Todos los elementos deben pertenecer a esta compañía.",
|
||||
MAILBOX_UNAVAILABLE: "El buzón y la persona deben estar activos.",
|
||||
MAILBOX_USE_OVERLAP: "Ya existe un periodo de uso que coincide con estas fechas.",
|
||||
MAILBOX_KIND_IMMUTABLE: "El tipo de buzón queda fijo desde el alta.",
|
||||
MAILBOX_USE_TARGET_IMMUTABLE: "La persona, buzón y fecha de inicio quedan fijos.",
|
||||
MAILBOX_USE_CLOSED: "Este periodo ya está cerrado.",
|
||||
MAILBOX_NOT_FOUND: "El buzón no está disponible.",
|
||||
MAILBOX_USE_NOT_FOUND: "La vinculación no está disponible.",
|
||||
INVALID_PERIOD: "Revisa las fechas del periodo.",
|
||||
INVALID_REQUEST: "Revisa los campos; introduce una dirección de correo válida.",
|
||||
MAIL_SCOPE_MISMATCH: "La persona, cuenta y dispositivo deben pertenecer a esta compañía.",
|
||||
MAIL_SCOPE_MISMATCH: "El buzón y el perfil deben pertenecer a esta compañía.",
|
||||
MAIL_NOT_AUTHORIZED:
|
||||
"La persona necesita una autorización vigente sobre esta cuenta y un dispositivo disponible.",
|
||||
MAIL_CONFIGURATION_EXISTS:
|
||||
"Ya hay una configuración abierta para esta persona, cuenta y dispositivo.",
|
||||
MAIL_TARGET_IMMUTABLE: "Para cambiar persona, cuenta o dispositivo, crea otra configuración.",
|
||||
"El perfil y el buzón deben estar vigentes y la persona debe tener un periodo de uso vigente del buzón.",
|
||||
MAIL_CONFIGURATION_EXISTS: "Ya hay una configuración abierta para este buzón y perfil.",
|
||||
MAIL_TARGET_IMMUTABLE: "Para cambiar buzón o perfil, crea otra configuración.",
|
||||
INVALID_MAIL_TRANSITION:
|
||||
"El cambio de estado no es válido. Un correo configurado pasa primero a pendiente de retirar.",
|
||||
MAIL_CONFIGURATION_NOT_FOUND: "La configuración no está disponible en esta compañía.",
|
||||
@ -61,6 +76,10 @@ export const mailSpanish = {
|
||||
export const mailEnglish = {
|
||||
title: "Email on devices",
|
||||
new: "Configure email",
|
||||
profile: "Work profile",
|
||||
legacy: "Previous record awaiting mailbox and profile linking.",
|
||||
legacyLink:
|
||||
"Select this person’s profile on this device and a mailbox with the same address. Previous history will be retained:",
|
||||
edit: "Edit configuration",
|
||||
empty: "No email configurations on devices yet.",
|
||||
search: "Find email, person or device…",
|
||||
@ -81,7 +100,7 @@ export const mailEnglish = {
|
||||
removal_pending: "Pending removal",
|
||||
removed: "Removed",
|
||||
never_configured: "Never configured",
|
||||
review: "Review access: authorization, person, account or device is no longer valid.",
|
||||
review: "Review usage: mailbox, profile, person, device or usage period is no longer valid.",
|
||||
noAuthorization: "Authorize this account for the person before saving an active configuration.",
|
||||
authorize: "Authorize account for this person",
|
||||
newAccount: "New account",
|
||||
@ -94,19 +113,29 @@ export const mailEnglish = {
|
||||
examples:
|
||||
"Ana: ana@company.com via IMAP on her laptop and Exchange on her phone; sales@company.com on the laptop too.",
|
||||
guidance:
|
||||
"Account authorization is managed separately. Confirm removal from the device before marking it removed. Do not enter passwords.",
|
||||
"Mailbox usage by the person is tracked separately and does not require an IT account. Confirm removal from the device before marking it removed. Do not enter passwords.",
|
||||
scope:
|
||||
"Multiple accounts per person and multiple devices per account are supported. Changes retain history.",
|
||||
"Multiple mailboxes per person and multiple profiles per mailbox are supported. Changes retain history.",
|
||||
fixed:
|
||||
"Person, account and device are fixed. To change any of them, remove this configuration and create another.",
|
||||
"Mailbox and profile are fixed. To change either, remove this configuration and create another.",
|
||||
errors: {
|
||||
MAIL_LEGACY_TARGET_MISMATCH: "Linking must preserve the previous person, device and address.",
|
||||
MAILBOX_EXISTS: "A mailbox with this address already exists.",
|
||||
MAILBOX_SCOPE_MISMATCH: "All elements must belong to this company.",
|
||||
MAILBOX_UNAVAILABLE: "Mailbox and person must be active.",
|
||||
MAILBOX_USE_OVERLAP: "A usage period already overlaps these dates.",
|
||||
MAILBOX_KIND_IMMUTABLE: "Mailbox type is fixed after creation.",
|
||||
MAILBOX_USE_TARGET_IMMUTABLE: "Person, mailbox and start date are fixed.",
|
||||
MAILBOX_USE_CLOSED: "This period is already closed.",
|
||||
MAILBOX_NOT_FOUND: "The mailbox is unavailable.",
|
||||
MAILBOX_USE_NOT_FOUND: "The usage link is unavailable.",
|
||||
INVALID_PERIOD: "Check the period dates.",
|
||||
INVALID_REQUEST: "Review the fields; enter a valid email address.",
|
||||
MAIL_SCOPE_MISMATCH: "Person, account and device must belong to this company.",
|
||||
MAIL_SCOPE_MISMATCH: "Mailbox and profile must belong to this company.",
|
||||
MAIL_NOT_AUTHORIZED:
|
||||
"The person needs a current account authorization and an available device.",
|
||||
MAIL_CONFIGURATION_EXISTS:
|
||||
"An open configuration already exists for this person, account and device.",
|
||||
MAIL_TARGET_IMMUTABLE: "Create another configuration to change person, account or device.",
|
||||
"The profile and mailbox must be current, with an active mailbox usage period for the person.",
|
||||
MAIL_CONFIGURATION_EXISTS: "An open configuration already exists for this mailbox and profile.",
|
||||
MAIL_TARGET_IMMUTABLE: "Create another configuration to change mailbox or profile.",
|
||||
INVALID_MAIL_TRANSITION:
|
||||
"Invalid status change. Configured email must first be marked for removal.",
|
||||
MAIL_CONFIGURATION_NOT_FOUND: "The configuration is unavailable in this company.",
|
||||
|
||||
@ -10,10 +10,14 @@ import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { accessKeys } from "../access/access-api";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
import { profileKeys } from "../profiles/work-profile-api";
|
||||
|
||||
import { mailKeys } from "./mail-api";
|
||||
import { MailConfigurationForm } from "./mail-configuration-form";
|
||||
import { MailConfigurationsPage } from "./mail-configurations-page";
|
||||
import { mailboxKeys } from "./mailbox-api";
|
||||
import { MailboxForm } from "./mailbox-form";
|
||||
import { MailboxesPage } from "./mailboxes-page";
|
||||
|
||||
const company = {
|
||||
id: "0199b200-0000-7000-8000-000000000001",
|
||||
@ -35,6 +39,8 @@ async function render(language: string, node: ReactNode) {
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
client.setQueryData(mailKeys.company(company.id), { configurations: [] });
|
||||
client.setQueryData(profileKeys.company(company.id), { profiles: [] });
|
||||
client.setQueryData(mailboxKeys.company(company.id), { mailboxes: [], uses: [] });
|
||||
client.setQueryData(accessKeys.scope(company.id), { items: [], reviews: [], coverage: [] });
|
||||
client.setQueryData(inventoryKeys.company(company.id), {
|
||||
assets: [],
|
||||
@ -69,13 +75,29 @@ test("correo: selectores reutilizables con altas y ayuda inmediatamente antes de
|
||||
"es",
|
||||
<MailConfigurationForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
for (const label of ["Nueva persona", "Nueva cuenta", "Nuevo dispositivo"]) {
|
||||
for (const label of ["Nuevo perfil", "Nuevo buzón"]) {
|
||||
assert.ok(html.includes(`aria-label="${label}"`));
|
||||
}
|
||||
assert.ok(html.includes('role="combobox"'));
|
||||
assert.ok(html.includes("Dirección de correo"));
|
||||
assert.ok(html.includes("Buzón"));
|
||||
assert.equal(html.includes("Cuenta informática"), false);
|
||||
assert.ok(html.includes("IMAP"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve esta configuración?") < html.indexOf("Guardar"));
|
||||
assert.ok(html.includes("varias cuentas por persona"));
|
||||
assert.ok(html.includes("varios buzones por persona"));
|
||||
assert.equal(html.includes('type="password"'), false);
|
||||
});
|
||||
|
||||
test("Buzones: alta en modal, cuenta opcional y ayuda al final", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const page = await render(language, <MailboxesPage company={company} csrfToken="test" />);
|
||||
assert.equal(page.includes("<form"), false);
|
||||
assert.ok(page.includes(language === "es" ? "Nuevo buzón" : "New mailbox"));
|
||||
}
|
||||
const form = await render(
|
||||
"es",
|
||||
<MailboxForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
assert.ok(form.includes("Cuenta informática relacionada (opcional)"));
|
||||
assert.ok(form.indexOf("¿Qué es un buzón de correo?") < form.indexOf("Guardar"));
|
||||
assert.equal(form.includes('type="password"'), false);
|
||||
});
|
||||
|
||||
35
modules/companies/src/web/mail/mailbox-api.ts
Normal file
35
modules/companies/src/web/mail/mailbox-api.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type MailboxInput,
|
||||
type MailboxUseInput,
|
||||
MailboxesResponseSchema,
|
||||
} from "../../common/mailboxes";
|
||||
|
||||
import { MailApiError } from "./mail-api";
|
||||
export const mailboxKeys = { company: (id: string) => ["mailboxes", id] };
|
||||
export const mailboxApi = {
|
||||
async request(
|
||||
companyId: string,
|
||||
values?: MailboxInput | MailboxUseInput,
|
||||
token?: string,
|
||||
id?: string
|
||||
) {
|
||||
const suffix = values && "mailboxId" in values ? "/uses" : "";
|
||||
const response = await fetch(
|
||||
`/api/mailboxes/${companyId}${suffix}${id ? `/${id}/update` : ""}`,
|
||||
{
|
||||
method: values ? "POST" : "GET",
|
||||
credentials: "same-origin",
|
||||
headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
|
||||
...(values ? { body: JSON.stringify({ values }) } : {}),
|
||||
}
|
||||
);
|
||||
const json: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsed = z.object({ code: z.string() }).safeParse(json);
|
||||
throw new MailApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return MailboxesResponseSchema.parse(json);
|
||||
},
|
||||
};
|
||||
193
modules/companies/src/web/mail/mailbox-form.tsx
Normal file
193
modules/companies/src/web/mail/mailbox-form.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { type MailboxDTO, type MailboxInput, MailboxInputSchema } from "../../common/mailboxes";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { ConfigurationQuickEditor } from "../components/configuration-quick-editor";
|
||||
|
||||
import { mailKeys } from "./mail-api";
|
||||
import { MailFeedback } from "./mail-feedback";
|
||||
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
||||
|
||||
export function MailboxForm({
|
||||
company,
|
||||
csrfToken,
|
||||
mailbox,
|
||||
onSaved,
|
||||
allowQuickCreate = true,
|
||||
initialAddress = "",
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
mailbox?: MailboxDTO | undefined;
|
||||
onSaved: (id: string) => void;
|
||||
allowQuickCreate?: boolean;
|
||||
initialAddress?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const [quick, setQuick] = useState(false);
|
||||
const access = useQuery({
|
||||
queryKey: accessKeys.scope(company.id),
|
||||
queryFn: () => accessApi.request(company.id),
|
||||
});
|
||||
const form = useForm<MailboxInput>({
|
||||
resolver: zodResolver(MailboxInputSchema),
|
||||
defaultValues: mailbox?.values ?? {
|
||||
address: initialAddress,
|
||||
kind: "corporate",
|
||||
context: null,
|
||||
accountId: null,
|
||||
active: true,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: MailboxInput) =>
|
||||
mailboxApi.request(company.id, input, csrfToken, mailbox?.id),
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }),
|
||||
client.invalidateQueries({ queryKey: mailKeys.company(company.id) }),
|
||||
]);
|
||||
if (mounted.current && result.savedId) {
|
||||
onSaved(result.savedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("mail.address")}</Label>
|
||||
<Input
|
||||
id={prefix}
|
||||
maxLength={254}
|
||||
required
|
||||
type="email"
|
||||
{...form.register("address")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-kind`}>{t("mailbox.kind")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={!!mailbox}
|
||||
id={`${prefix}-kind`}
|
||||
label={t("mailbox.kind")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = MailboxInputSchema.shape.kind.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("kind", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={MailboxInputSchema.shape.kind.options.map((kind) => ({
|
||||
value: kind,
|
||||
label: t(`mailbox.${kind}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
value={values.kind}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-context`}>{t("mailbox.context")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-context`}
|
||||
maxLength={255}
|
||||
onChange={(event) => form.setValue("context", event.target.value || null)}
|
||||
value={values.context ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-account`}>{t("mailbox.account")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
allowQuickCreate
|
||||
? { label: t("mail.newAccount"), onClick: () => setQuick(true) }
|
||||
: undefined
|
||||
}
|
||||
disabled={!access.data}
|
||||
id={`${prefix}-account`}
|
||||
label={t("mailbox.account")}
|
||||
onValueChange={(id) => form.setValue("accountId", id || null)}
|
||||
options={(access.data?.items ?? [])
|
||||
.filter((item) => item.values.kind === "account")
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: accessLabel(item, access.data?.items ?? [], company.people),
|
||||
}))}
|
||||
placeholder={t("mailbox.noAccount")}
|
||||
value={values.accountId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("active")} />
|
||||
{t("mailbox.active")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
onChange={(event) => form.setValue("notes", event.target.value || null)}
|
||||
value={values.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p role="alert">{t("mail.errors.INVALID_REQUEST")}</p>
|
||||
)}
|
||||
<MailFeedback error={mutation.error ?? access.error} />
|
||||
<ContextHelp
|
||||
description={t("mailbox.description")}
|
||||
examples={t("mailbox.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("mailbox.guidance")}
|
||||
title={t("mailbox.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending || !access.data} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<ConfigurationQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
data={access.data}
|
||||
kind="account"
|
||||
onClose={() => setQuick(false)}
|
||||
onCreated={(_field, id) => {
|
||||
if (mounted.current && id) {
|
||||
form.setValue("accountId", id);
|
||||
setQuick(false);
|
||||
}
|
||||
}}
|
||||
values={{ personId: "", accountId: "" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
modules/companies/src/web/mail/mailbox-i18n.ts
Normal file
74
modules/companies/src/web/mail/mailbox-i18n.ts
Normal file
@ -0,0 +1,74 @@
|
||||
export const mailboxSpanish = {
|
||||
title: "Buzones de correo",
|
||||
titleSingle: "Buzón",
|
||||
new: "Nuevo buzón",
|
||||
edit: "Editar buzón",
|
||||
empty: "Todavía no hay buzones registrados.",
|
||||
search: "Buscar dirección o contexto",
|
||||
kind: "Tipo de buzón",
|
||||
corporate: "Corporativo",
|
||||
external: "Externo",
|
||||
context: "Contexto (opcional)",
|
||||
account: "Cuenta informática relacionada (opcional)",
|
||||
noAccount: "Sin cuenta relacionada",
|
||||
active: "Buzón activo",
|
||||
linkPerson: "Vincular persona",
|
||||
authorize: "Registrar autorización de uso",
|
||||
recordUse: "Registrar uso de correo externo",
|
||||
useMissing: "Registra primero el periodo de uso de este buzón por la persona del perfil.",
|
||||
start: "Desde",
|
||||
end: "Hasta (opcional; día de fin excluido)",
|
||||
openPeriod: "Sin fecha de fin",
|
||||
closeUse: "Finalizar uso",
|
||||
help: "¿Qué es un buzón de correo?",
|
||||
description:
|
||||
"Un buzón identifica una dirección de correo. Puede utilizarlo más de una persona y configurarse en varios perfiles.",
|
||||
guidance:
|
||||
"El dominio no determina a qué compañía pertenece el buzón. El correo externo puede ser personal, de un cliente, proveedor o colaboración. Relacionar una cuenta informática es opcional y no concede acceso al buzón. El tipo queda fijo desde el alta.",
|
||||
examples:
|
||||
"ana@empresa.es; administracion@otraempresa.es; correo personal utilizado en el portátil. No introduzcas contraseñas.",
|
||||
useHelp: "¿Para qué sirve esta vinculación?",
|
||||
useDescription:
|
||||
"Para buzones corporativos registra la autorización documentada. Para buzones externos registra su uso por la persona, sin afirmar que la compañía es su propietaria.",
|
||||
useGuidance:
|
||||
"El inicio está incluido y el día de fin está excluido. Finalizar conserva el historial y señala para revisión los correos que aún estén configurados. No retira el correo de los equipos automáticamente.",
|
||||
useExamples:
|
||||
"Ana utiliza administracion@empresa.es desde su incorporación; Ana deja de utilizarlo al cambiar de departamento.",
|
||||
};
|
||||
export const mailboxEnglish = {
|
||||
title: "Mailboxes",
|
||||
titleSingle: "Mailbox",
|
||||
new: "New mailbox",
|
||||
edit: "Edit mailbox",
|
||||
empty: "No mailboxes recorded yet.",
|
||||
search: "Search address or context",
|
||||
kind: "Mailbox type",
|
||||
corporate: "Corporate",
|
||||
external: "External",
|
||||
context: "Context (optional)",
|
||||
account: "Related IT account (optional)",
|
||||
noAccount: "No related account",
|
||||
active: "Active mailbox",
|
||||
linkPerson: "Link person",
|
||||
authorize: "Record usage authorization",
|
||||
recordUse: "Record external email usage",
|
||||
useMissing: "First record this mailbox's usage period for the profile's person.",
|
||||
start: "From",
|
||||
end: "Until (optional; end day excluded)",
|
||||
openPeriod: "No end date",
|
||||
closeUse: "End usage",
|
||||
help: "What is a mailbox?",
|
||||
description:
|
||||
"A mailbox identifies an email address. Several people can use it and it can be configured in several profiles.",
|
||||
guidance:
|
||||
"The domain does not determine company ownership. External email may be personal or belong to a client, supplier or collaboration. Linking an IT account is optional and does not grant mailbox access. Type is fixed after creation.",
|
||||
examples:
|
||||
"ana@company.com; administration@anothercompany.com; personal email used on a laptop. Do not enter passwords.",
|
||||
useHelp: "What is this link for?",
|
||||
useDescription:
|
||||
"For corporate mailboxes, record documented authorization. For external mailboxes, record personal usage without asserting company ownership.",
|
||||
useGuidance:
|
||||
"The start day is included and the end day excluded. Ending usage preserves history and flags outstanding configurations for review. It does not remove email from devices automatically.",
|
||||
useExamples:
|
||||
"Ana uses administration@company.com from her start date; she stops using it when changing departments.",
|
||||
};
|
||||
230
modules/companies/src/web/mail/mailbox-use-form.tsx
Normal file
230
modules/companies/src/web/mail/mailbox-use-form.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import {
|
||||
type MailboxUseDTO,
|
||||
type MailboxUseInput,
|
||||
MailboxUseInputSchema,
|
||||
} from "../../common/mailboxes";
|
||||
import { PersonForm } from "../components/person-form";
|
||||
|
||||
import { mailKeys } from "./mail-api";
|
||||
import { MailFeedback } from "./mail-feedback";
|
||||
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
||||
import { MailboxForm } from "./mailbox-form";
|
||||
|
||||
export function MailboxUseForm({
|
||||
company,
|
||||
csrfToken,
|
||||
use,
|
||||
initialMailboxId = "",
|
||||
initialPersonId = "",
|
||||
onSaved,
|
||||
allowQuickCreate = true,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
use?: MailboxUseDTO | undefined;
|
||||
initialMailboxId?: string;
|
||||
initialPersonId?: string;
|
||||
onSaved: () => void;
|
||||
allowQuickCreate?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const [quick, setQuick] = useState<"person" | "mailbox">();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const query = useQuery({
|
||||
queryKey: mailboxKeys.company(company.id),
|
||||
queryFn: () => mailboxApi.request(company.id),
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const form = useForm<MailboxUseInput>({
|
||||
resolver: zodResolver(MailboxUseInputSchema),
|
||||
defaultValues: useDefaults(use, initialMailboxId, initialPersonId, today),
|
||||
});
|
||||
const values = form.watch();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: MailboxUseInput) =>
|
||||
mailboxApi.request(company.id, input, csrfToken, use?.id),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }),
|
||||
client.invalidateQueries({ queryKey: mailKeys.company(company.id) }),
|
||||
]);
|
||||
if (mounted.current) {
|
||||
onSaved();
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-mailbox`}>{t("mailbox.titleSingle")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
canCreate(!!use, initialMailboxId, allowQuickCreate)
|
||||
? { label: t("mailbox.new"), onClick: () => setQuick("mailbox") }
|
||||
: undefined
|
||||
}
|
||||
disabled={!!use || !!initialMailboxId}
|
||||
id={`${prefix}-mailbox`}
|
||||
label={t("mailbox.titleSingle")}
|
||||
onValueChange={(id) => form.setValue("mailboxId", id)}
|
||||
options={(query.data?.mailboxes ?? [])
|
||||
.filter((item) => item.values.active || item.id === values.mailboxId)
|
||||
.map((item) => ({ value: item.id, label: item.values.address }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.mailboxId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("mail.person")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
canCreate(!!use, initialPersonId, allowQuickCreate)
|
||||
? { label: t("newPerson"), onClick: () => setQuick("person") }
|
||||
: undefined
|
||||
}
|
||||
disabled={!!use || !!initialPersonId}
|
||||
id={`${prefix}-person`}
|
||||
label={t("mail.person")}
|
||||
onValueChange={(id) => form.setValue("personId", id)}
|
||||
options={company.people
|
||||
.filter((item) => item.active || item.id === values.personId)
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.personId}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-start`}>{t("mailbox.start")}</Label>
|
||||
<Input
|
||||
disabled={!!use}
|
||||
id={`${prefix}-start`}
|
||||
required
|
||||
type="date"
|
||||
{...form.register("startDate")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-end`}>{t("mailbox.end")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
onChange={(event) => form.setValue("endDate", event.target.value || null)}
|
||||
required={!!use}
|
||||
type="date"
|
||||
value={values.endDate ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p role="alert">{t("mail.errors.INVALID_REQUEST")}</p>
|
||||
)}
|
||||
<MailFeedback error={mutation.error ?? query.error} />
|
||||
<ContextHelp
|
||||
description={t("mailbox.useDescription")}
|
||||
examples={t("mailbox.useExamples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("mailbox.useGuidance")}
|
||||
title={t("mailbox.useHelp")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending || !query.data} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick === "person" && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("newPerson")}
|
||||
>
|
||||
<PersonForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("personId", id);
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
requireActive
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{quick === "mailbox" && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("mailbox.new")}
|
||||
>
|
||||
<MailboxForm
|
||||
allowQuickCreate={false}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("mailboxId", id);
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useDefaults(
|
||||
existing: MailboxUseDTO | undefined,
|
||||
mailboxId: string,
|
||||
personId: string,
|
||||
today: string
|
||||
): MailboxUseInput {
|
||||
if (existing) {
|
||||
const values = existing.values;
|
||||
return { ...values, endDate: today > values.startDate ? today : values.startDate };
|
||||
}
|
||||
return { mailboxId, personId, startDate: today, endDate: null };
|
||||
}
|
||||
|
||||
function canCreate(editing: boolean, initial: string, allowed: boolean) {
|
||||
return !(editing || initial) && allowed;
|
||||
}
|
||||
207
modules/companies/src/web/mail/mailboxes-page.tsx
Normal file
207
modules/companies/src/web/mail/mailboxes-page.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
|
||||
import { MailFeedback } from "./mail-feedback";
|
||||
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
||||
import { MailboxForm } from "./mailbox-form";
|
||||
import { MailboxUseForm } from "./mailbox-use-form";
|
||||
export function MailboxesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [useEditor, setUseEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useQuery({
|
||||
queryKey: mailboxKeys.company(company.id),
|
||||
queryFn: () => mailboxApi.request(company.id),
|
||||
});
|
||||
const edited = query.data?.mailboxes.find((item) => item.id === editor);
|
||||
const use = query.data?.uses.find((item) => item.id === useEditor);
|
||||
const viewed = query.data?.mailboxes.find((item) => item.id === view);
|
||||
const name = (id: string) => company.people.find((person) => person.id === id)?.name ?? id;
|
||||
const boxes = query.data?.mailboxes ?? [];
|
||||
const filtered = boxes.filter((item) =>
|
||||
[item.values.address, item.values.context ?? ""].some((value) =>
|
||||
value.toLowerCase().includes(search.trim().toLowerCase())
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap justify-between items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("mailbox.title")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={!query.data} onClick={() => setUseEditor("new")} variant="outline">
|
||||
{t("mailbox.linkPerson")}
|
||||
</Button>
|
||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||
{t("mailbox.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("mailbox.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("mailbox.search")}
|
||||
value={search}
|
||||
/>
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<MailFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(boxes.length ? "noResults" : "mailbox.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="py-4 space-y-3" key={item.id}>
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{item.values.address}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`mailbox.${item.values.kind}`)} ·{" "}
|
||||
{t(item.values.active ? "active" : "inactive")}
|
||||
{item.values.context ? ` · ${item.values.context}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("mail.history")}
|
||||
</Button>
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{query.data?.uses
|
||||
.filter((use) => use.values.mailboxId === item.id)
|
||||
.map((use) => (
|
||||
<li className="flex justify-between gap-3 text-sm" key={use.id}>
|
||||
<span>
|
||||
{name(use.values.personId)} · {use.values.startDate} →{" "}
|
||||
{use.values.endDate ?? t("mailbox.openPeriod")}
|
||||
</span>
|
||||
{!use.values.endDate && (
|
||||
<Button onClick={() => setUseEditor(use.id)} size="sm" variant="outline">
|
||||
{t("mailbox.closeUse")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(edited ? "mailbox.edit" : "mailbox.new")}
|
||||
wide
|
||||
>
|
||||
<MailboxForm
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
mailbox={edited}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{useEditor && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setUseEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(use ? "mailbox.closeUse" : "mailbox.linkPerson")}
|
||||
wide
|
||||
>
|
||||
<MailboxUseForm
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={() => setUseEditor(undefined)}
|
||||
use={use}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{viewed && (
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setView(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("mail.history")}
|
||||
wide
|
||||
>
|
||||
<ol className="space-y-3">
|
||||
{[...viewed.history].reverse().map((revision) => (
|
||||
<li className="border rounded-md p-3 text-sm" key={revision.id}>
|
||||
<p>
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {revision.actorName}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.address} · {t(`mailbox.${revision.values.kind}`)} ·{" "}
|
||||
{t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<p>{revision.values.context}</p>
|
||||
<p>{revision.values.notes}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{query.data?.uses
|
||||
.filter((use) => use.values.mailboxId === viewed.id)
|
||||
.map((use) => (
|
||||
<div className="mt-4 rounded-md border p-3 text-sm" key={use.id}>
|
||||
<h3 className="font-medium">{name(use.values.personId)}</h3>
|
||||
{use.history.map((revision) => (
|
||||
<p key={revision.id}>
|
||||
{revision.values.startDate} →{" "}
|
||||
{revision.values.endDate ?? t("mailbox.openPeriod")} · {revision.actorName} ·{" "}
|
||||
{revision.recordedAt}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -26,10 +26,10 @@ export const profilesSpanish = {
|
||||
"Elige un ordenador u otro dispositivo que aloje la configuración de la persona. La cuenta de inicio de sesión es opcional y, si se indica, debe estar autorizada para ella. Un perfil vigente no significa que todas sus configuraciones estén listas.",
|
||||
examplesLabel: "Ejemplos y recomendaciones",
|
||||
examples:
|
||||
"Ana en PORT-ANA-01; Ana en su segundo ordenador; Luis en un ordenador compartido. Los correos, programas y recursos se incorporarán en las siguientes entregas.",
|
||||
"Ana en PORT-ANA-01; Ana en su segundo ordenador; Luis en un ordenador compartido. Cada perfil puede tener varios buzones de correo. Programas y recursos se incorporarán después.",
|
||||
scope:
|
||||
"Todos los elementos pertenecen a esta compañía. Archivar conserva los datos y el historial; no retira accesos ni borra configuraciones en el equipo.",
|
||||
unknownConfiguration: "Configuraciones del perfil: todavía no registradas.",
|
||||
unknownConfiguration: "Programas, carpetas, impresoras y certificados: pendientes de incorporar.",
|
||||
stations: "Puestos que utilizan este dispositivo",
|
||||
noStation: "Este dispositivo no está incluido en un puesto vigente.",
|
||||
accountHint:
|
||||
@ -74,10 +74,10 @@ export const profilesEnglish = {
|
||||
"Choose a computer or another device hosting the person's configuration. The sign-in account is optional and must be authorized for this person when supplied. A current profile does not mean all its configurations are ready.",
|
||||
examplesLabel: "Examples and guidance",
|
||||
examples:
|
||||
"Ana on PORT-ANA-01; Ana on a second computer; Luis on a shared computer. Email, applications and resources will be added in later deliveries.",
|
||||
"Ana on PORT-ANA-01; Ana on a second computer; Luis on a shared computer. Each profile can have several mailboxes. Applications and resources will be added later.",
|
||||
scope:
|
||||
"All elements belong to this company. Archiving preserves data and history; it does not revoke access or remove device configurations.",
|
||||
unknownConfiguration: "Profile configurations: not recorded yet.",
|
||||
unknownConfiguration: "Applications, folders, printers and certificates: pending implementation.",
|
||||
stations: "Workstations using this device",
|
||||
noStation: "This device is not included in a current workstation.",
|
||||
accountHint:
|
||||
|
||||
@ -33,11 +33,13 @@ export function WorkProfileForm({
|
||||
csrfToken,
|
||||
profile,
|
||||
onSaved,
|
||||
allowQuickCreate = true,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
profile?: WorkProfileDTO | undefined;
|
||||
onSaved: () => void;
|
||||
onSaved: (id: string) => void;
|
||||
allowQuickCreate?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const client = useQueryClient();
|
||||
@ -88,10 +90,10 @@ export function WorkProfileForm({
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: WorkProfileInput) =>
|
||||
profileApi.save(company.id, input, csrfToken, profile?.id),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (result) => {
|
||||
await client.invalidateQueries({ queryKey: profileKeys.company(company.id) });
|
||||
if (mounted.current) {
|
||||
onSaved();
|
||||
onSaved(result.profile.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -148,7 +150,7 @@ export function WorkProfileForm({
|
||||
</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed
|
||||
fixed || !allowQuickCreate
|
||||
? undefined
|
||||
: {
|
||||
label: t(`mail.${selector.create}`),
|
||||
@ -176,7 +178,7 @@ export function WorkProfileForm({
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("profiles.accountHint")}</p>
|
||||
{values.personId && values.accountId && !authorized && (
|
||||
{values.personId && values.accountId && !authorized && allowQuickCreate && (
|
||||
<div className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<p>{t("mail.noAuthorization")}</p>
|
||||
<Button onClick={() => setQuick("authorization")} type="button" variant="outline">
|
||||
|
||||
@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
|
||||
import { ProfileFeedback } from "./profile-feedback";
|
||||
import { profileApi, profileKeys } from "./work-profile-api";
|
||||
@ -23,6 +24,7 @@ export function WorkProfilesPage({
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [mailProfile, setMailProfile] = useState<string>();
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
@ -58,7 +60,21 @@ export function WorkProfilesPage({
|
||||
value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
const ready = !!(query.data && inventory.data && access.data);
|
||||
const ready = [query.data, inventory.data, access.data].every(Boolean);
|
||||
if (mailProfile) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button onClick={() => setMailProfile(undefined)} variant="outline">
|
||||
{t("profiles.title")}
|
||||
</Button>
|
||||
<MailConfigurationsPage
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialProfileId={mailProfile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
@ -121,6 +137,9 @@ export function WorkProfilesPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setMailProfile(item.id)} size="sm" variant="outline">
|
||||
{t("mail.title")}
|
||||
</Button>
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("profiles.history")}
|
||||
</Button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user