feat(mail): implement mail configuration management with persistence and UI components
- Added migration scripts for mail configurations and revisions. - Created persistence mapper for mail configurations and revisions. - Developed Sequelize repository for mail configuration management. - Introduced common schemas and types for mail configurations. - Implemented API for fetching and saving mail configurations. - Built React components for mail configuration forms and pages. - Added internationalization support for mail-related texts in English and Spanish. - Implemented feedback mechanism for API errors in mail components. - Created tests for mail configuration components and functionality.
This commit is contained in:
parent
867d5ec9c1
commit
e099b8c507
@ -23,6 +23,7 @@ import { verifyAssets } from "./assets.integration";
|
|||||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||||
import { verifyInventory } from "./inventory.integration";
|
import { verifyInventory } from "./inventory.integration";
|
||||||
|
import { verifyMail } from "./mail.integration";
|
||||||
import { verifyRelationships } from "./relationships.integration";
|
import { verifyRelationships } from "./relationships.integration";
|
||||||
import { verifyWorkstations } from "./workstations.integration";
|
import { verifyWorkstations } from "./workstations.integration";
|
||||||
|
|
||||||
@ -37,6 +38,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
|||||||
await once(server, "listening");
|
await once(server, "listening");
|
||||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||||
await database.query("DELETE FROM http_sessions");
|
await database.query("DELETE FROM http_sessions");
|
||||||
|
await database.query("DELETE FROM mail_configuration_revisions");
|
||||||
|
await database.query("DELETE FROM mail_configurations");
|
||||||
await database.query("DELETE FROM workstation_assets");
|
await database.query("DELETE FROM workstation_assets");
|
||||||
await database.query("DELETE FROM workstation_revisions");
|
await database.query("DELETE FROM workstation_revisions");
|
||||||
await database.query("DELETE FROM workstations");
|
await database.query("DELETE FROM workstations");
|
||||||
@ -711,6 +714,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
|||||||
secondCompany.id,
|
secondCompany.id,
|
||||||
database
|
database
|
||||||
);
|
);
|
||||||
|
await verifyMail(
|
||||||
|
(...args) => operator.request(...args),
|
||||||
|
customer.id,
|
||||||
|
secondCompany.id,
|
||||||
|
database
|
||||||
|
);
|
||||||
const countAfterAccess = await database.query<{ total: number }>(
|
const countAfterAccess = await database.query<{ total: number }>(
|
||||||
"SELECT COUNT(*) AS total FROM users",
|
"SELECT COUNT(*) AS total FROM users",
|
||||||
{ type: QueryTypes.SELECT }
|
{ type: QueryTypes.SELECT }
|
||||||
@ -730,6 +739,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
|||||||
401,
|
401,
|
||||||
"UNAUTHENTICATED"
|
"UNAUTHENTICATED"
|
||||||
);
|
);
|
||||||
|
await expectError(
|
||||||
|
await operator.request(`/mail-configurations/${customer.id}`),
|
||||||
|
401,
|
||||||
|
"UNAUTHENTICATED"
|
||||||
|
);
|
||||||
|
await expectError(
|
||||||
|
await operator.request(`/mail-configurations/${customer.id}`, {}),
|
||||||
|
401,
|
||||||
|
"UNAUTHENTICATED"
|
||||||
|
);
|
||||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export async function createIdentityRuntime(
|
|||||||
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
|
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
|
||||||
const companies = await createCompanyModule(database, identity.authenticate);
|
const companies = await createCompanyModule(database, identity.authenticate);
|
||||||
const assets = await createAssetModule(database, identity.authenticate);
|
const assets = await createAssetModule(database, identity.authenticate);
|
||||||
|
const mail = await companies.initializeMail(assets.services.inventory);
|
||||||
const SequelizeStore = connectSequelize(session.Store);
|
const SequelizeStore = connectSequelize(session.Store);
|
||||||
const store = new SequelizeStore({
|
const store = new SequelizeStore({
|
||||||
db: database,
|
db: database,
|
||||||
@ -40,6 +41,7 @@ export async function createIdentityRuntime(
|
|||||||
identity.register(app);
|
identity.register(app);
|
||||||
companies.register(app);
|
companies.register(app);
|
||||||
assets.register(app);
|
assets.register(app);
|
||||||
|
mail.register(app);
|
||||||
},
|
},
|
||||||
middleware,
|
middleware,
|
||||||
stop: () => store.stopExpiringSessions(),
|
stop: () => store.stopExpiringSessions(),
|
||||||
|
|||||||
223
apps/server/src/mail.integration.ts
Normal file
223
apps/server/src/mail.integration.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { InventoryResponseSchema } from "@metis/assets/common";
|
||||||
|
import {
|
||||||
|
type AccessFieldsDTO,
|
||||||
|
AccessResponseSchema,
|
||||||
|
CompanyResponseSchema,
|
||||||
|
type MailConfigurationInput,
|
||||||
|
MailConfigurationResponseSchema,
|
||||||
|
MailConfigurationsResponseSchema,
|
||||||
|
} from "@metis/companies/common";
|
||||||
|
import type { Sequelize } from "sequelize";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||||
|
export async function verifyMail(
|
||||||
|
request: Request,
|
||||||
|
companyId: string,
|
||||||
|
otherId: string,
|
||||||
|
database: Sequelize
|
||||||
|
) {
|
||||||
|
const path = `/mail-configurations/${companyId}`;
|
||||||
|
const accessPath = `/access/${companyId}`;
|
||||||
|
const company = CompanyResponseSchema.parse(
|
||||||
|
await (await request(`/companies/${companyId}`)).json()
|
||||||
|
).company;
|
||||||
|
const other = CompanyResponseSchema.parse(
|
||||||
|
await (await request(`/companies/${otherId}`)).json()
|
||||||
|
).company;
|
||||||
|
const foreignAsset = InventoryResponseSchema.parse(
|
||||||
|
await (await request(`/assets/${otherId}`)).json()
|
||||||
|
).assets[0];
|
||||||
|
assert.ok(foreignAsset);
|
||||||
|
const inventory = InventoryResponseSchema.parse(
|
||||||
|
await (await request(`/assets/${companyId}`)).json()
|
||||||
|
);
|
||||||
|
const assets = inventory.assets;
|
||||||
|
for (const item of assets.slice(0, 2)) {
|
||||||
|
if (item.values.status === "retired") {
|
||||||
|
const response = await request(`/assets/${companyId}/${item.id}/update`, {
|
||||||
|
values: { ...item.values, status: "available" },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200, await response.clone().text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const first = assets[0];
|
||||||
|
const second = assets[1];
|
||||||
|
const foreignPerson = other.people[0];
|
||||||
|
assert.ok(first && second && foreignPerson);
|
||||||
|
const personFields = {
|
||||||
|
name: "Persona correo",
|
||||||
|
position: null,
|
||||||
|
email: null,
|
||||||
|
phone: null,
|
||||||
|
active: true,
|
||||||
|
isContact: false,
|
||||||
|
};
|
||||||
|
const peopleResponse = await request(`/companies/${companyId}/people`, personFields);
|
||||||
|
assert.equal(peopleResponse.status, 201);
|
||||||
|
const person = CompanyResponseSchema.parse(await peopleResponse.json()).company.people.at(-1);
|
||||||
|
assert.ok(person);
|
||||||
|
const otherPerson = company.people.find((item) => item.active);
|
||||||
|
assert.ok(otherPerson);
|
||||||
|
const createAccess = async (values: AccessFieldsDTO) => {
|
||||||
|
const response = await request(accessPath, { values });
|
||||||
|
assert.equal(response.status, 201, await response.clone().text());
|
||||||
|
const item = AccessResponseSchema.parse(await response.json()).items.at(-1);
|
||||||
|
assert.ok(item);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
const system = await createAccess({ kind: "system", name: "Correo pruebas", active: true });
|
||||||
|
const accountFields = {
|
||||||
|
kind: "account" as const,
|
||||||
|
systemId: system.id,
|
||||||
|
environmentId: null,
|
||||||
|
usage: "shared" as const,
|
||||||
|
active: true,
|
||||||
|
expiresOn: null,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
const account = await createAccess({ ...accountFields, login: "ventas@example.test" });
|
||||||
|
const anotherAccount = await createAccess({
|
||||||
|
...accountFields,
|
||||||
|
login: "ana@example.test",
|
||||||
|
usage: "personal",
|
||||||
|
});
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const authorization = {
|
||||||
|
kind: "account_person" as const,
|
||||||
|
personId: person.id,
|
||||||
|
startDate: today,
|
||||||
|
endDate: null,
|
||||||
|
configuration: "pending" as const,
|
||||||
|
};
|
||||||
|
const relation = await createAccess({ ...authorization, accountId: account.id });
|
||||||
|
await createAccess({ ...authorization, accountId: anotherAccount.id });
|
||||||
|
await createAccess({ ...authorization, personId: otherPerson.id, accountId: account.id });
|
||||||
|
const input: MailConfigurationInput = {
|
||||||
|
personId: person.id,
|
||||||
|
accountId: account.id,
|
||||||
|
assetId: first.id,
|
||||||
|
address: "ventas@example.test",
|
||||||
|
method: "imap",
|
||||||
|
state: "configured",
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
const save = async (values: MailConfigurationInput, id?: string) => {
|
||||||
|
const response = await request(id ? `${path}/${id}/update` : path, { values });
|
||||||
|
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
|
||||||
|
return MailConfigurationResponseSchema.parse(await response.json()).configuration;
|
||||||
|
};
|
||||||
|
const error = async (values: unknown, code: string, id?: string, status = 409) => {
|
||||||
|
const response = await request(id ? `${path}/${id}/update` : path, { values });
|
||||||
|
assert.equal(response.status, status, await response.clone().text());
|
||||||
|
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||||
|
};
|
||||||
|
await error({ ...input, personId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH");
|
||||||
|
await error({ ...input, accountId: system.id }, "MAIL_SCOPE_MISMATCH");
|
||||||
|
await error({ ...input, assetId: foreignAsset.id }, "MAIL_SCOPE_MISMATCH");
|
||||||
|
await error({ ...input, personId: other.people[0]?.id }, "MAIL_SCOPE_MISMATCH");
|
||||||
|
await error({ ...input, address: "invalid" }, "INVALID_REQUEST", undefined, 400);
|
||||||
|
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
|
||||||
|
await error({ ...input, state: "removed" }, "INVALID_MAIL_TRANSITION");
|
||||||
|
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
|
||||||
|
assert.equal((await request(path, { values: input }, undefined, "bad")).status, 403);
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
request(path, { values: input }),
|
||||||
|
request(path, { values: input }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
|
||||||
|
const created = MailConfigurationResponseSchema.parse(
|
||||||
|
await (a.status === 201 ? a : b).json()
|
||||||
|
).configuration;
|
||||||
|
assert.equal(created.id[14], "7");
|
||||||
|
assert.equal(created.history[0]?.id[14], "7");
|
||||||
|
assert.ok(created.history[0]?.actorName);
|
||||||
|
assert.equal(created.needsReview, false);
|
||||||
|
assert.deepEqual(await save(input, created.id), created);
|
||||||
|
const personal = await save({
|
||||||
|
...input,
|
||||||
|
accountId: anotherAccount.id,
|
||||||
|
address: "ana@example.test",
|
||||||
|
state: "pending",
|
||||||
|
});
|
||||||
|
const mobile = await save({ ...input, assetId: second.id, method: "exchange" });
|
||||||
|
const shared = await save({
|
||||||
|
...input,
|
||||||
|
personId: otherPerson.id,
|
||||||
|
state: "pending",
|
||||||
|
method: "pop",
|
||||||
|
});
|
||||||
|
assert.notEqual(mobile.id, created.id);
|
||||||
|
assert.notEqual(personal.id, created.id);
|
||||||
|
assert.notEqual(shared.id, created.id);
|
||||||
|
await error({ ...input, assetId: second.id }, "MAIL_TARGET_IMMUTABLE", created.id);
|
||||||
|
await error({ ...input, state: "removed" }, "INVALID_MAIL_TRANSITION", created.id);
|
||||||
|
const foreignUpdate = await request(`/mail-configurations/${otherId}/${created.id}/update`, {
|
||||||
|
values: input,
|
||||||
|
});
|
||||||
|
assert.equal(foreignUpdate.status, 404);
|
||||||
|
const closedAccess = await request(`${accessPath}/${relation.id}/update`, {
|
||||||
|
values: {
|
||||||
|
...authorization,
|
||||||
|
accountId: account.id,
|
||||||
|
endDate: today,
|
||||||
|
configuration: "never_configured",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(closedAccess.status, 200);
|
||||||
|
const reviewed = MailConfigurationsResponseSchema.parse(
|
||||||
|
await (await request(path)).json()
|
||||||
|
).configurations;
|
||||||
|
assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true);
|
||||||
|
assert.equal(reviewed.find((item) => item.id === personal.id)?.needsReview, false);
|
||||||
|
await error({ ...input, method: "pop" }, "MAIL_NOT_AUTHORIZED", created.id);
|
||||||
|
const pending = await save({ ...input, state: "removal_pending" }, created.id);
|
||||||
|
const removed = await save({ ...input, state: "removed" }, created.id);
|
||||||
|
assert.equal(pending.history.length, 2);
|
||||||
|
assert.equal(removed.history.length, 3);
|
||||||
|
assert.equal(removed.history[0]?.values.state, "configured");
|
||||||
|
assert.equal(removed.needsReview, false);
|
||||||
|
await error(input, "INVALID_MAIL_TRANSITION", removed.id);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await request(`/companies/${companyId}/people/${person.id}/update`, {
|
||||||
|
...personFields,
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const disabledPerson = MailConfigurationsResponseSchema.parse(
|
||||||
|
await (await request(path)).json()
|
||||||
|
).configurations;
|
||||||
|
assert.equal(disabledPerson.find((item) => item.id === mobile.id)?.needsReview, true);
|
||||||
|
const never = await save({ ...personal.values, state: "never_configured" }, personal.id);
|
||||||
|
assert.equal(never.history.length, 2);
|
||||||
|
// No borrar configuraciones ni sus revisiones al terminar un permiso.
|
||||||
|
const reloaded = MailConfigurationsResponseSchema.parse(
|
||||||
|
await (await request(path)).json()
|
||||||
|
).configurations;
|
||||||
|
assert.equal(reloaded.length, 4);
|
||||||
|
assert.equal(reloaded.find((item) => item.id === removed.id)?.history.length, 3);
|
||||||
|
await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", {
|
||||||
|
replacements: { id: mobile.id },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.equal((await request(path)).status, 500);
|
||||||
|
} finally {
|
||||||
|
await database.query("UPDATE mail_configurations SET currentKey=:key WHERE id=:id", {
|
||||||
|
replacements: {
|
||||||
|
id: mobile.id,
|
||||||
|
key: [mobile.values.personId, mobile.values.accountId, mobile.values.assetId].join(":"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
MailConfigurationsResponseSchema.parse(
|
||||||
|
await (await request(`/mail-configurations/${otherId}`)).json()
|
||||||
|
).configurations.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -53,6 +53,8 @@ AssetRelationship es la raíz de un vínculo entre dos activos de una compañía
|
|||||||
|
|
||||||
Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md).
|
Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md).
|
||||||
|
|
||||||
|
MailConfiguration es el agregado de un correo configurado en un dispositivo; MailConfigurationRevision conserva cada instantánea con UUID propio. MailConfigurationValues encapsula referencias UniqueID y dirección EmailAddress. Consultar [Correos en dispositivos](../correos-en-dispositivos.md).
|
||||||
|
|
||||||
Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.
|
Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.
|
||||||
|
|
||||||
Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización.
|
Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización.
|
||||||
|
|||||||
44
docs/correos-en-dispositivos.md
Normal file
44
docs/correos-en-dispositivos.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Correos en dispositivos
|
||||||
|
|
||||||
|
Cada compañía dispone de una sección «Correos en dispositivos». Un registro relaciona una persona, una cuenta informática y un activo del inventario, con dirección de correo, método (POP, IMAP, Exchange u otro), estado y notas opcionales. Una persona puede tener varias cuentas; una cuenta puede estar en varios dispositivos, con métodos distintos. Las cuentas compartidas admiten varias personas autorizadas.
|
||||||
|
|
||||||
|
La dirección se registra explícitamente porque el identificador de acceso de una cuenta puede ser un nombre de usuario. No se deduce que un sistema sea de correo por su nombre. La configuración no crea permisos sobre la cuenta ni modifica su titularidad. Las cuentas técnicas se excluyen.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
«Configurar correo» abre un modal desde la cabecera. Los selectores reutilizables ofrecen «+» para crear personas, cuentas y dispositivos sin perder lo rellenado. Guardar selecciona el nuevo elemento; cancelar conserva los datos del formulario principal. Los elementos creados permanecen si después se cancela el correo. Las altas relacionadas utilizan opciones existentes y no abren un tercer modal.
|
||||||
|
|
||||||
|
Si falta una autorización vigente de la persona sobre la cuenta, se puede registrar desde el mismo formulario. La ayuda y los ejemplos aparecen inmediatamente antes de Guardar y Cancelar. El listado permite buscar por dirección, persona o dispositivo, editar y consultar el historial.
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
Persona, cuenta y dispositivo pertenecen a la misma compañía. Para crear o modificar una configuración pendiente o configurada se exige persona activa, cuenta personal o compartida activa y no caducada, sistema y entorno activos, dispositivo no retirado y una relación cuenta-persona vigente. La fecha de fin de esa relación es exclusiva, siguiendo los periodos de accesos.
|
||||||
|
|
||||||
|
El estado técnico de la autorización general no sustituye el estado de cada dispositivo: una autorización pendiente de configurar puede justificar la preparación del correo.
|
||||||
|
|
||||||
|
Solo existe una configuración abierta por persona, cuenta y dispositivo. La exclusividad se protege con bloqueo de compañía y clave única en MariaDB; no depende del navegador. La dirección, método y notas se pueden actualizar, conservando revisiones. Persona, cuenta y dispositivo quedan fijados desde el alta.
|
||||||
|
|
||||||
|
Estados:
|
||||||
|
- Pendiente de configurar → Configurado o No llegó a configurarse.
|
||||||
|
- Configurado → Pendiente de retirar.
|
||||||
|
- Pendiente de retirar → Retirado.
|
||||||
|
- Retirado y No llegó a configurarse son terminales. Una nueva configuración usa otro UUID.
|
||||||
|
|
||||||
|
No se borran registros. Un cambio equivalente no añade una revisión. Cada cambio real conserva fecha, usuario autenticado y valores anteriores con UUID v7 propio.
|
||||||
|
|
||||||
|
Cerrar una autorización, cambiar el titular, desactivar una persona/cuenta/sistema/entorno, caducar una cuenta o retirar un activo conserva las configuraciones. La sección calcula un aviso de revisión para las que siguen abiertas y carecen de autorización vigente. La retirada sigue permitida aunque las referencias estén inactivas. El aviso no afirma que un acceso haya sido revocado físicamente: Metis Desk registra el trabajo y su comprobación.
|
||||||
|
|
||||||
|
## Arquitectura y API
|
||||||
|
|
||||||
|
MailConfiguration es un agregado y MailConfigurationRevision una entidad interna. MailConfigurationValues encapsula referencias UniqueID, EmailAddress y valores inmutables. La persistencia valida referencias, identidad, auditoría, secuencia y última revisión antes de rehidratar. Las migraciones idempotentes crean mail_configurations y mail_configuration_revisions con claves foráneas y exclusividad.
|
||||||
|
|
||||||
|
El módulo de activos expone un servicio público de lectura del inventario. Compañías lo recibe mediante constructor injection; no importa repositorios ni modelos ORM de activos. Las escrituras bloquean la compañía antes de comprobar personas, cuentas, autorizaciones y dispositivos. Los lectores no adquieren bloqueos de catálogo para evitar invertir el orden de bloqueo.
|
||||||
|
|
||||||
|
GET /api/mail-configurations/:companyId devuelve configuraciones, revisiones y needsReview calculado.
|
||||||
|
POST /api/mail-configurations/:companyId crea y POST /api/mail-configurations/:companyId/:id/update actualiza, recibiendo values. Sesión, origen válido y CSRF son obligatorios; el actor procede de la sesión.
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
Esta entrega registra correos en activos corporativos existentes, incluidos smartphones registrados en el inventario. El registro mínimo de dispositivos personales, listas de baja, configuraciones de impresoras/recursos, licencias y roles quedan pendientes. No almacena credenciales ni configura aplicaciones de correo automáticamente.
|
||||||
|
|
||||||
|
Las pruebas incluyen varias cuentas y dispositivos, cuentas compartidas, fechas, exclusividad concurrente, identidad e historial, pertenencia, autenticación, origen/CSRF, retirada tras cerrar permisos y desactivar personas, corrupción de persistencia, textos es/en y ayuda inferior. El navegador se comprueba con datos de prueba aislados.
|
||||||
@ -47,6 +47,20 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
|||||||
const relationships = new AssetRelationshipUseCases(assetRepository);
|
const relationships = new AssetRelationshipUseCases(assetRepository);
|
||||||
const useCases = new AssetCatalogUseCases(repository);
|
const useCases = new AssetCatalogUseCases(repository);
|
||||||
return {
|
return {
|
||||||
|
services: {
|
||||||
|
inventory: {
|
||||||
|
list: async (companyId: UniqueID) => {
|
||||||
|
const result = await assetRepository.get(companyId);
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
throw new Error("Inventario no disponible.");
|
||||||
|
}
|
||||||
|
return result.data.assets.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
available: asset.values.toPrimitive().status !== "retired",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
||||||
registerAssetCatalogRoutes(app, useCases, authenticate);
|
registerAssetCatalogRoutes(app, useCases, authenticate);
|
||||||
registerAssetRoutes(app, inventory, authenticate);
|
registerAssetRoutes(app, inventory, authenticate);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export { catalogKeys } from "./catalog-api";
|
export { AssetForm } from "./asset-form";
|
||||||
|
export { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
export { AssetCatalogPage } from "./catalog-page";
|
export { AssetCatalogPage } from "./catalog-page";
|
||||||
export { assetsEnglish, assetsSpanish } from "./i18n";
|
export { assetsEnglish, assetsSpanish } from "./i18n";
|
||||||
export { inventoryKeys } from "./inventory-api";
|
export { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
export { AssetInventoryPage } from "./inventory-page";
|
export { AssetInventoryPage } from "./inventory-page";
|
||||||
export { WorkstationsPage } from "./workstations-page";
|
export { WorkstationsPage } from "./workstations-page";
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/web/access/access.test.tsx"
|
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { MailConfiguration } from "../domain/mail-configuration.aggregate";
|
||||||
|
import {
|
||||||
|
MailConfigurationError,
|
||||||
|
type MailConfigurationInput,
|
||||||
|
} from "../domain/mail-configuration-values";
|
||||||
|
import { MailConfigurationValues } from "../domain/value-objects/mail-configuration-values";
|
||||||
|
|
||||||
|
import type { MailContext } from "./mail-configuration.repository";
|
||||||
|
import { mailAuthorized } from "./mail-configuration-policy";
|
||||||
|
export const mapMailValues = MailConfigurationValues.create;
|
||||||
|
|
||||||
|
export function mapMailInput(
|
||||||
|
input: MailConfigurationInput,
|
||||||
|
context: MailContext,
|
||||||
|
previous?: MailConfiguration
|
||||||
|
) {
|
||||||
|
const mapped = MailConfigurationValues.create(input);
|
||||||
|
if (!mapped.isSuccess) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return validateMailInput(mapped.data, context, previous);
|
||||||
|
}
|
||||||
|
export function validateMailInput(
|
||||||
|
values: MailConfigurationValues,
|
||||||
|
context: MailContext,
|
||||||
|
previous?: MailConfiguration
|
||||||
|
) {
|
||||||
|
const account = context.items.find((item) => item.id.equals(values.accountId))?.values;
|
||||||
|
if (
|
||||||
|
!context.people.some((item) => item.id.equals(values.personId)) ||
|
||||||
|
account?.kind !== "account" ||
|
||||||
|
!context.assets.some((item) => item.id.equals(values.assetId))
|
||||||
|
) {
|
||||||
|
return Result.fail(new MailConfigurationError("MAIL_SCOPE_MISMATCH"));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
["pending", "configured"].includes(values.state) &&
|
||||||
|
!mailAuthorized(values, context) &&
|
||||||
|
!previous?.values.equals(values)
|
||||||
|
) {
|
||||||
|
return Result.fail(new MailConfigurationError("MAIL_NOT_AUTHORIZED"));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!values.closed &&
|
||||||
|
context.configurations.some(
|
||||||
|
(item) =>
|
||||||
|
!(item.id.equals(previous?.id) || item.values.closed) && item.values.sameTarget(values)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_EXISTS"));
|
||||||
|
}
|
||||||
|
return Result.ok(values);
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import type { MailConfigurationValues } from "../domain/value-objects/mail-configuration-values";
|
||||||
|
|
||||||
|
import type { MailContext } from "./mail-configuration.repository";
|
||||||
|
// La autorización depende del periodo, no de que se haya terminado de configurar un dispositivo.
|
||||||
|
export function mailAuthorized(values: MailConfigurationValues, context: MailContext) {
|
||||||
|
const account = context.items.find((item) => item.id.equals(values.accountId))?.values;
|
||||||
|
if (
|
||||||
|
account?.kind !== "account" ||
|
||||||
|
account.usage === "technical" ||
|
||||||
|
!account.active ||
|
||||||
|
(account.expiresOn !== null && account.expiresOn < context.today) ||
|
||||||
|
!context.people.some((person) => person.id.equals(values.personId) && person.active) ||
|
||||||
|
!context.assets.some((asset) => asset.id.equals(values.assetId) && asset.available)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const system = context.items.find((item) => item.id.toPrimitive() === account.systemId)?.values;
|
||||||
|
const environment = context.items.find(
|
||||||
|
(item) => item.id.toPrimitive() === account.environmentId
|
||||||
|
)?.values;
|
||||||
|
if (
|
||||||
|
system?.kind !== "system" ||
|
||||||
|
!system.active ||
|
||||||
|
(account.environmentId !== null && (environment?.kind !== "environment" || !environment.active))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return context.items.some((item) => {
|
||||||
|
const relation = item.values;
|
||||||
|
return (
|
||||||
|
relation.kind === "account_person" &&
|
||||||
|
relation.personId === values.personId.toPrimitive() &&
|
||||||
|
relation.accountId === values.accountId.toPrimitive() &&
|
||||||
|
relation.startDate <= context.today &&
|
||||||
|
(relation.endDate === null || context.today < relation.endDate)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { MailConfigurationDTO } from "../../common/mail-configurations";
|
||||||
|
import type { MailConfiguration } from "../domain/mail-configuration.aggregate";
|
||||||
|
|
||||||
|
import type { MailContext } from "./mail-configuration.repository";
|
||||||
|
import { mailAuthorized } from "./mail-configuration-policy";
|
||||||
|
export function buildMailResponse(
|
||||||
|
item: MailConfiguration,
|
||||||
|
context: MailContext
|
||||||
|
): MailConfigurationDTO {
|
||||||
|
return {
|
||||||
|
id: item.id.toPrimitive(),
|
||||||
|
companyId: item.companyId.toPrimitive(),
|
||||||
|
values: item.values.toPrimitive(),
|
||||||
|
needsReview: !(item.values.closed || mailAuthorized(item.values, context)),
|
||||||
|
history: item.history.map((revision) => ({
|
||||||
|
id: revision.id.toPrimitive(),
|
||||||
|
values: revision.values.toPrimitive(),
|
||||||
|
actorId: revision.actorId.toPrimitive(),
|
||||||
|
actorName: context.actors.get(revision.actorId.toPrimitive()) ?? "—",
|
||||||
|
recordedAt: revision.recordedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import type { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { AccessDefinition } from "../domain/access/access-definition";
|
||||||
|
import type { MailConfiguration } from "../domain/mail-configuration.aggregate";
|
||||||
|
import type { MailConfigurationError } from "../domain/mail-configuration-values";
|
||||||
|
export interface IMailInventoryService {
|
||||||
|
list(companyId: UniqueID): Promise<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 }>[];
|
||||||
|
assets: readonly Readonly<{ id: UniqueID; available: boolean }>[];
|
||||||
|
actors: ReadonlyMap<string, string>;
|
||||||
|
today: string;
|
||||||
|
}>;
|
||||||
|
export interface IMailConfigurationRepository {
|
||||||
|
get(companyId: UniqueID): Promise<Result<MailContext, MailConfigurationError>>;
|
||||||
|
save(
|
||||||
|
companyId: UniqueID,
|
||||||
|
action: (context: MailContext) => Result<MailConfiguration, MailConfigurationError>
|
||||||
|
): Promise<
|
||||||
|
Result<{ configuration: MailConfiguration; context: MailContext }, MailConfigurationError>
|
||||||
|
>;
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { MailConfiguration } from "../domain/mail-configuration.aggregate";
|
||||||
|
import {
|
||||||
|
MailConfigurationError,
|
||||||
|
type MailConfigurationInput,
|
||||||
|
} from "../domain/mail-configuration-values";
|
||||||
|
|
||||||
|
import type { IMailConfigurationRepository } from "./mail-configuration.repository";
|
||||||
|
import { mapMailValues, validateMailInput } from "./mail-configuration-input.mapper";
|
||||||
|
export class MailConfigurationUseCases {
|
||||||
|
constructor(private readonly repository: IMailConfigurationRepository) {}
|
||||||
|
get(companyId: string) {
|
||||||
|
const company = UniqueID.create(companyId);
|
||||||
|
return company.isSuccess
|
||||||
|
? this.repository.get(company.data)
|
||||||
|
: Promise.resolve(Result.fail(new MailConfigurationError("INVALID_REQUEST")));
|
||||||
|
}
|
||||||
|
save(companyId: string, id: string | undefined, input: MailConfigurationInput, actorId: string) {
|
||||||
|
const company = UniqueID.create(companyId);
|
||||||
|
const actor = UniqueID.create(actorId);
|
||||||
|
const identity = id ? UniqueID.create(id) : undefined;
|
||||||
|
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
|
||||||
|
return Promise.resolve(Result.fail(new MailConfigurationError("INVALID_REQUEST")));
|
||||||
|
}
|
||||||
|
return this.repository.save(company.data, (context) => {
|
||||||
|
const previous = identity?.isSuccess
|
||||||
|
? context.configurations.find((item) => item.id.equals(identity.data))
|
||||||
|
: undefined;
|
||||||
|
if (id && !previous) {
|
||||||
|
return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
const values = mapMailValues(input);
|
||||||
|
if (!values.isSuccess) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const candidate = previous
|
||||||
|
? previous.update(values.data, actor.data, now)
|
||||||
|
: MailConfiguration.create(company.data, values.data, actor.data, now);
|
||||||
|
if (!candidate.isSuccess) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
const validated = validateMailInput(values.data, context, previous);
|
||||||
|
return validated.isSuccess ? candidate : validated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
import type { MailConfigurationValues } from "./value-objects/mail-configuration-values";
|
||||||
|
export type MailRevisionProps = Readonly<{
|
||||||
|
values: MailConfigurationValues;
|
||||||
|
actorId: UniqueID;
|
||||||
|
recordedAt: string;
|
||||||
|
}>;
|
||||||
|
export class MailConfigurationRevision extends DomainEntity<MailRevisionProps> {
|
||||||
|
static create(props: MailRevisionProps) {
|
||||||
|
return new MailConfigurationRevision(Object.freeze({ ...props }));
|
||||||
|
}
|
||||||
|
static rehydrate(props: MailRevisionProps, id: UniqueID) {
|
||||||
|
return new MailConfigurationRevision(Object.freeze({ ...props }), id);
|
||||||
|
}
|
||||||
|
get values() {
|
||||||
|
return this.props.values;
|
||||||
|
}
|
||||||
|
get actorId() {
|
||||||
|
return this.props.actorId;
|
||||||
|
}
|
||||||
|
get recordedAt() {
|
||||||
|
return this.props.recordedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
export const MailMethodSchema = z.enum(["pop", "imap", "exchange", "other"]);
|
||||||
|
export const MailStateSchema = z.enum([
|
||||||
|
"pending",
|
||||||
|
"configured",
|
||||||
|
"removal_pending",
|
||||||
|
"removed",
|
||||||
|
"never_configured",
|
||||||
|
]);
|
||||||
|
export const MailConfigurationInputSchema = z.strictObject({
|
||||||
|
personId: z.uuid(),
|
||||||
|
accountId: z.uuid(),
|
||||||
|
assetId: z.uuid(),
|
||||||
|
address: z.email().max(254),
|
||||||
|
method: MailMethodSchema,
|
||||||
|
state: MailStateSchema,
|
||||||
|
notes: z.string().trim().max(4000).nullable(),
|
||||||
|
});
|
||||||
|
export type MailConfigurationInput = z.infer<typeof MailConfigurationInputSchema>;
|
||||||
|
export class MailConfigurationError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAIL_STATE_TRANSITIONS = {
|
||||||
|
pending: ["pending", "configured", "never_configured"],
|
||||||
|
configured: ["configured", "removal_pending"],
|
||||||
|
removal_pending: ["removal_pending", "removed"],
|
||||||
|
removed: ["removed"],
|
||||||
|
never_configured: ["never_configured"],
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { MailConfigurationRevision } from "./mail-configuration-revision.entity";
|
||||||
|
import { MAIL_STATE_TRANSITIONS, MailConfigurationError } from "./mail-configuration-values";
|
||||||
|
import type { MailConfigurationValues } from "./value-objects/mail-configuration-values";
|
||||||
|
|
||||||
|
type Props = Readonly<{
|
||||||
|
companyId: UniqueID;
|
||||||
|
values: MailConfigurationValues;
|
||||||
|
history: readonly MailConfigurationRevision[];
|
||||||
|
}>;
|
||||||
|
export class MailConfiguration extends AggregateRoot<Props> {
|
||||||
|
private constructor(props: Props, id?: UniqueID) {
|
||||||
|
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||||
|
}
|
||||||
|
static create(
|
||||||
|
companyId: UniqueID,
|
||||||
|
values: MailConfigurationValues,
|
||||||
|
actorId: UniqueID,
|
||||||
|
recordedAt: string
|
||||||
|
): Result<MailConfiguration, MailConfigurationError> {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
z.iso.datetime().safeParse(recordedAt).success &&
|
||||||
|
["pending", "configured"].includes(values.state)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Result.fail(new MailConfigurationError("INVALID_MAIL_TRANSITION"));
|
||||||
|
}
|
||||||
|
return Result.ok(
|
||||||
|
new MailConfiguration({
|
||||||
|
companyId,
|
||||||
|
values,
|
||||||
|
history: [MailConfigurationRevision.create({ values, actorId, recordedAt })],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
static rehydrate(props: Props, id: UniqueID) {
|
||||||
|
return new MailConfiguration(props, id);
|
||||||
|
}
|
||||||
|
get companyId() {
|
||||||
|
return this.props.companyId;
|
||||||
|
}
|
||||||
|
get values() {
|
||||||
|
return this.props.values;
|
||||||
|
}
|
||||||
|
get history() {
|
||||||
|
return this.props.history;
|
||||||
|
}
|
||||||
|
update(
|
||||||
|
values: MailConfigurationValues,
|
||||||
|
actorId: UniqueID,
|
||||||
|
recordedAt: string
|
||||||
|
): Result<MailConfiguration, MailConfigurationError> {
|
||||||
|
if (this.values.equals(values)) {
|
||||||
|
return Result.ok(this);
|
||||||
|
}
|
||||||
|
if (!this.values.sameTarget(values)) {
|
||||||
|
return Result.fail(new MailConfigurationError("MAIL_TARGET_IMMUTABLE"));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!MAIL_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) ||
|
||||||
|
(this.values.closed && !this.values.equals(values))
|
||||||
|
) {
|
||||||
|
return Result.fail(new MailConfigurationError("INVALID_MAIL_TRANSITION"));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||||
|
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||||
|
) {
|
||||||
|
return Result.fail(new MailConfigurationError("INVALID_REQUEST"));
|
||||||
|
}
|
||||||
|
return Result.ok(
|
||||||
|
new MailConfiguration(
|
||||||
|
{
|
||||||
|
...this.props,
|
||||||
|
values,
|
||||||
|
history: [
|
||||||
|
...this.history,
|
||||||
|
MailConfigurationRevision.create({ values, actorId, recordedAt }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
this.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
modules/companies/src/api/domain/mail-configuration.test.ts
Normal file
147
modules/companies/src/api/domain/mail-configuration.test.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
import type { MailContext } from "../application/mail-configuration.repository";
|
||||||
|
import { mapMailInput } from "../application/mail-configuration-input.mapper";
|
||||||
|
import { mailAuthorized } from "../application/mail-configuration-policy";
|
||||||
|
import { MailConfigurationPersistenceMapper } from "../infrastructure/persistence/mail-configuration-persistence.mapper";
|
||||||
|
|
||||||
|
import { MailConfiguration } from "./mail-configuration.aggregate";
|
||||||
|
import type { MailConfigurationInput } from "./mail-configuration-values";
|
||||||
|
import { MailConfigurationValues } from "./value-objects/mail-configuration-values";
|
||||||
|
|
||||||
|
const company = UniqueID.generateNewID();
|
||||||
|
const person = UniqueID.generateNewID();
|
||||||
|
const account = UniqueID.generateNewID();
|
||||||
|
const asset = UniqueID.generateNewID();
|
||||||
|
const system = UniqueID.generateNewID();
|
||||||
|
const actor = UniqueID.generateNewID();
|
||||||
|
const input = {
|
||||||
|
personId: person.toPrimitive(),
|
||||||
|
accountId: account.toPrimitive(),
|
||||||
|
assetId: asset.toPrimitive(),
|
||||||
|
address: "Ana@EXAMPLE.test",
|
||||||
|
method: "imap" as const,
|
||||||
|
state: "configured" as const,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
function values(data: MailConfigurationInput = input) {
|
||||||
|
const result = MailConfigurationValues.create(data);
|
||||||
|
assert.ok(result.isSuccess);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
function create() {
|
||||||
|
const result = MailConfiguration.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
|
||||||
|
assert.ok(result.isSuccess);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
const context: MailContext = {
|
||||||
|
configurations: [],
|
||||||
|
people: [{ id: person, active: true }],
|
||||||
|
assets: [{ id: asset, available: true }],
|
||||||
|
actors: new Map(),
|
||||||
|
today: "2026-10-04",
|
||||||
|
items: [
|
||||||
|
{ id: system, values: { kind: "system", name: "Correo", active: true } },
|
||||||
|
{
|
||||||
|
id: account,
|
||||||
|
values: {
|
||||||
|
kind: "account",
|
||||||
|
systemId: system.toPrimitive(),
|
||||||
|
environmentId: null,
|
||||||
|
login: "ana",
|
||||||
|
usage: "shared",
|
||||||
|
active: true,
|
||||||
|
expiresOn: null,
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
values: {
|
||||||
|
kind: "account_person",
|
||||||
|
accountId: account.toPrimitive(),
|
||||||
|
personId: person.toPrimitive(),
|
||||||
|
startDate: "2026-10-01",
|
||||||
|
endDate: null,
|
||||||
|
configuration: "pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
test("correo: UUIDv7, normalización, no-op, historial y retirada explícita", () => {
|
||||||
|
const item = create();
|
||||||
|
assert.equal(item.id.toPrimitive()[14], "7");
|
||||||
|
assert.equal(item.history[0]?.id.toPrimitive()[14], "7");
|
||||||
|
assert.equal(item.values.address.toPrimitive(), "ana@example.test");
|
||||||
|
const unchanged = item.update(values(), actor, "2026-10-04T11:00:00.000Z");
|
||||||
|
assert.ok(unchanged.isSuccess);
|
||||||
|
assert.equal(unchanged.data, item);
|
||||||
|
assert.equal(
|
||||||
|
item.update(values({ ...input, state: "removed" }), actor, "2026-10-04T11:00:00.000Z")
|
||||||
|
.isSuccess,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const pendingValues = MailConfigurationValues.create({ ...input, state: "removal_pending" });
|
||||||
|
assert.ok(pendingValues.isSuccess);
|
||||||
|
const pending = item.update(pendingValues.data, actor, "2026-10-04T11:00:00.000Z");
|
||||||
|
assert.ok(pending.isSuccess);
|
||||||
|
const removedValues = MailConfigurationValues.create({ ...input, state: "removed" });
|
||||||
|
assert.ok(removedValues.isSuccess);
|
||||||
|
const removed = pending.data.update(removedValues.data, actor, "2026-10-04T12:00:00.000Z");
|
||||||
|
assert.ok(removed.isSuccess);
|
||||||
|
assert.ok(removed.data.id.equals(item.id));
|
||||||
|
assert.equal(removed.data.history.length, 3);
|
||||||
|
assert.equal(removed.data.history[0]?.values.state, "configured");
|
||||||
|
assert.equal(removed.data.update(values(), actor, "2026-10-04T13:00:00.000Z").isSuccess, false);
|
||||||
|
});
|
||||||
|
test("correo: autorización vigente y ámbito; varias cuentas y dispositivos, duplicados abiertos", () => {
|
||||||
|
const item = create();
|
||||||
|
assert.equal(mailAuthorized(item.values, context), true);
|
||||||
|
assert.equal(mapMailInput(input, context).isSuccess, true);
|
||||||
|
assert.equal(mapMailInput(input, { ...context, configurations: [item] }).isSuccess, false);
|
||||||
|
assert.equal(mapMailInput(input, { ...context, items: [] }).isSuccess, false);
|
||||||
|
assert.equal(
|
||||||
|
mapMailInput(input, { ...context, people: [{ id: person, active: false }] }).isSuccess,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(mapMailInput(input, { ...context, assets: [] }).isSuccess, false);
|
||||||
|
assert.equal(mailAuthorized(item.values, { ...context, today: "2026-09-30" }), false);
|
||||||
|
const expired = {
|
||||||
|
...context,
|
||||||
|
items: context.items.map((entry) =>
|
||||||
|
entry.values.kind === "account"
|
||||||
|
? { ...entry, values: { ...entry.values, expiresOn: "2026-10-03" } }
|
||||||
|
: entry
|
||||||
|
),
|
||||||
|
};
|
||||||
|
assert.equal(mailAuthorized(item.values, expired), false);
|
||||||
|
assert.equal(mapMailInput({ ...input, state: "removal_pending" }, expired, item).isSuccess, true);
|
||||||
|
const anotherAsset = UniqueID.generateNewID();
|
||||||
|
assert.equal(
|
||||||
|
mapMailInput(
|
||||||
|
{ ...input, assetId: anotherAsset.toPrimitive(), method: "pop" },
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
configurations: [item],
|
||||||
|
assets: [...context.assets, { id: anotherAsset, available: true }],
|
||||||
|
}
|
||||||
|
).isSuccess,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("correo: mapper conserva identidad e historial y rechaza corrupción", () => {
|
||||||
|
const mapper = new MailConfigurationPersistenceMapper();
|
||||||
|
const original = create();
|
||||||
|
const persisted = mapper.toPersistence(original);
|
||||||
|
const restored = mapper.toDomain(persisted.root, persisted.revisions);
|
||||||
|
assert.ok(restored.id.equals(original.id));
|
||||||
|
assert.ok(restored.values.equals(original.values));
|
||||||
|
assert.ok(restored.history[0]?.id.equals(original.history[0]?.id));
|
||||||
|
assert.throws(() =>
|
||||||
|
mapper.toDomain({ ...persisted.root, personId: actor.toPrimitive() }, persisted.revisions)
|
||||||
|
);
|
||||||
|
assert.throws(() => mapper.toDomain(persisted.root, []));
|
||||||
|
});
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MailConfigurationError,
|
||||||
|
type MailConfigurationInput,
|
||||||
|
MailConfigurationInputSchema,
|
||||||
|
} from "../mail-configuration-values";
|
||||||
|
export class MailConfigurationValues {
|
||||||
|
private constructor(
|
||||||
|
public readonly personId: UniqueID,
|
||||||
|
public readonly accountId: UniqueID,
|
||||||
|
public readonly assetId: UniqueID,
|
||||||
|
public readonly address: EmailAddress,
|
||||||
|
public readonly method: MailConfigurationInput["method"],
|
||||||
|
public readonly state: MailConfigurationInput["state"],
|
||||||
|
public readonly notes: Maybe<string>
|
||||||
|
) {
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
static create(
|
||||||
|
input: MailConfigurationInput
|
||||||
|
): Result<MailConfigurationValues, MailConfigurationError> {
|
||||||
|
const parsed = MailConfigurationInputSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Result.fail(new MailConfigurationError("INVALID_REQUEST"));
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
const person = UniqueID.create(data.personId);
|
||||||
|
const account = UniqueID.create(data.accountId);
|
||||||
|
const asset = UniqueID.create(data.assetId);
|
||||||
|
const address = EmailAddress.create(data.address);
|
||||||
|
if (!(person.isSuccess && account.isSuccess && asset.isSuccess && address.isSuccess)) {
|
||||||
|
return Result.fail(new MailConfigurationError("INVALID_REQUEST"));
|
||||||
|
}
|
||||||
|
return Result.ok(
|
||||||
|
new MailConfigurationValues(
|
||||||
|
person.data,
|
||||||
|
account.data,
|
||||||
|
asset.data,
|
||||||
|
address.data,
|
||||||
|
data.method,
|
||||||
|
data.state,
|
||||||
|
data.notes?.trim() ? Maybe.some(data.notes.trim()) : Maybe.none()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get closed() {
|
||||||
|
return this.state === "removed" || this.state === "never_configured";
|
||||||
|
}
|
||||||
|
sameTarget(other: MailConfigurationValues) {
|
||||||
|
return (
|
||||||
|
this.personId.equals(other.personId) &&
|
||||||
|
this.accountId.equals(other.accountId) &&
|
||||||
|
this.assetId.equals(other.assetId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
equals(other: MailConfigurationValues) {
|
||||||
|
return (
|
||||||
|
this.sameTarget(other) &&
|
||||||
|
this.address.equals(other.address) &&
|
||||||
|
this.method === other.method &&
|
||||||
|
this.state === other.state &&
|
||||||
|
this.notes.getOrUndefined() === other.notes.getOrUndefined()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toPrimitive(): MailConfigurationInput {
|
||||||
|
return {
|
||||||
|
personId: this.personId.toPrimitive(),
|
||||||
|
accountId: this.accountId.toPrimitive(),
|
||||||
|
assetId: this.assetId.toPrimitive(),
|
||||||
|
address: this.address.toPrimitive(),
|
||||||
|
method: this.method,
|
||||||
|
state: this.state,
|
||||||
|
notes: this.notes.getOrUndefined() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import { type Application, type RequestHandler, type Response, Router } from "express";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MailItemParamsSchema,
|
||||||
|
MailParamsSchema,
|
||||||
|
SaveMailRequestSchema,
|
||||||
|
} from "../../../common/mail-configurations";
|
||||||
|
import type { MailConfigurationUseCases } from "../../application/mail-configuration.use-cases";
|
||||||
|
import { buildMailResponse } from "../../application/mail-configuration-response.builder";
|
||||||
|
|
||||||
|
function fail(response: Response, code: string) {
|
||||||
|
response
|
||||||
|
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||||
|
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||||
|
}
|
||||||
|
export function registerMailRoutes(
|
||||||
|
app: Application,
|
||||||
|
useCases: MailConfigurationUseCases,
|
||||||
|
authenticate: RequestHandler
|
||||||
|
) {
|
||||||
|
const router = Router();
|
||||||
|
router.get("/:companyId", (request, response, next) => {
|
||||||
|
const params = MailParamsSchema.safeParse(request.params);
|
||||||
|
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
|
||||||
|
fail(response, "INVALID_REQUEST");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useCases
|
||||||
|
.get(params.data.companyId)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
fail(response, result.error.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.json({
|
||||||
|
configurations: result.data.configurations.map((item) =>
|
||||||
|
buildMailResponse(item, result.data)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(next);
|
||||||
|
});
|
||||||
|
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
|
||||||
|
router.post(path, (request, response, next) => {
|
||||||
|
const params = (path.endsWith("update") ? MailItemParamsSchema : MailParamsSchema).safeParse(
|
||||||
|
request.params
|
||||||
|
);
|
||||||
|
const body = SaveMailRequestSchema.safeParse(request.body);
|
||||||
|
const actor = z
|
||||||
|
.object({ userId: z.uuid() })
|
||||||
|
.safeParse("session" in request ? request.session : undefined);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
params.success &&
|
||||||
|
body.success &&
|
||||||
|
actor.success &&
|
||||||
|
z.strictObject({}).safeParse(request.query).success
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(response, "INVALID_REQUEST");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id =
|
||||||
|
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||||
|
useCases
|
||||||
|
.save(params.data.companyId, id, body.data.values, actor.data.userId)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
fail(response, result.error.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.status(id ? 200 : 201).json({
|
||||||
|
configuration: buildMailResponse(result.data.configuration, result.data.context),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.use("/api/mail-configurations", authenticate, router);
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||||
|
export async function migrateMailConfigurations(database: Sequelize) {
|
||||||
|
const query = database.getQueryInterface();
|
||||||
|
const rows = await database.query<{ name: string }>(
|
||||||
|
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const reference = (table: string) => ({
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: table, key: "id" },
|
||||||
|
onDelete: "RESTRICT",
|
||||||
|
});
|
||||||
|
if (!rows.some((row) => row.name === "mail_configurations")) {
|
||||||
|
await query.createTable("mail_configurations", {
|
||||||
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
|
companyId: reference("companies"),
|
||||||
|
personId: reference("company_contacts"),
|
||||||
|
accountId: reference("access_items"),
|
||||||
|
assetId: reference("assets"),
|
||||||
|
values: { type: DataTypes.JSON, allowNull: false },
|
||||||
|
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!rows.some((row) => row.name === "mail_configuration_revisions")) {
|
||||||
|
await query.createTable("mail_configuration_revisions", {
|
||||||
|
id: { type: DataTypes.UUID, primaryKey: true },
|
||||||
|
configurationId: reference("mail_configurations"),
|
||||||
|
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
actorId: reference("users"),
|
||||||
|
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||||
|
values: { type: DataTypes.JSON, allowNull: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const indexes = await database.query<{ name: string }>(
|
||||||
|
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mail_configuration_revisions'",
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (!indexes.some((index) => index.name === "mail_revision_position")) {
|
||||||
|
await query.addIndex("mail_configuration_revisions", ["configurationId", "position"], {
|
||||||
|
unique: true,
|
||||||
|
name: "mail_revision_position",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { MailConfiguration } from "../../domain/mail-configuration.aggregate";
|
||||||
|
import { MailConfigurationRevision } from "../../domain/mail-configuration-revision.entity";
|
||||||
|
import { MailConfigurationInputSchema } from "../../domain/mail-configuration-values";
|
||||||
|
import { MailConfigurationValues } from "../../domain/value-objects/mail-configuration-values";
|
||||||
|
export function mailIdentity(value: string) {
|
||||||
|
const id = UniqueID.create(value);
|
||||||
|
if (!id.isSuccess) {
|
||||||
|
throw new Error("Identidad de correo persistida inconsistente.");
|
||||||
|
}
|
||||||
|
return id.data;
|
||||||
|
}
|
||||||
|
export function persistedJson(value: unknown): unknown {
|
||||||
|
return typeof value === "string" ? JSON.parse(value) : value;
|
||||||
|
}
|
||||||
|
export type MailRow = {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
personId: string;
|
||||||
|
accountId: string;
|
||||||
|
assetId: string;
|
||||||
|
values: unknown;
|
||||||
|
currentKey: string | null;
|
||||||
|
};
|
||||||
|
export type MailRevisionRow = {
|
||||||
|
id: string;
|
||||||
|
configurationId: string;
|
||||||
|
position: number;
|
||||||
|
actorId: string;
|
||||||
|
recordedAt: string;
|
||||||
|
values: unknown;
|
||||||
|
};
|
||||||
|
function valuesOf(raw: unknown) {
|
||||||
|
const values = MailConfigurationValues.create(
|
||||||
|
MailConfigurationInputSchema.parse(persistedJson(raw))
|
||||||
|
);
|
||||||
|
if (!values.isSuccess) {
|
||||||
|
throw new Error("Valores de correo persistidos inconsistentes.");
|
||||||
|
}
|
||||||
|
return values.data;
|
||||||
|
}
|
||||||
|
export class MailConfigurationPersistenceMapper {
|
||||||
|
toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) {
|
||||||
|
const values = valuesOf(row.values);
|
||||||
|
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||||
|
const history = ordered.map((revision, position) => {
|
||||||
|
const revisionValues = valuesOf(revision.values);
|
||||||
|
if (
|
||||||
|
revision.position !== position ||
|
||||||
|
!z.iso.datetime().safeParse(revision.recordedAt).success ||
|
||||||
|
revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") ||
|
||||||
|
!values.sameTarget(revisionValues)
|
||||||
|
) {
|
||||||
|
throw new Error("Historial de correo persistido inconsistente.");
|
||||||
|
}
|
||||||
|
return MailConfigurationRevision.rehydrate(
|
||||||
|
{
|
||||||
|
values: revisionValues,
|
||||||
|
actorId: mailIdentity(revision.actorId),
|
||||||
|
recordedAt: revision.recordedAt,
|
||||||
|
},
|
||||||
|
mailIdentity(revision.id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!history.at(-1)?.values.equals(values) ||
|
||||||
|
row.personId !== values.personId.toPrimitive() ||
|
||||||
|
row.accountId !== values.accountId.toPrimitive() ||
|
||||||
|
row.assetId !== values.assetId.toPrimitive() ||
|
||||||
|
row.currentKey !==
|
||||||
|
(values.closed ? null : [row.personId, row.accountId, row.assetId].join(":"))
|
||||||
|
) {
|
||||||
|
throw new Error("Referencias de correo persistidas inconsistentes.");
|
||||||
|
}
|
||||||
|
return MailConfiguration.rehydrate(
|
||||||
|
{ companyId: mailIdentity(row.companyId), values, history },
|
||||||
|
mailIdentity(row.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toPersistence(item: MailConfiguration) {
|
||||||
|
const values = item.values.toPrimitive();
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
id: item.id.toPrimitive(),
|
||||||
|
companyId: item.companyId.toPrimitive(),
|
||||||
|
personId: values.personId,
|
||||||
|
accountId: values.accountId,
|
||||||
|
assetId: values.assetId,
|
||||||
|
values: JSON.stringify(values),
|
||||||
|
currentKey: item.values.closed
|
||||||
|
? null
|
||||||
|
: [values.personId, values.accountId, values.assetId].join(":"),
|
||||||
|
},
|
||||||
|
revisions: item.history.map((revision, position) => ({
|
||||||
|
id: revision.id.toPrimitive(),
|
||||||
|
configurationId: item.id.toPrimitive(),
|
||||||
|
position,
|
||||||
|
actorId: revision.actorId.toPrimitive(),
|
||||||
|
recordedAt: revision.recordedAt,
|
||||||
|
values: JSON.stringify(revision.values.toPrimitive()),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IMailConfigurationRepository,
|
||||||
|
IMailInventoryService,
|
||||||
|
MailContext,
|
||||||
|
} from "../../application/mail-configuration.repository";
|
||||||
|
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
|
||||||
|
import { MailConfigurationError } from "../../domain/mail-configuration-values";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MailConfigurationPersistenceMapper,
|
||||||
|
type MailRevisionRow,
|
||||||
|
type MailRow,
|
||||||
|
mailIdentity,
|
||||||
|
persistedJson,
|
||||||
|
} from "./mail-configuration-persistence.mapper";
|
||||||
|
export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository {
|
||||||
|
private readonly mapper = new MailConfigurationPersistenceMapper();
|
||||||
|
constructor(
|
||||||
|
private readonly database: Sequelize,
|
||||||
|
private readonly inventory: IMailInventoryService
|
||||||
|
) {}
|
||||||
|
private async load(
|
||||||
|
company: UniqueID,
|
||||||
|
transaction: Transaction,
|
||||||
|
write: boolean
|
||||||
|
): Promise<Result<MailContext, MailConfigurationError>> {
|
||||||
|
const companyId = company.toPrimitive();
|
||||||
|
// El bloqueo de compañía precede a las lecturas: los escritores de personas, cuentas y activos usan el mismo bloqueo.
|
||||||
|
const companies = await this.database.query<{ id: string }>(
|
||||||
|
`SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`,
|
||||||
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (!companies.length) {
|
||||||
|
return Result.fail(new MailConfigurationError("COMPANY_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
const rows = await this.database.query<MailRow>(
|
||||||
|
"SELECT * FROM mail_configurations WHERE companyId=:companyId",
|
||||||
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const revisions = await this.database.query<MailRevisionRow>(
|
||||||
|
"SELECT r.* FROM mail_configuration_revisions r JOIN mail_configurations c ON c.id=r.configurationId WHERE c.companyId=:companyId",
|
||||||
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const people = await this.database.query<{ id: string; active: boolean }>(
|
||||||
|
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
||||||
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const items = await this.database.query<{ id: string; values: unknown }>(
|
||||||
|
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
|
||||||
|
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const actors = await this.database.query<{ id: string; name: string }>(
|
||||||
|
"SELECT id,name FROM users",
|
||||||
|
{ transaction, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const context: MailContext = {
|
||||||
|
configurations: rows.map((row) =>
|
||||||
|
this.mapper.toDomain(
|
||||||
|
row,
|
||||||
|
revisions.filter((revision) => revision.configurationId === row.id)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
people: people.map((person) => ({
|
||||||
|
id: mailIdentity(person.id),
|
||||||
|
active: Boolean(person.active),
|
||||||
|
})),
|
||||||
|
items: items.map((item) => ({
|
||||||
|
id: mailIdentity(item.id),
|
||||||
|
values: AccessDefinitionSchema.parse(persistedJson(item.values)),
|
||||||
|
})),
|
||||||
|
assets: await this.inventory.list(company),
|
||||||
|
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||||
|
today: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
context.configurations.some(
|
||||||
|
(item) =>
|
||||||
|
!(
|
||||||
|
context.people.some((person) => person.id.equals(item.values.personId)) &&
|
||||||
|
context.items.some(
|
||||||
|
(account) =>
|
||||||
|
account.id.equals(item.values.accountId) && account.values.kind === "account"
|
||||||
|
) &&
|
||||||
|
context.assets.some((asset) => asset.id.equals(item.values.assetId))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("Pertenencia de correo persistida inconsistente.");
|
||||||
|
}
|
||||||
|
return Result.ok(context);
|
||||||
|
}
|
||||||
|
get(companyId: UniqueID) {
|
||||||
|
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
||||||
|
}
|
||||||
|
save(companyId: UniqueID, action: Parameters<IMailConfigurationRepository["save"]>[1]) {
|
||||||
|
return this.database.transaction(async (transaction) => {
|
||||||
|
const context = await this.load(companyId, transaction, true);
|
||||||
|
if (!context.isSuccess) {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
const candidate = action(context.data);
|
||||||
|
if (!candidate.isSuccess) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
const previous = context.data.configurations.find((item) =>
|
||||||
|
item.id.equals(candidate.data.id)
|
||||||
|
);
|
||||||
|
if (previous !== candidate.data) {
|
||||||
|
const record = this.mapper.toPersistence(candidate.data);
|
||||||
|
await this.database.query(
|
||||||
|
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
|
||||||
|
{ replacements: record.root, transaction }
|
||||||
|
);
|
||||||
|
const stored = new Set(
|
||||||
|
previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
|
||||||
|
);
|
||||||
|
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
|
||||||
|
await this.database.query(
|
||||||
|
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)",
|
||||||
|
{ replacements: revision, transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result.ok({ configuration: candidate.data, context: context.data });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,8 +4,11 @@ import type { Sequelize } from "sequelize";
|
|||||||
import { AccessUseCases } from "./application/access.use-cases";
|
import { AccessUseCases } from "./application/access.use-cases";
|
||||||
import { CompanyUseCases } from "./application/company.use-cases";
|
import { CompanyUseCases } from "./application/company.use-cases";
|
||||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||||
|
import type { IMailInventoryService } from "./application/mail-configuration.repository";
|
||||||
|
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||||
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
||||||
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||||
|
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||||
import {
|
import {
|
||||||
AccessPersistenceMapper,
|
AccessPersistenceMapper,
|
||||||
SequelizeAccessRepository,
|
SequelizeAccessRepository,
|
||||||
@ -13,10 +16,12 @@ import {
|
|||||||
} from "./infrastructure/persistence/access-persistence";
|
} from "./infrastructure/persistence/access-persistence";
|
||||||
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
||||||
|
import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
|
||||||
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
||||||
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
||||||
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
||||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||||
|
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||||
|
|
||||||
export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) {
|
export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) {
|
||||||
await migrateCompanies(database);
|
await migrateCompanies(database);
|
||||||
@ -30,6 +35,16 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req
|
|||||||
);
|
);
|
||||||
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
||||||
return {
|
return {
|
||||||
|
initializeMail: async (inventory: IMailInventoryService) => {
|
||||||
|
await migrateMailConfigurations(database);
|
||||||
|
const mail = new MailConfigurationUseCases(
|
||||||
|
new SequelizeMailConfigurationRepository(database, inventory)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
register: (app: Parameters<typeof registerMailRoutes>[0]) =>
|
||||||
|
registerMailRoutes(app, mail, authenticate),
|
||||||
|
};
|
||||||
|
},
|
||||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||||
registerCompanyRoutes(app, useCases, authenticate);
|
registerCompanyRoutes(app, useCases, authenticate);
|
||||||
registerAccessRoutes(app, access, authenticate);
|
registerAccessRoutes(app, access, authenticate);
|
||||||
|
|||||||
@ -5,3 +5,4 @@ export * from "./dto/response/company.response.dto";
|
|||||||
export * from "./dto/shared/access.dto";
|
export * from "./dto/shared/access.dto";
|
||||||
export * from "./dto/shared/company.dto";
|
export * from "./dto/shared/company.dto";
|
||||||
export * from "./dto/shared/people.dto";
|
export * from "./dto/shared/people.dto";
|
||||||
|
export * from "./mail-configurations";
|
||||||
|
|||||||
36
modules/companies/src/common/mail-configurations.ts
Normal file
36
modules/companies/src/common/mail-configurations.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { MailConfigurationInputSchema } from "../api/domain/mail-configuration-values";
|
||||||
|
|
||||||
|
export type { MailConfigurationInput } from "../api/domain/mail-configuration-values";
|
||||||
|
export {
|
||||||
|
MailConfigurationInputSchema,
|
||||||
|
MailMethodSchema,
|
||||||
|
MailStateSchema,
|
||||||
|
} from "../api/domain/mail-configuration-values";
|
||||||
|
export const MailParamsSchema = z.strictObject({ companyId: z.uuid() });
|
||||||
|
export const MailItemParamsSchema = MailParamsSchema.extend({ id: z.uuid() });
|
||||||
|
export const SaveMailRequestSchema = z.strictObject({ values: MailConfigurationInputSchema });
|
||||||
|
export const MailRevisionSchema = z.strictObject({
|
||||||
|
id: z.uuid(),
|
||||||
|
values: MailConfigurationInputSchema,
|
||||||
|
actorId: z.uuid(),
|
||||||
|
actorName: z.string().min(1),
|
||||||
|
recordedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export const MailConfigurationSchema = z.strictObject({
|
||||||
|
id: z.uuid(),
|
||||||
|
companyId: z.uuid(),
|
||||||
|
values: MailConfigurationInputSchema,
|
||||||
|
history: z.array(MailRevisionSchema).min(1),
|
||||||
|
needsReview: z.boolean(),
|
||||||
|
});
|
||||||
|
export const MailConfigurationsResponseSchema = z.strictObject({
|
||||||
|
configurations: z.array(MailConfigurationSchema),
|
||||||
|
});
|
||||||
|
export const MailConfigurationResponseSchema = z.strictObject({
|
||||||
|
configuration: MailConfigurationSchema,
|
||||||
|
});
|
||||||
|
export type MailConfigurationDTO = z.infer<typeof MailConfigurationSchema>;
|
||||||
|
|
||||||
|
export { MAIL_STATE_TRANSITIONS } from "../api/domain/mail-configuration-values";
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "@repo/shadcn-ui/lib/icons";
|
} from "@repo/shadcn-ui/lib/icons";
|
||||||
@ -18,6 +19,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { CompanyDTO } from "../../common";
|
import type { CompanyDTO } from "../../common";
|
||||||
import { AccessWorkspacePage } from "../access/access-workspace";
|
import { AccessWorkspacePage } from "../access/access-workspace";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
import { CompanyForm } from "./company-form";
|
import { CompanyForm } from "./company-form";
|
||||||
@ -106,7 +108,8 @@ type CompanyTab =
|
|||||||
| "access"
|
| "access"
|
||||||
| "assets"
|
| "assets"
|
||||||
| "inventory"
|
| "inventory"
|
||||||
| "workstations";
|
| "workstations"
|
||||||
|
| "mail";
|
||||||
|
|
||||||
export function CompanyDetail({
|
export function CompanyDetail({
|
||||||
id,
|
id,
|
||||||
@ -126,9 +129,7 @@ export function CompanyDetail({
|
|||||||
const { t: accessTranslation } = useTranslation("access");
|
const { t: accessTranslation } = useTranslation("access");
|
||||||
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [tab, setTab] = useState<
|
const [tab, setTab] = useState<CompanyTab>(embedded ? "inventory" : "people");
|
||||||
"people" | "departments" | "sites" | "access" | "assets" | "inventory" | "workstations"
|
|
||||||
>(embedded ? "inventory" : "people");
|
|
||||||
const company = query.data?.company;
|
const company = query.data?.company;
|
||||||
const layout = embedded
|
const layout = embedded
|
||||||
? {
|
? {
|
||||||
@ -205,6 +206,7 @@ export function CompanyDetail({
|
|||||||
],
|
],
|
||||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
||||||
["access", accessTranslation("title"), KeyRoundIcon],
|
["access", accessTranslation("title"), KeyRoundIcon],
|
||||||
|
["mail", t("mail.title"), MailIcon],
|
||||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
] as const
|
] as const
|
||||||
).map(([section, label, Icon]) => (
|
).map(([section, label, Icon]) => (
|
||||||
@ -250,6 +252,8 @@ function CompanySection({
|
|||||||
return (
|
return (
|
||||||
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
|
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
|
||||||
);
|
);
|
||||||
|
case "mail":
|
||||||
|
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||||
case "workstations":
|
case "workstations":
|
||||||
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
||||||
case "inventory":
|
case "inventory":
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
|
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
||||||
|
|
||||||
export const companiesSpanish = {
|
export const companiesSpanish = {
|
||||||
|
mail: mailSpanish,
|
||||||
companySpaces: "Espacios de compañías",
|
companySpaces: "Espacios de compañías",
|
||||||
globalCatalogs: "Catálogos globales",
|
globalCatalogs: "Catálogos globales",
|
||||||
supportWorkspace: "Tu espacio de soporte",
|
supportWorkspace: "Tu espacio de soporte",
|
||||||
@ -113,6 +116,7 @@ export const companiesSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const companiesEnglish = {
|
export const companiesEnglish = {
|
||||||
|
mail: mailEnglish,
|
||||||
companySpaces: "Company spaces",
|
companySpaces: "Company spaces",
|
||||||
globalCatalogs: "Global catalogs",
|
globalCatalogs: "Global catalogs",
|
||||||
supportWorkspace: "Your support workspace",
|
supportWorkspace: "Your support workspace",
|
||||||
|
|||||||
43
modules/companies/src/web/mail/mail-api.ts
Normal file
43
modules/companies/src/web/mail/mail-api.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MailConfigurationInput,
|
||||||
|
MailConfigurationResponseSchema,
|
||||||
|
MailConfigurationsResponseSchema,
|
||||||
|
} from "../../common/mail-configurations";
|
||||||
|
export const mailKeys = { company: (id: string) => ["mail-configurations", id] };
|
||||||
|
export class MailApiError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function request(
|
||||||
|
companyId: string,
|
||||||
|
values?: MailConfigurationInput,
|
||||||
|
token?: string,
|
||||||
|
id?: string
|
||||||
|
) {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mail-configurations/${companyId}${id ? `/${id}/update` : ""}`,
|
||||||
|
{
|
||||||
|
method: values ? "POST" : "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
|
||||||
|
...(values ? { body: JSON.stringify({ values }) } : {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const json: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = z.object({ code: z.string() }).safeParse(json);
|
||||||
|
throw new MailApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
export const mailApi = {
|
||||||
|
async get(companyId: string) {
|
||||||
|
return MailConfigurationsResponseSchema.parse(await request(companyId));
|
||||||
|
},
|
||||||
|
async save(companyId: string, values: MailConfigurationInput, token: string, id?: string) {
|
||||||
|
return MailConfigurationResponseSchema.parse(await request(companyId, values, token, id));
|
||||||
|
},
|
||||||
|
};
|
||||||
289
modules/companies/src/web/mail/mail-configuration-form.tsx
Normal file
289
modules/companies/src/web/mail/mail-configuration-form.tsx
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
|
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
|
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { AccessItemDTO, CompanyDTO } from "../../common";
|
||||||
|
import {
|
||||||
|
MAIL_STATE_TRANSITIONS,
|
||||||
|
type MailConfigurationDTO,
|
||||||
|
type MailConfigurationInput,
|
||||||
|
MailConfigurationInputSchema,
|
||||||
|
} from "../../common/mail-configurations";
|
||||||
|
import { accessApi, accessKeys } from "../access/access-api";
|
||||||
|
import { accessLabel } from "../access/access-labels";
|
||||||
|
|
||||||
|
import { mailApi, mailKeys } from "./mail-api";
|
||||||
|
import { MailFeedback } from "./mail-feedback";
|
||||||
|
import { MailQuickEditor } from "./mail-quick-editor";
|
||||||
|
|
||||||
|
export type QuickKind = "person" | "account" | "asset" | "authorization";
|
||||||
|
export function MailConfigurationForm({
|
||||||
|
company,
|
||||||
|
csrfToken,
|
||||||
|
configuration,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
company: CompanyDTO;
|
||||||
|
csrfToken: string;
|
||||||
|
configuration?: MailConfigurationDTO | undefined;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("companies");
|
||||||
|
const client = useQueryClient();
|
||||||
|
const prefix = useId();
|
||||||
|
const [quick, setQuick] = useState<QuickKind>();
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const access = useQuery({
|
||||||
|
queryKey: accessKeys.scope(company.id),
|
||||||
|
queryFn: () => accessApi.request(company.id),
|
||||||
|
});
|
||||||
|
const inventory = useQuery({
|
||||||
|
queryKey: inventoryKeys.company(company.id),
|
||||||
|
queryFn: () => inventoryApi.get(company.id),
|
||||||
|
});
|
||||||
|
const form = useForm<MailConfigurationInput>({
|
||||||
|
resolver: zodResolver(MailConfigurationInputSchema),
|
||||||
|
defaultValues: configuration?.values ?? {
|
||||||
|
personId: "",
|
||||||
|
accountId: "",
|
||||||
|
assetId: "",
|
||||||
|
address: "",
|
||||||
|
method: "imap",
|
||||||
|
state: "pending",
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const values = form.watch();
|
||||||
|
const items = access.data?.items ?? [];
|
||||||
|
const accounts = items.filter(
|
||||||
|
(item) => item.values.kind === "account" && item.values.usage !== "technical"
|
||||||
|
);
|
||||||
|
const authorized = hasAuthorization(items, values);
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (input: MailConfigurationInput) =>
|
||||||
|
mailApi.save(company.id, input, csrfToken, configuration?.id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await client.invalidateQueries({ queryKey: mailKeys.company(company.id) });
|
||||||
|
if (mounted.current) {
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fixed = !!configuration;
|
||||||
|
const closed =
|
||||||
|
configuration && ["removed", "never_configured"].includes(configuration.values.state);
|
||||||
|
const disabled = mutation.isPending || !!closed;
|
||||||
|
const selected = (field: "personId" | "accountId" | "assetId", value: string) => {
|
||||||
|
form.setValue(field, value);
|
||||||
|
};
|
||||||
|
const created = (field: "personId" | "accountId" | "assetId", id?: string) => {
|
||||||
|
if (mounted.current && id) {
|
||||||
|
form.setValue(field, id);
|
||||||
|
setQuick(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selectors = [
|
||||||
|
{
|
||||||
|
field: "personId",
|
||||||
|
label: "person",
|
||||||
|
create: "newPerson",
|
||||||
|
kind: "person",
|
||||||
|
options: company.people
|
||||||
|
.filter((item) => item.active || item.id === values.personId)
|
||||||
|
.map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "accountId",
|
||||||
|
label: "account",
|
||||||
|
create: "newAccount",
|
||||||
|
kind: "account",
|
||||||
|
options: accounts.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: accessLabel(item, items, company.people),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "assetId",
|
||||||
|
label: "asset",
|
||||||
|
create: "newAsset",
|
||||||
|
kind: "asset",
|
||||||
|
options: (inventory.data?.assets ?? [])
|
||||||
|
.filter((item) => item.values.status !== "retired" || item.id === values.assetId)
|
||||||
|
.map((item) => ({ value: item.id, label: item.values.name })),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<SelectWithAction
|
||||||
|
action={
|
||||||
|
fixed
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
label: t(`mail.${selector.create}`),
|
||||||
|
onClick: () => setQuick(selector.kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disabled={fixed || disabled || !access.data || !inventory.data}
|
||||||
|
id={`${prefix}-${selector.field}`}
|
||||||
|
label={t(`mail.${selector.label}`)}
|
||||||
|
onValueChange={(value) => selected(selector.field, value)}
|
||||||
|
options={selector.options}
|
||||||
|
placeholder={t("mail.choose")}
|
||||||
|
required
|
||||||
|
value={values[selector.field]}
|
||||||
|
/>
|
||||||
|
</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 ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{configuration && <p className="text-sm text-muted-foreground">{t("mail.fixed")}</p>}
|
||||||
|
{(access.isPending || inventory.isPending) && <p role="status">{t("loading")}</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} />
|
||||||
|
<ContextHelp
|
||||||
|
description={t("mail.description")}
|
||||||
|
examples={t("mail.examples")}
|
||||||
|
examplesLabel={t("mail.examplesLabel")}
|
||||||
|
guidance={t("mail.guidance")}
|
||||||
|
scopeNote={t("mail.scope")}
|
||||||
|
title={t("mail.help")}
|
||||||
|
/>
|
||||||
|
<FormActions>
|
||||||
|
<Button disabled={disabled || !access.data || !inventory.data} type="submit">
|
||||||
|
{t(mutation.isPending ? "saving" : "save")}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</form>
|
||||||
|
{quick && (
|
||||||
|
<MailQuickEditor
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
data={access.data}
|
||||||
|
kind={quick}
|
||||||
|
onClose={() => setQuick(undefined)}
|
||||||
|
onCreated={created}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 allowedStates(configuration?: MailConfigurationDTO) {
|
||||||
|
return configuration
|
||||||
|
? MAIL_STATE_TRANSITIONS[configuration.values.state]
|
||||||
|
: ["pending", "configured"];
|
||||||
|
}
|
||||||
168
modules/companies/src/web/mail/mail-configurations-page.tsx
Normal file
168
modules/companies/src/web/mail/mail-configurations-page.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
|
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 { mailApi, mailKeys } from "./mail-api";
|
||||||
|
import { MailConfigurationForm } from "./mail-configuration-form";
|
||||||
|
import { MailFeedback } from "./mail-feedback";
|
||||||
|
export function MailConfigurationsPage({
|
||||||
|
company,
|
||||||
|
csrfToken,
|
||||||
|
}: {
|
||||||
|
company: CompanyDTO;
|
||||||
|
csrfToken: string;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation("companies");
|
||||||
|
const [editor, setEditor] = useState<string>();
|
||||||
|
const [view, setView] = useState<string>();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: mailKeys.company(company.id),
|
||||||
|
queryFn: () => mailApi.get(company.id),
|
||||||
|
});
|
||||||
|
const inventory = useQuery({
|
||||||
|
queryKey: inventoryKeys.company(company.id),
|
||||||
|
queryFn: () => inventoryApi.get(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())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
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")}>
|
||||||
|
{t("mail.new")}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<Input
|
||||||
|
aria-label={t("mail.search")}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
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) && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
Promise.all([query.refetch(), inventory.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(configurations.length ? "noResults" : "mail.empty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<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}`)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{person(item.values.personId)} · {asset(item.values.assetId)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm">{t(`mail.${item.values.state}`)}</p>
|
||||||
|
{item.needsReview && (
|
||||||
|
<p className="mt-2 text-sm text-amber-700">{t("mail.review")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||||
|
{t("mail.history")}
|
||||||
|
</Button>
|
||||||
|
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||||
|
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{editor && (
|
||||||
|
<FormDialog
|
||||||
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditor(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
title={t(edited ? "mail.edit" : "mail.new")}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
<MailConfigurationForm
|
||||||
|
company={company}
|
||||||
|
configuration={edited}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
onSaved={() => setEditor(undefined)}
|
||||||
|
/>
|
||||||
|
</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="rounded-md border p-3 text-sm" key={revision.id}>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(revision.recordedAt))}{" "}
|
||||||
|
· {revision.actorName}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{revision.values.address} · {t(`mail.${revision.values.method}`)} ·{" "}
|
||||||
|
{t(`mail.${revision.values.state}`)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{person(revision.values.personId)} · {asset(revision.values.assetId)}
|
||||||
|
</p>
|
||||||
|
{revision.values.notes && (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</FormDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
modules/companies/src/web/mail/mail-feedback.tsx
Normal file
11
modules/companies/src/web/mail/mail-feedback.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { MailApiError } from "./mail-api";
|
||||||
|
export function MailFeedback({ error }: { error: unknown }) {
|
||||||
|
const { t } = useTranslation("companies");
|
||||||
|
return error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{t(`mail.errors.${error instanceof MailApiError ? error.code : "INTERNAL_ERROR"}`)}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
118
modules/companies/src/web/mail/mail-i18n.ts
Normal file
118
modules/companies/src/web/mail/mail-i18n.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
export const mailSpanish = {
|
||||||
|
title: "Correos en dispositivos",
|
||||||
|
new: "Configurar correo",
|
||||||
|
edit: "Editar configuración",
|
||||||
|
empty: "Todavía no hay correos registrados en dispositivos.",
|
||||||
|
search: "Buscar correo, persona o dispositivo…",
|
||||||
|
person: "Persona",
|
||||||
|
account: "Cuenta informática",
|
||||||
|
asset: "Dispositivo",
|
||||||
|
choose: "Selecciona un valor",
|
||||||
|
address: "Dirección de correo",
|
||||||
|
method: "Método de acceso",
|
||||||
|
state: "Estado",
|
||||||
|
notes: "Notas (opcional)",
|
||||||
|
pop: "POP",
|
||||||
|
imap: "IMAP",
|
||||||
|
exchange: "Exchange",
|
||||||
|
other: "Otro",
|
||||||
|
pending: "Pendiente de configurar",
|
||||||
|
configured: "Configurado",
|
||||||
|
removal_pending: "Pendiente de retirar",
|
||||||
|
removed: "Retirado",
|
||||||
|
never_configured: "No llegó a configurarse",
|
||||||
|
review: "Revisar acceso: autorización, persona, cuenta o dispositivo no vigente.",
|
||||||
|
noAuthorization:
|
||||||
|
"Autoriza esta cuenta para la persona antes de guardar una configuración activa.",
|
||||||
|
authorize: "Autorizar cuenta para esta persona",
|
||||||
|
newAccount: "Nueva cuenta",
|
||||||
|
newAsset: "Nuevo dispositivo",
|
||||||
|
newPerson: "Nueva persona",
|
||||||
|
history: "Historial",
|
||||||
|
help: "¿Para qué sirve esta configuración?",
|
||||||
|
description:
|
||||||
|
"Registra qué correo está configurado en cada dispositivo de una persona y cómo se accede a él.",
|
||||||
|
examplesLabel: "Ejemplos y recomendaciones",
|
||||||
|
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.",
|
||||||
|
scope:
|
||||||
|
"Puedes registrar varias cuentas por persona y varios dispositivos por cuenta. Los cambios conservan el historial.",
|
||||||
|
fixed:
|
||||||
|
"Persona, cuenta y dispositivo quedan fijados. Para cambiar alguno, retira esta configuración y crea otra.",
|
||||||
|
errors: {
|
||||||
|
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_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.",
|
||||||
|
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.",
|
||||||
|
COMPANY_NOT_FOUND: "La compañía no está disponible.",
|
||||||
|
UNAUTHENTICATED: "La sesión ha caducado. Vuelve a entrar.",
|
||||||
|
FORBIDDEN: "Recarga la página para autorizar la operación.",
|
||||||
|
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const mailEnglish = {
|
||||||
|
title: "Email on devices",
|
||||||
|
new: "Configure email",
|
||||||
|
edit: "Edit configuration",
|
||||||
|
empty: "No email configurations on devices yet.",
|
||||||
|
search: "Find email, person or device…",
|
||||||
|
person: "Person",
|
||||||
|
account: "IT account",
|
||||||
|
asset: "Device",
|
||||||
|
choose: "Choose a value",
|
||||||
|
address: "Email address",
|
||||||
|
method: "Access method",
|
||||||
|
state: "Status",
|
||||||
|
notes: "Notes (optional)",
|
||||||
|
pop: "POP",
|
||||||
|
imap: "IMAP",
|
||||||
|
exchange: "Exchange",
|
||||||
|
other: "Other",
|
||||||
|
pending: "Pending configuration",
|
||||||
|
configured: "Configured",
|
||||||
|
removal_pending: "Pending removal",
|
||||||
|
removed: "Removed",
|
||||||
|
never_configured: "Never configured",
|
||||||
|
review: "Review access: authorization, person, account or device 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",
|
||||||
|
newAsset: "New device",
|
||||||
|
newPerson: "New person",
|
||||||
|
history: "History",
|
||||||
|
help: "What is this configuration for?",
|
||||||
|
description: "Record which email is configured on each person's device and how it is accessed.",
|
||||||
|
examplesLabel: "Examples and guidance",
|
||||||
|
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.",
|
||||||
|
scope:
|
||||||
|
"Multiple accounts per person and multiple devices per account are supported. Changes retain history.",
|
||||||
|
fixed:
|
||||||
|
"Person, account and device are fixed. To change any of them, remove this configuration and create another.",
|
||||||
|
errors: {
|
||||||
|
INVALID_REQUEST: "Review the fields; enter a valid email address.",
|
||||||
|
MAIL_SCOPE_MISMATCH: "Person, account and device 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.",
|
||||||
|
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.",
|
||||||
|
COMPANY_NOT_FOUND: "The company is unavailable.",
|
||||||
|
UNAUTHENTICATED: "Your session expired. Sign in again.",
|
||||||
|
FORBIDDEN: "Reload the page to authorize the operation.",
|
||||||
|
INTERNAL_ERROR: "Unable to complete the operation. Try again.",
|
||||||
|
},
|
||||||
|
};
|
||||||
127
modules/companies/src/web/mail/mail-quick-editor.tsx
Normal file
127
modules/companies/src/web/mail/mail-quick-editor.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { AssetForm, catalogApi, catalogKeys } from "@metis/assets/client";
|
||||||
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { AccessResponseDTO, CompanyDTO } from "../../common";
|
||||||
|
import type { MailConfigurationInput } from "../../common/mail-configurations";
|
||||||
|
import { AccessForm } from "../access/access-form";
|
||||||
|
import { PersonForm } from "../components/person-form";
|
||||||
|
|
||||||
|
import type { QuickKind } from "./mail-configuration-form";
|
||||||
|
import { MailFeedback } from "./mail-feedback";
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
person: "newPerson",
|
||||||
|
account: "newAccount",
|
||||||
|
asset: "newAsset",
|
||||||
|
authorization: "authorize",
|
||||||
|
} as const;
|
||||||
|
export function MailQuickEditor({
|
||||||
|
kind,
|
||||||
|
company,
|
||||||
|
csrfToken,
|
||||||
|
values,
|
||||||
|
data,
|
||||||
|
onCreated,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
kind: QuickKind;
|
||||||
|
company: CompanyDTO;
|
||||||
|
csrfToken: string;
|
||||||
|
values: MailConfigurationInput;
|
||||||
|
data: AccessResponseDTO | undefined;
|
||||||
|
onCreated: (field: "personId" | "accountId" | "assetId", id?: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("companies");
|
||||||
|
const active = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
active.current = true;
|
||||||
|
return () => {
|
||||||
|
active.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const created = (field: "personId" | "accountId" | "assetId", id?: string) => {
|
||||||
|
if (active.current) {
|
||||||
|
onCreated(field, id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
active.current = false;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const catalog = useQuery({
|
||||||
|
queryKey: catalogKeys.scope(company.id),
|
||||||
|
queryFn: () => catalogApi.get(company.id),
|
||||||
|
enabled: kind === "asset",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<FormDialog
|
||||||
|
cancelLabel={t("cancel")}
|
||||||
|
formActions
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
title={t(`mail.${titles[kind]}`)}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
{kind === "person" && (
|
||||||
|
<PersonForm
|
||||||
|
companyId={company.id}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
onSaved={(id) => created("personId", id)}
|
||||||
|
requireActive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(kind === "account" || kind === "authorization") && data && (
|
||||||
|
<AccessForm
|
||||||
|
allowQuickCreate={false}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
data={data}
|
||||||
|
initialValues={
|
||||||
|
kind === "authorization"
|
||||||
|
? { personId: values.personId, accountId: values.accountId }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
kind={kind === "account" ? "account" : "account_person"}
|
||||||
|
lockedFields={kind === "authorization" ? ["personId", "accountId"] : []}
|
||||||
|
onSaved={(id) => {
|
||||||
|
if (kind === "account") {
|
||||||
|
created("accountId", id);
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
people={company.people}
|
||||||
|
scope={company.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{kind === "asset" &&
|
||||||
|
(catalog.data ? (
|
||||||
|
<AssetForm
|
||||||
|
allowQuickCreate={false}
|
||||||
|
companyId={company.id}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
items={catalog.data.items}
|
||||||
|
lookups={{
|
||||||
|
sites: company.sites,
|
||||||
|
people: company.people,
|
||||||
|
onCreatePerson: () => undefined,
|
||||||
|
onCreateSite: () => undefined,
|
||||||
|
}}
|
||||||
|
onSaved={(id) => created("assetId", id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p role="status">{t("loading")}</p>
|
||||||
|
<MailFeedback error={catalog.error} />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</FormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
modules/companies/src/web/mail/mail.test.tsx
Normal file
81
modules/companies/src/web/mail/mail.test.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { inventoryKeys } from "@metis/assets/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createInstance } from "i18next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
|
import { accessKeys } from "../access/access-api";
|
||||||
|
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||||
|
|
||||||
|
import { mailKeys } from "./mail-api";
|
||||||
|
import { MailConfigurationForm } from "./mail-configuration-form";
|
||||||
|
import { MailConfigurationsPage } from "./mail-configurations-page";
|
||||||
|
|
||||||
|
const company = {
|
||||||
|
id: "0199b200-0000-7000-8000-000000000001",
|
||||||
|
name: "Empresa test",
|
||||||
|
active: true,
|
||||||
|
people: [],
|
||||||
|
contacts: [],
|
||||||
|
sites: [],
|
||||||
|
departments: [],
|
||||||
|
assignments: [],
|
||||||
|
};
|
||||||
|
async function render(language: string, node: ReactNode) {
|
||||||
|
const i18n = createInstance();
|
||||||
|
await i18n.init({
|
||||||
|
lng: language,
|
||||||
|
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
||||||
|
});
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||||
|
});
|
||||||
|
client.setQueryData(mailKeys.company(company.id), { configurations: [] });
|
||||||
|
client.setQueryData(accessKeys.scope(company.id), { items: [], reviews: [], coverage: [] });
|
||||||
|
client.setQueryData(inventoryKeys.company(company.id), {
|
||||||
|
assets: [],
|
||||||
|
relationships: [],
|
||||||
|
workstations: [],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test("correo: listado traducido, alta en cabecera y formularios ocultos", async () => {
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<MailConfigurationsPage company={company} csrfToken="test" />
|
||||||
|
);
|
||||||
|
assert.equal(html.includes("<form"), false);
|
||||||
|
assert.ok(html.includes(language === "es" ? "Configurar correo" : "Configure email"));
|
||||||
|
assert.ok(
|
||||||
|
html.includes(language === "es" ? "Todavía no hay correos" : "No email configurations")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("correo: selectores reutilizables con altas y ayuda inmediatamente antes de guardar", async () => {
|
||||||
|
const html = await render(
|
||||||
|
"es",
|
||||||
|
<MailConfigurationForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||||
|
);
|
||||||
|
for (const label of ["Nueva persona", "Nueva cuenta", "Nuevo dispositivo"]) {
|
||||||
|
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("IMAP"));
|
||||||
|
assert.ok(html.indexOf("¿Para qué sirve esta configuración?") < html.indexOf("Guardar"));
|
||||||
|
assert.ok(html.includes("varias cuentas por persona"));
|
||||||
|
assert.equal(html.includes('type="password"'), false);
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@ export {
|
|||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user