feat: initialize pnpm workspace and add configuration files

- Added pnpm-workspace.yaml to define package structure.
- Created prepare-local-env.py script for local environment setup.
- Introduced tsconfig.json for TypeScript configuration.
- Added turbo.json for task management with Turbo.
This commit is contained in:
David Arranz 2026-10-03 12:36:03 +02:00
commit e3f92fa534
161 changed files with 12974 additions and 0 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
PORT=3001
APP_ORIGIN=http://localhost:5173
DB_HOST=127.0.0.1
DB_PORT=3307
DB_NAME=metis_desk
DB_USER=metis
DB_PASSWORD=replace-with-a-random-value
DB_ROOT_PASSWORD=replace-with-another-random-value
SESSION_SECRET=replace-with-at-least-32-random-characters
NODE_ENV=development

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.turbo/
coverage/
.env
.env.*
!.env.example
*.tsbuildinfo

1
.node-version Normal file
View File

@ -0,0 +1 @@
24.20.0

93
AGENTS.md Normal file
View File

@ -0,0 +1,93 @@
# Instrucciones de Metis Desk
## Alcance y entorno
Proyecto independiente de factuges-erp. Se conserva su estilo, no sus funcionalidades ni dependencias locales.
El repositorio vive en `/home/david/dev/metis-desk`, en WSL2 Ubuntu-24.04.
Ejecutar las herramientas del proyecto dentro de WSL con bash, nunca desde Windows, rutas UNC o /mnt/c.
Antes de modificar código: comprobar pwd, git status --short, node --version y pnpm --version.
Usar Node.js 24.x y la versión de PNPM fijada en package.json.
No imprimir, copiar ni versionar .env locales o secretos.
## Convenciones
- Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano.
- Ficheros y directorios en kebab-case. Clases, tipos e interfaces en PascalCase.
- Variables, funciones y métodos en camelCase; constantes globales en UPPER_SNAKE_CASE.
- Interfaces con prefijo I cuando el área ya siga esa convención.
- Documentación en /docs, con nombres de ficheros en castellano.
- Comentarios que expliquen decisiones, riesgos o contratos, nunca que repitan el código.
- Mantener es y en para textos de interfaz; no insertar literales en áreas con i18n.
## Disciplina
Aplicar SOLID, Clean Code, DRY, KISS, YAGNI, SSOT, fail fast y programación defensiva.
Priorizar claridad y explicitud frente a abstracción prematura.
Cambios incrementales y acotados. No renombrar, reformatear o mover código ajeno a la tarea.
No crear capas, factories, genéricos, eventos, contenedores DI ni repositorios base sin necesidad real.
Justificar cada nueva dependencia. Preferir APIs nativas cuando basten.
No añadir supresiones de TypeScript o Biome sin justificación.
No eliminar legado sin buscar y clasificar sus referencias en todo el monorepo.
## TypeScript y asincronía
- Importar tipos explícitamente con import type en la cabecera.
- No usar import("...").Tipo en código ordinario.
- Evitar any, assertions y non-null assertions salvo garantías verificables.
- Inferir DTOs de schemas Zod; no duplicarlos mediante interfaces.
- Usar unions para conjuntos cerrados y mantener switches exhaustivos.
- No usar Partial si el contrato ya representa la opcionalidad.
- No usar el operador void para descartar promesas.
- Toda promesa se espera, se devuelve o gestiona su error expresamente.
- No dejar promesas flotantes ni capturas que oculten fallos.
- Comprobar el contrato del callback antes de convertirlo a async.
- Usar Result para fallos de negocio y Maybe para ausencia semántica cuando se implementen esas operaciones.
- Collection solo cuando el conjunto necesite comportamiento de dominio; no introducir estos patrones sin consumidores.
## Arquitectura y contratos
Las fuentes normativas se consultan, no se duplican:
- [Capas del servidor](docs/arquitectura/capas-del-servidor.md)
- [Composición de módulos](docs/arquitectura/composicion-de-modulos.md)
- [Flujo HTTP](docs/arquitectura/flujo-http.md)
- [Contratos HTTP](docs/arquitectura/contratos-http.md)
- [Pruebas](docs/arquitectura/pruebas.md)
- [Modelo DDD e identificadores](docs/arquitectura/modelo-ddd-e-identificadores.md)
## Seguridad y persistencia
- Menor privilegio y validación de toda entrada externa.
- Autenticación y autorización antes de acceder a datos de clientes.
- El cliente atendido no es el tenant de la empresa que presta soporte.
- Verificar siempre permisos y pertenencia; no confiar en companyId enviado por el navegador.
- No exponer SQL, stacks, datos sensibles o credenciales en respuestas ni logs.
- Registrar identificadores de petición y resultados suficientes para diagnosticar fallos.
- MariaDB y Sequelize v6 para persistencia; ORM exclusivamente en Infrastructure.
- Consultas parametrizadas, transacciones para cambios atómicos y restricciones en base de datos.
- Migraciones explícitas; no usar sync({ alter: true }) como sustituto.
- Toda entidad de negocio, incluidas las entidades internas de un agregado, tiene identidad UniqueID (UUID v7 para nuevas entidades), siguiendo factuges-erp.
- Declarar Value Objects, DomainEntity y AggregateRoot según docs/arquitectura/modelo-ddd-e-identificadores.md.
- Mantener semántica de create, rehydrate y update según la arquitectura.
- Comparar cambios de dominio semánticamente, no mediante JSON.stringify.
- Añadir idempotencia donde una repetición pueda producir efectos duplicados.
## Frontend
React, TanStack Query, Tailwind y shadcn/ui (Base UI, estilo Vega). Los componentes compartidos viven en @repo/shadcn-ui. Incorporar TanStack Table y React Hook Form cuando haya consumidores.
Estado remoto en TanStack Query; claves centralizadas e invalidación explícita después de mutaciones.
No duplicar estado remoto ni usar efectos para derivar valores calculables durante render.
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
Separar componentes por responsabilidades reales, sin fragmentar JSX trivial.
Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios sencillos en modal shadcn/ui y formularios complejos o extensos en una página propia. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario.
## Validación y entrega
Ejecutar pnpm typecheck, pnpm lint, pnpm test, pnpm check y pnpm build en cambios transversales.
Preferir comprobaciones dirigidas al paquete cuando sean suficientes.
Añadir pruebas de comportamiento observable: válidos, inválidos, límites e idempotencia cuando corresponda.
Tests deterministas, sin esperas arbitrarias ni desactivaciones.
Informar cambios, archivos principales, comandos ejecutados, resultados y pendientes reales.
No afirmar validaciones no ejecutadas ni ocultar fallos conocidos.
Precedencia: instrucciones actuales del usuario, AGENTS.md más cercano, este archivo y convenciones existentes.

13
README.md Normal file
View File

@ -0,0 +1,13 @@
# Metis Desk
Aplicación interna para soporte técnico a pequeñas empresas.
- [Preparación y ejecución](docs/puesta-en-marcha.md)
- [Modelo DDD e identificadores](docs/arquitectura/modelo-ddd-e-identificadores.md)
- [Organización del código](docs/arquitectura/capas-del-servidor.md)
- [Usuarios y acceso](docs/usuarios-y-acceso.md)
- [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md)
- [Componentes de interfaz](docs/componentes-de-interfaz.md)
- [Alcance y siguientes entregas](docs/plan-de-desarrollo.md)
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con contactos y sedes. Inventario y tickets se incorporarán progresivamente.

31
apps/server/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@metis/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
"typecheck": "tsc -p tsconfig.json",
"test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test src/identity.test.ts",
"build": "tsup --config tsup.config.ts",
"start": "node --env-file=../../.env dist/index.js"
},
"dependencies": {
"@metis/core": "workspace:*",
"express": "4.22.1",
"helmet": "8.1.0",
"zod": "4.3.6",
"@metis/identity": "workspace:*",
"sequelize": "6.37.8",
"mariadb": "3.5.4",
"express-session": "1.19.0",
"connect-session-sequelize": "8.0.6",
"@metis/companies": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/express": "4.17.21",
"tsup": "8.5.1",
"@types/express-session": "^1.18.2"
}
}

View File

@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { test } from "node:test";
import { GetHealthResponseSchema, HttpErrorResponseSchema } from "@metis/core/common";
import { createApp } from "./app";
import type { HttpLogEntry } from "./infrastructure/http/http-logger";
test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async () => {
const entries: HttpLogEntry[] = [];
const server = createApp((entry) => entries.push(entry)).listen(0, "127.0.0.1");
try {
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}`;
const response = await fetch(`${baseUrl}/api/__health`);
assert.equal(response.status, 200);
const health = GetHealthResponseSchema.parse(await response.json());
assert.equal(health.status, "ok");
assert.equal(response.headers.get("x-powered-by"), null);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.ok(response.headers.get("x-content-type-options"));
for (const [path, status, code] of [
["/api/__health?token=secret-test-value", 400, "INVALID_REQUEST"],
["/api/customers", 404, "NOT_FOUND"],
] as const) {
const failure = await fetch(`${baseUrl}${path}`);
assert.equal(failure.status, status);
const body = HttpErrorResponseSchema.parse(await failure.json());
assert.equal(body.code, code);
assert.equal(body.requestId, failure.headers.get("x-request-id"));
}
assert.equal(entries.length, 3);
assert.equal(new Set(entries.map((entry) => entry.requestId)).size, 3);
assert.equal(JSON.stringify(entries).includes("secret-test-value"), false);
assert.ok(entries.every((entry) => entry.durationMs >= 0));
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}
});

63
apps/server/src/app.ts Normal file
View File

@ -0,0 +1,63 @@
import { randomUUID } from "node:crypto";
import type { HttpErrorResponseDTO } from "@metis/core/common";
import { registerCoreRoutes } from "@metis/core/module";
import type { Application, ErrorRequestHandler, RequestHandler } from "express";
import express from "express";
import helmet from "helmet";
import type { HttpLogger } from "./infrastructure/http/http-logger";
import { logHttpRequest } from "./infrastructure/http/http-logger";
export function createApp(
logger: HttpLogger = logHttpRequest,
identity?: { middleware: RequestHandler; register: (app: Application) => void }
) {
const app = express();
app.disable("x-powered-by");
app.use(helmet());
app.use((request, response, next) => {
const requestId = randomUUID();
const startedAt = performance.now();
response.setHeader("x-request-id", requestId);
response.setHeader("cache-control", "no-store");
response.once("finish", () => {
logger({
event: "http_request",
requestId,
method: request.method,
statusCode: response.statusCode,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
});
});
next();
});
app.use(express.json({ limit: "64kb" }));
if (identity) {
app.use(identity.middleware);
identity.register(app);
}
registerCoreRoutes(app);
app.use((_request, response) => {
const error: HttpErrorResponseDTO = {
code: "NOT_FOUND",
requestId: response.getHeader("x-request-id")?.toString() ?? "",
};
response.status(404).json(error);
});
const errorHandler: ErrorRequestHandler = (_error: unknown, _request, response, next) => {
if (response.headersSent) {
next(_error);
return;
}
const error: HttpErrorResponseDTO = {
code: "INTERNAL_ERROR",
requestId: response.getHeader("x-request-id")?.toString() ?? "",
};
response.status(500).json(error);
};
app.use(errorHandler);
return app;
}

View File

@ -0,0 +1,29 @@
import { Sequelize } from "sequelize";
import { z } from "zod/v4";
const DatabaseEnvironmentSchema = z.object({
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().positive(),
DB_NAME: z.string().regex(/^[a-z_]+$/),
DB_USER: z.string().min(1),
DB_PASSWORD: z.string().min(1),
SESSION_SECRET: z.string().min(32),
APP_ORIGIN: z.url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});
export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) {
const parsed = DatabaseEnvironmentSchema.safeParse(source);
if (!parsed.success) {
throw new Error("Configuración de persistencia o sesión incompleta.");
}
return parsed.data;
}
export function createDatabase(environment: ReturnType<typeof readDatabaseEnvironment>) {
return new Sequelize(environment.DB_NAME, environment.DB_USER, environment.DB_PASSWORD, {
dialect: "mariadb",
host: environment.DB_HOST,
port: environment.DB_PORT,
logging: false,
pool: { max: 5, min: 0, idle: 10000 },
});
}

View File

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readServerEnvironment } from "./env-schema";
test("el puerto usa un valor local por defecto y acepta los límites válidos", () => {
assert.equal(readServerEnvironment({}).PORT, 3001);
assert.equal(readServerEnvironment({ PORT: "1" }).PORT, 1);
assert.equal(readServerEnvironment({ PORT: "65535" }).PORT, 65535);
});
test("el arranque rechaza puertos inválidos sin revelar el valor recibido", () => {
for (const port of ["", "0", "-1", "65536", "1.5", "secret-value"]) {
assert.throws(() => readServerEnvironment({ PORT: port }), {
message: "Invalid server configuration: PORT must be between 1 and 65535",
});
}
});

View File

@ -0,0 +1,14 @@
import { z } from "zod/v4";
const ServerEnvironmentSchema = z.object({
PORT: z.coerce.number().int().min(1).max(65535),
});
/** El arranque rechaza una configuración inválida antes de abrir el puerto. */
export function readServerEnvironment(environment: NodeJS.ProcessEnv) {
const result = ServerEnvironmentSchema.safeParse({ PORT: environment.PORT ?? "3001" });
if (!result.success) {
throw new Error("Invalid server configuration: PORT must be between 1 and 65535");
}
return result.data;
}

View File

@ -0,0 +1,431 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { test } from "node:test";
import {
CompaniesResponseSchema,
CompanyErrorResponseSchema,
CompanyResponseSchema,
} from "@metis/companies/common";
import {
IdentityErrorResponseSchema,
SessionResponseSchema,
UserResponseSchema,
UsersResponseSchema,
} from "@metis/identity/common";
import { QueryTypes } from "sequelize";
import { createApp } from "./app";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
const environment = readDatabaseEnvironment(process.env);
// Este proceso solo puede limpiar la base de pruebas dedicada.
assert.equal(environment.DB_NAME, "metis_desk_test");
const database = createDatabase(environment);
const identity = await createIdentityRuntime(database, environment);
const server = createApp(() => undefined, identity).listen(0, "127.0.0.1");
try {
await once(server, "listening");
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
await database.query("DELETE FROM http_sessions");
await database.query("DELETE FROM users");
await database.query("DELETE FROM company_contacts");
await database.query("DELETE FROM company_sites");
await database.query("DELETE FROM companies");
const address = server.address();
assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}/api`;
function browser() {
let cookie = "";
let csrf = "";
return {
async request(
path: string,
body?: unknown,
origin = environment.APP_ORIGIN,
token?: string
) {
const response = await fetch(`${baseUrl}${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
cookie,
origin,
"content-type": "application/json",
"x-csrf-token": token ?? csrf,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const newCookie = response.headers.get("set-cookie");
if (newCookie) {
cookie = newCookie.split(";")[0] ?? "";
}
return response;
},
async session() {
const response = await this.request("/auth/session");
assert.equal(response.status, 200);
const data = SessionResponseSchema.parse(await response.json());
csrf = data.csrfToken;
return data;
},
cookie: () => cookie,
};
}
async function expectError(response: Response, status: number, code: string) {
assert.equal(response.status, status);
assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code);
}
const admin = browser();
const competitor = browser();
assert.equal((await admin.session()).setupRequired, true);
await competitor.session();
await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED");
const first = {
name: "Admin test",
email: "ADMIN@example.test",
password: "Admin123",
};
await expectError(
await admin.request("/auth/bootstrap", first, "https://other.example"),
403,
"FORBIDDEN"
);
await expectError(
await admin.request("/auth/bootstrap", first, environment.APP_ORIGIN, "á".repeat(64)),
403,
"FORBIDDEN"
);
await expectError(
await admin.request("/auth/bootstrap", { ...first, password: "1234567" }),
400,
"INVALID_REQUEST"
);
const beforeCookie = admin.cookie();
const attempts = await Promise.all([
admin.request("/auth/bootstrap", first),
competitor.request("/auth/bootstrap", { ...first, email: "other@example.test" }),
]);
assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]);
const winnerIndex = attempts.findIndex((response) => response.status === 201);
const administrator = winnerIndex === 0 ? admin : competitor;
const winnerResponse = attempts[winnerIndex];
assert.ok(winnerResponse);
const initial = UserResponseSchema.parse(await winnerResponse.json()).user;
assert.equal(initial.isInitialAdmin, true);
assert.equal(initial.id.charAt(14), "7");
if (winnerIndex === 0) {
assert.notEqual(admin.cookie(), beforeCookie);
}
assert.equal((await administrator.session()).setupRequired, false);
await expectError(await administrator.request("/auth/bootstrap", first), 409, "SETUP_CLOSED");
await expectError(
await administrator.request(`/users/${initial.id}/update`, {
name: initial.name,
email: initial.email,
active: false,
}),
409,
"ADMIN_PROTECTED"
);
await expectError(
await administrator.request("/users", {
name: "Intruso",
email: "intruder@example.test",
password: first.password,
isInitialAdmin: true,
}),
400,
"INVALID_REQUEST"
);
await expectError(
await administrator.request(`/users/${initial.id.toUpperCase()}/reset-password`, {
password: "Admin123",
}),
409,
"ADMIN_PROTECTED"
);
const person = {
name: "Operador test",
email: "operator@example.test",
password: "User1234",
};
const created = await administrator.request("/users", person);
assert.equal(created.status, 201);
const user = UserResponseSchema.parse(await created.json()).user;
assert.equal(user.isInitialAdmin, false);
await expectError(
await administrator.request("/users", { ...person, email: "OPERATOR@example.test" }),
409,
"EMAIL_EXISTS"
);
const list = UsersResponseSchema.parse(await (await administrator.request("/users")).json());
assert.equal(list.users.length, 2);
assert.equal(JSON.stringify(list).includes("passwordHash"), false);
const operator = browser();
await operator.session();
await expectError(
await operator.request("/auth/login", { email: person.email, password: "incorrect" }),
401,
"INVALID_CREDENTIALS"
);
assert.equal(
(await operator.request("/auth/login", { email: person.email, password: person.password }))
.status,
200
);
await operator.session();
await expectError(await operator.request("/users"), 403, "FORBIDDEN");
await expectError(
await operator.request("/auth/change-password", {
currentPassword: "incorrect",
password: "Nuevo123",
}),
401,
"INVALID_CREDENTIALS"
);
const oldSession = browser();
await oldSession.session();
assert.equal(
(await oldSession.request("/auth/login", { email: person.email, password: person.password }))
.status,
200
);
await oldSession.session();
const changedPassword = "Nuevo123";
assert.equal(
(
await operator.request("/auth/change-password", {
currentPassword: person.password,
password: changedPassword,
})
).status,
200
);
assert.equal((await operator.session()).user?.id, user.id);
assert.equal((await oldSession.session()).user, null);
const resetPassword = "Reset123";
assert.equal(
(await administrator.request(`/users/${user.id}/reset-password`, { password: resetPassword }))
.status,
200
);
assert.equal((await operator.session()).user, null);
await expectError(
await operator.request("/auth/login", { email: person.email, password: changedPassword }),
401,
"INVALID_CREDENTIALS"
);
assert.equal(
(await operator.request("/auth/login", { email: person.email, password: resetPassword }))
.status,
200
);
await operator.session();
assert.equal(
(
await administrator.request(`/users/${user.id}/update`, {
name: "Operador editado",
email: person.email,
active: false,
})
).status,
200
);
assert.equal((await operator.session()).user, null);
await expectError(
await operator.request("/auth/login", { email: person.email, password: resetPassword }),
401,
"INVALID_CREDENTIALS"
);
assert.equal(
(
await administrator.request(`/users/${user.id}/update`, {
name: person.name,
email: person.email,
active: true,
})
).status,
200
);
assert.equal(
(await operator.request("/auth/login", { email: person.email, password: resetPassword }))
.status,
200
);
await operator.session();
const anonymousCompanyBrowser = browser();
await anonymousCompanyBrowser.session();
await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED");
const companyCreated = await operator.request("/companies", {
name: "Compañía de pruebas",
active: true,
});
assert.equal(companyCreated.status, 201);
const customer = CompanyResponseSchema.parse(await companyCreated.json()).company;
assert.equal(customer.id.charAt(14), "7");
const secondCreated = await operator.request("/companies", {
name: "Segunda compañía",
active: true,
});
const secondCompany = CompanyResponseSchema.parse(await secondCreated.json()).company;
const personContact = {
name: "Ana Test",
position: "Responsable IT",
email: "ANA@example.test",
phone: "+34 900 123 456",
active: true,
};
await expectError(
await operator.request(
`/companies/${customer.id}/contacts`,
personContact,
"https://other.example"
),
403,
"FORBIDDEN"
);
await expectError(
await operator.request(`/companies/${customer.id}/contacts`, {
...personContact,
phone: "invalid",
}),
400,
"INVALID_REQUEST"
);
const addedContact = await operator.request(
`/companies/${customer.id}/contacts`,
personContact
);
assert.equal(addedContact.status, 201);
const afterContact = CompanyResponseSchema.parse(await addedContact.json()).company;
const contact = afterContact.contacts[0];
assert.ok(contact);
assert.equal(contact.companyId, customer.id);
assert.equal(contact.id.charAt(14), "7");
assert.equal(contact.email, "ana@example.test");
const crossContact = await operator.request(
`/companies/${secondCompany.id}/contacts/${contact.id}/update`,
personContact
);
assert.equal(crossContact.status, 404);
assert.equal(
CompanyErrorResponseSchema.parse(await crossContact.json()).code,
"CONTACT_NOT_FOUND"
);
const changedContact = await operator.request(
`/companies/${customer.id}/contacts/${contact.id}/update`,
{ ...personContact, name: "Ana Actualizada", active: false }
);
assert.equal(changedContact.status, 200);
assert.equal(
CompanyResponseSchema.parse(await changedContact.json()).company.contacts[0]?.active,
false
);
const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, {
name: "Almacén",
});
assert.equal(noAddressSite.status, 201);
const emptySite = CompanyResponseSchema.parse(await noAddressSite.json()).company.sites[0];
assert.ok(emptySite);
assert.equal(emptySite.address, null);
const siteAddress = "Calle Mayor, 10\n28001 Madrid";
const changedSite = await operator.request(
`/companies/${customer.id}/sites/${emptySite.id}/update`,
{ name: "Sede central", address: siteAddress }
);
assert.equal(changedSite.status, 200);
assert.equal(
CompanyResponseSchema.parse(await changedSite.json()).company.sites[0]?.address,
siteAddress
);
const crossSite = await operator.request(
`/companies/${secondCompany.id}/sites/${emptySite.id}/update`,
{ name: "Ajena", address: null }
);
assert.equal(crossSite.status, 404);
const largeAddress = "Á".repeat(10000);
assert.equal(
(
await operator.request(`/companies/${customer.id}/sites`, {
name: "Oficina",
address: largeAddress,
})
).status,
201
);
assert.equal(
(
await operator.request(`/companies/${customer.id}/sites`, {
name: "Demasiado",
address: "a".repeat(10001),
})
).status,
400
);
const editedCompany = await operator.request(`/companies/${customer.id}/update`, {
name: "Compañía actualizada",
active: false,
});
assert.equal(editedCompany.status, 200);
const details = CompanyResponseSchema.parse(
await (await operator.request(`/companies/${customer.id}`)).json()
).company;
assert.equal(details.id, customer.id);
assert.equal(details.active, false);
assert.equal(details.contacts[0]?.id, contact.id);
assert.equal(details.sites.length, 2);
const companiesList = CompaniesResponseSchema.parse(
await (await operator.request("/companies")).json()
);
assert.equal(companiesList.companies.length, 2);
await assert.rejects(
database.query("DELETE FROM companies WHERE id = :id", { replacements: { id: customer.id } })
);
const clearedSite = await operator.request(
`/companies/${customer.id}/sites/${emptySite.id}/update`,
{ name: "Sede central", address: null }
);
assert.equal(clearedSite.status, 200);
assert.equal((await operator.request("/auth/logout", {})).status, 200);
assert.equal((await operator.session()).user, null);
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
await assert.rejects(
database.query("UPDATE users SET active = 0 WHERE id = :id", {
replacements: { id: initial.id },
})
);
await assert.rejects(
database.query("DELETE FROM users WHERE id = :id", { replacements: { id: initial.id } })
);
const stored = await database.query<{ passwordHash: string }>(
"SELECT passwordHash FROM users WHERE id = :id",
{ replacements: { id: user.id }, type: QueryTypes.SELECT }
);
assert.ok(stored[0]?.passwordHash.startsWith("scrypt-v1$"));
assert.equal(JSON.stringify(stored).includes(resetPassword), false);
// Al acumular fallos se rechazan los siguientes intentos.
let limited = false;
for (let index = 0; index < 12; index += 1) {
const response = await operator.request("/auth/login", {
email: "missing@example.test",
password: "wrong",
});
if (response.status === 429) {
limited = true;
break;
}
assert.equal(response.status, 401);
}
assert.equal(limited, true);
} finally {
identity.stop();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
await database.close();
}
});

42
apps/server/src/index.ts Normal file
View File

@ -0,0 +1,42 @@
import { createApp } from "./app";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { readServerEnvironment } from "./config/env-schema";
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
async function start(): Promise<void> {
const environment = readServerEnvironment(process.env);
const databaseEnvironment = readDatabaseEnvironment(process.env);
const database = createDatabase(databaseEnvironment);
try {
const identity = await createIdentityRuntime(database, databaseEnvironment);
const server = createApp(undefined, identity).listen(environment.PORT, "127.0.0.1", () => {
console.info(JSON.stringify({ event: "server_started", port: environment.PORT }));
});
server.on("error", () => {
identity.stop();
database
.close()
.catch(() => console.error(JSON.stringify({ event: "database_close_failed" })));
console.error(JSON.stringify({ event: "server_start_failed" }));
process.exitCode = 1;
});
const shutdown = () => {
identity.stop();
server.close(() => {
database.close().catch(() => {
console.error(JSON.stringify({ event: "database_close_failed" }));
process.exitCode = 1;
});
});
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
} catch (error) {
await database.close();
throw error;
}
}
start().catch(() => {
console.error(JSON.stringify({ event: "server_start_failed" }));
process.exitCode = 1;
});

View File

@ -0,0 +1,13 @@
export type HttpLogEntry = Readonly<{
event: "http_request";
requestId: string;
method: string;
statusCode: number;
durationMs: number;
}>;
export type HttpLogger = (entry: HttpLogEntry) => void;
// No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales.
export const logHttpRequest: HttpLogger = (entry) => {
console.info(JSON.stringify(entry));
};

View File

@ -0,0 +1,44 @@
import { createCompanyModule } from "@metis/companies/module";
import { createIdentityModule, migrateIdentity } from "@metis/identity/module";
import connectSequelize from "connect-session-sequelize";
import session from "express-session";
import type { Sequelize } from "sequelize";
import type { readDatabaseEnvironment } from "../../config/database";
export async function createIdentityRuntime(
database: Sequelize,
environment: ReturnType<typeof readDatabaseEnvironment>
) {
await database.authenticate();
await migrateIdentity(database);
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
const companies = await createCompanyModule(database, identity.authenticate);
const SequelizeStore = connectSequelize(session.Store);
const store = new SequelizeStore({
db: database,
tableName: "http_sessions",
expiration: 8 * 60 * 60 * 1000,
});
const middleware = session({
name: "metis.sid",
secret: environment.SESSION_SECRET,
store,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "strict",
secure: environment.NODE_ENV === "production",
maxAge: 8 * 60 * 60 * 1000,
},
});
return {
register: (app: Parameters<typeof identity.register>[0]) => {
identity.register(app);
companies.register(app);
},
middleware,
stop: () => store.stopExpiringSessions(),
};
}

View File

@ -0,0 +1,4 @@
{
"extends": "@repo/typescript-config/buildless.json",
"include": ["src", "tsup.config.ts"]
}

View File

@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node24",
platform: "node",
bundle: true,
noExternal: [/^@metis\//, "@repo/rdx-utils", "@repo/rdx-ddd"],
// El código CommonJS de dependencias empaquetadas necesita require en la salida ESM.
banner: {
js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
},
clean: true,
sourcemap: true,
});

22
apps/web/components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-vega",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/shadcn-ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "#components",
"ui": "@repo/shadcn-ui/components",
"utils": "@repo/shadcn-ui/lib/utils",
"lib": "#lib",
"hooks": "#hooks"
},
"rtl": false
}

13
apps/web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Metis Desk — soporte técnico con contexto.">
<title>Metis Desk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

38
apps/web/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "@metis/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@metis/core": "workspace:*",
"@tanstack/react-query": "5.98.0",
"i18next": "26.0.4",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-i18next": "17.0.2",
"tailwindcss": "4.2.2",
"@repo/shadcn-ui": "workspace:*",
"@metis/identity": "workspace:*",
"@metis/companies": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@tailwindcss/vite": "4.2.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"vite": "8.3.0"
},
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}

82
apps/web/src/app.test.tsx Normal file
View File

@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { identityKeys } from "@metis/identity/client";
import type { SessionResponseDTO, UserDTO } from "@metis/identity/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { App } from "./app";
import { createAppI18n } from "./i18n";
const user: UserDTO = {
id: "c90b4d83-b06a-4d27-a402-e64899a2dfc2",
name: "Usuario de prueba",
email: "user@example.test",
active: true,
isInitialAdmin: false,
};
async function renderPage(language: string, session?: SessionResponseDTO) {
const { instance, initialization } = createAppI18n(language);
await initialization;
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
if (session) {
client.setQueryData(identityKeys.session, session);
}
try {
return renderToStaticMarkup(
<I18nextProvider i18n={instance}>
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("el acceso protege el contenido y traduce la carga de sesión", async () => {
for (const [language, pending] of [
["es", "Comprobando acceso"],
["en", "Checking access"],
] as const) {
const markup = await renderPage(language);
assert.equal(markup.includes("Tu espacio de soporte"), false);
assert.equal(markup.includes("Your support workspace"), false);
assert.match(markup, /role="combobox"/);
assert.ok(markup.includes(language === "es" ? "Español" : "English"));
assert.ok(markup.includes(pending));
assert.ok(markup.includes('role="status"'));
}
});
test("la configuración única y el login presentan sus formularios traducidos sin acceso al contenido", async () => {
for (const [language, setupTitle, loginTitle] of [
["es", "Configura Metis Desk", "Accede a Metis Desk"],
["en", "Set up Metis Desk", "Sign in to Metis Desk"],
] as const) {
const snapshot = { user: null, csrfToken: "a".repeat(64), setupRequired: true };
const setup = await renderPage(language, snapshot);
assert.ok(setup.includes(setupTitle));
assert.match(setup, /autoComplete="new-password"/);
assert.match(setup, /minLength="8"/);
assert.equal(setup.includes("Tu espacio de soporte"), false);
const login = await renderPage(language, { ...snapshot, setupRequired: false });
assert.ok(login.includes(loginTitle));
assert.match(login, /autoComplete="current-password"/);
assert.match(login, /minLength="1"/);
assert.equal(login.includes('autoComplete="name"'), false);
}
});
test("solo el administrador inicial ve la navegación de gestión de usuarios", async () => {
const snapshot = { user, csrfToken: "a".repeat(64), setupRequired: false };
const normal = await renderPage("es", snapshot);
assert.ok(normal.includes("Tu espacio de soporte"));
assert.ok(normal.includes("Mi contraseña"));
assert.ok(normal.includes("Salir"));
assert.equal(normal.includes(">Usuarios</button>"), false);
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
assert.ok(admin.includes(">Usuarios</button>"));
});

82
apps/web/src/app.tsx Normal file
View File

@ -0,0 +1,82 @@
import { CompaniesPage } from "@metis/companies/client";
import { HomePage } from "@metis/core/client";
import { IdentityWorkspace } from "@metis/identity/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components/select";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
const LANGUAGE_OPTIONS = [
{ value: "es", label: "Español" },
{ value: "en", label: "English" },
];
export function App() {
const { t: companyTranslation } = useTranslation("companies");
const { t, i18n } = useTranslation("core");
const [languageError, setLanguageError] = useState(false);
const languageLabelId = useId();
return (
<div className="min-h-screen bg-background">
<header className="border-b border-border bg-card">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-6 px-6 py-5">
<span className="text-xl font-semibold tracking-tight text-foreground">
Metis Desk<span className="ml-1 text-primary">.</span>
</span>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span id={languageLabelId}>{t("language")}</span>
<Select
items={LANGUAGE_OPTIONS}
onValueChange={(language) => {
if (language !== "es" && language !== "en") {
return;
}
setLanguageError(false);
i18n
.changeLanguage(language)
.then(() => {
document.documentElement.lang = i18n.resolvedLanguage ?? "es";
})
.catch(() => setLanguageError(true));
}}
value={i18n.resolvedLanguage ?? "es"}
>
<SelectTrigger aria-labelledby={languageLabelId} className="min-w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{languageError && (
<p className="px-6 pb-4 text-destructive" role="alert">
{t("languageError")}
</p>
)}
</header>
<IdentityWorkspace
pages={[
{
id: "companies",
label: companyTranslation("title"),
render: (session) => <CompaniesPage csrfToken={session.csrfToken} />,
},
]}
>
<HomePage />
</IdentityWorkspace>
</div>
);
}

9
apps/web/src/global.css Normal file
View File

@ -0,0 +1,9 @@
@import "@repo/shadcn-ui/globals.css";
@source "../../../modules";
@source "../../../packages/shadcn-ui/src/components";
body {
margin: 0;
font-family: ui-sans-serif, system-ui, sans-serif;
}

20
apps/web/src/i18n.ts Normal file
View File

@ -0,0 +1,20 @@
import { companiesEnglish, companiesSpanish } from "@metis/companies/client";
import { coreEnglish, coreSpanish } from "@metis/core/client";
import { identityEnglish, identitySpanish } from "@metis/identity/client";
import { createInstance } from "i18next";
export function createAppI18n(language = "es") {
const instance = createInstance();
const initialization = instance.init({
lng: language,
fallbackLng: "es",
supportedLngs: ["es", "en"],
defaultNS: "core",
resources: {
es: { core: coreSpanish, identity: identitySpanish, companies: companiesSpanish },
en: { core: coreEnglish, identity: identityEnglish, companies: companiesEnglish },
},
interpolation: { escapeValue: false },
});
return { instance, initialization };
}

33
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,33 @@
import { coreSpanish } from "@metis/core/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { App } from "./app";
import { createAppI18n } from "./i18n";
import "./global.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing application root");
}
const { instance, initialization } = createAppI18n();
const queryClient = new QueryClient();
initialization
.then(() => {
createRoot(rootElement).render(
<StrictMode>
<I18nextProvider i18n={instance}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</I18nextProvider>
</StrictMode>
);
})
.catch(() => {
rootElement.setAttribute("role", "alert");
rootElement.textContent = coreSpanish.startupError;
});

9
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

14
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
strictPort: true,
proxy: { "/api": process.env.API_PROXY_TARGET ?? "http://127.0.0.1:3001" },
fs: { strict: true },
},
resolve: { dedupe: ["react", "react-dom"] },
});

337
biome.json Normal file
View File

@ -0,0 +1,337 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!!**/node_modules",
"!!**/.next",
"!!**/dist",
"!!**/build",
"!!**/coverage",
"!!**/.turbo",
"!!**/out",
"!!**/storybook-static",
"!!**/.vercel",
"!**/.env*",
"!**/public",
"!**/*.d.ts"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf",
"attributePosition": "auto",
"bracketSpacing": true,
"bracketSameLine": true,
"expand": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "off",
"noImplicitBoolean": "off",
"noInferrableTypes": "error",
"noNamespace": "error",
"noNegationElse": "warn",
"noNonNullAssertion": "error",
"noParameterAssign": "error",
"noUnusedTemplateLiteral": "error",
"noUselessElse": "warn",
"useBlockStatements": "error",
"useCollapsedElseIf": "error",
"useConst": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useExportType": "error",
"useFilenamingConvention": {
"level": "error",
"options": {
"strictCase": false,
"requireAscii": true,
"filenameCases": ["kebab-case"]
}
},
"useForOf": "error",
"useFragmentSyntax": "error",
"useImportType": "error",
"useNamingConvention": {
"level": "off",
"options": {
"strictCase": false,
"conventions": [
{
"selector": {
"kind": "function"
},
"formats": ["camelCase", "PascalCase"]
},
{
"selector": {
"kind": "variable"
},
"formats": ["camelCase", "PascalCase", "CONSTANT_CASE"]
},
{
"selector": {
"kind": "typeLike"
},
"formats": ["PascalCase"]
}
]
}
},
"useNodejsImportProtocol": "error",
"useNumberNamespace": "error",
"useSelfClosingElements": "error",
"useShorthandAssign": "error",
"useShorthandFunctionType": "error",
"useSingleVarDeclarator": "error",
"useTemplate": "error",
"useThrowOnlyError": "error"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeNegation": "error",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConsole": "off",
"noConstEnum": "error",
"noControlCharactersInRegex": "error",
"noDoubleEquals": "error",
"noDuplicateCase": "error",
"noEmptyBlockStatements": "off",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noLabelVar": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noSelfCompare": "error",
"noUnknownAtRules": "off"
},
"correctness": {
"noConstAssign": "error",
"noConstructorReturn": "error",
"noEmptyPattern": "error",
"noInvalidConstructorSuper": "error",
"noInvalidUseBeforeDeclaration": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedFunctionParameters": "warn",
"noUnusedLabels": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "info",
"useHookAtTopLevel": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useYield": "error"
},
"complexity": {
"noBannedTypes": "off",
"noExcessiveCognitiveComplexity": {
"level": "warn",
"options": {
"maxAllowedComplexity": 15
}
},
"noForEach": "warn",
"noStaticOnlyClass": "off",
"noThisInStatic": "error",
"noUselessCatch": "error",
"noUselessConstructor": "error",
"noUselessFragments": "off",
"noUselessLabel": "error",
"noUselessRename": "error",
"noUselessSwitchCase": "warn",
"noUselessTernary": "error",
"noUselessTypeConstraint": "error",
"noVoid": "error",
"useFlatMap": "error",
"useLiteralKeys": "error",
"useOptionalChain": "off",
"useSimpleNumberKeys": "error",
"useSimplifiedLogicExpression": "info"
},
"security": {
"noDangerouslySetInnerHtml": "error",
"noDangerouslySetInnerHtmlWithChildren": "error",
"noGlobalEval": "error"
},
"a11y": {
"noAccessKey": "error",
"noAriaHiddenOnFocusable": "error",
"noAriaUnsupportedElements": "error",
"noAutofocus": "error",
"noDistractingElements": "error",
"noHeaderScope": "error",
"noInteractiveElementToNoninteractiveRole": "error",
"noNoninteractiveElementToInteractiveRole": "error",
"noNoninteractiveTabindex": "error",
"noPositiveTabindex": "error",
"noRedundantAlt": "error",
"noRedundantRoles": "error",
"useFocusableInteractive": "error",
"useIframeTitle": "error",
"useKeyWithClickEvents": "error",
"useKeyWithMouseEvents": "error",
"useMediaCaption": "error",
"useSemanticElements": "info",
"useValidAnchor": "info",
"useValidAriaProps": "error",
"useValidAriaValues": "error",
"useValidLang": "error",
"useButtonType": "info"
},
"performance": {
"noAccumulatingSpread": "warn",
"noDelete": "error"
}
},
"domains": {
"next": "all",
"react": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":URL:",
":BLANK_LINE:",
[":BUN:", ":NODE:"],
":BLANK_LINE:",
":PACKAGE_WITH_PROTOCOL:",
":BLANK_LINE:",
[":PACKAGE:"],
":BLANK_LINE:",
["!@/**", "!#*", "!~*", "!$*", "!%*"],
":BLANK_LINE:",
["@/**", "#*", "~*", "$*", "%*"],
":BLANK_LINE:",
[":PATH:", "!./**", "!../**"],
":BLANK_LINE:",
["../**"],
":BLANK_LINE:",
["./**"]
],
"identifierOrder": "lexicographic"
}
},
"useSortedAttributes": "on"
}
}
},
"javascript": {
"formatter": {
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "es5"
},
"globals": ["console", "process", "__dirname", "__filename"]
},
"json": {
"formatter": {
"trailingCommas": "none",
"indentStyle": "space",
"indentWidth": 2
}
},
"css": {
"parser": {
"tailwindDirectives": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"quoteStyle": "double"
},
"linter": {
"enabled": true
}
},
"html": {
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
}
},
"overrides": [
{
"includes": ["**/*.d.ts", "**/lib/env/*.ts"],
"linter": {
"rules": {
"style": {
"noNamespace": "off",
"useNamingConvention": {
"level": "error",
"options": {
"strictCase": false,
"conventions": [
{
"selector": {
"kind": "objectLiteralProperty"
},
"formats": ["CONSTANT_CASE", "camelCase"]
}
]
}
}
},
"suspicious": {
"noExplicitAny": "info"
},
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 12
}
}
}
}
}
}
]
}

21
compose.yaml Normal file
View File

@ -0,0 +1,21 @@
name: metis-desk
services:
database:
image: mariadb:11.8
ports:
- "127.0.0.1:3307:3306"
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- database-data:/var/lib/mysql
- ./docker/mariadb/01-test-database.sql:/docker-entrypoint-initdb.d/01-test-database.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 3s
timeout: 3s
retries: 30
volumes:
database-data:

View File

@ -0,0 +1,3 @@
-- Las pruebas usan un esquema separado y nunca vacían los datos de la aplicación.
CREATE DATABASE IF NOT EXISTS metis_desk_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL PRIVILEGES ON metis_desk_test.* TO 'metis'@'%';

View File

@ -0,0 +1,57 @@
# Capas del servidor
Esta es la fuente normativa de la arquitectura de Metis Desk.
## Organización
```text
apps/
server/ arranque, configuración y middleware HTTP común
web/ arranque React, proveedores y composición de páginas
modules/
core/
src/
common/ contratos Zod y traducciones compartidos
api/ código exclusivo del servidor
web/ páginas, consultas y acceso HTTP del navegador
packages/
typescript-config/ configuración compartida del compilador
```
Los módulos funcionales seguirán esta estructura. Dentro de api, crear domain, application e infrastructure cuando existan responsabilidades que lo requieran.
La primera entrega solo comprueba la vida del proceso. No hay reglas de negocio, agregados, repositorios ni casos de uso artificiales para esta comprobación técnica.
## Dependencias
Infrastructure → Application → Domain.
Domain no conoce Express, Zod de transporte, Sequelize, Application ni Infrastructure.
Application conoce Domain, contratos y puertos necesarios; no importa Express, modelos ORM ni Infrastructure.
Infrastructure implementa HTTP y persistencia. Los puertos de repositorio se declaran en Application; sus implementaciones concretas viven en Infrastructure.
common puede ser consumido por servidor y navegador. Nunca exporta modelos ORM ni infraestructura.
La aplicación web importa únicamente contratos comunes y la API pública web de los módulos.
Cada módulo mantiene sus propios repositorios. La colaboración entre módulos se realiza mediante servicios públicos explícitos, únicamente cuando existan consumidores reales.
## Creación y persistencia
create valida invariantes de creación y devuelve Result. Application lo usa para nuevas entidades.
rehydrate reconstruye entidades persistidas sin aplicar reglas de creación ni emitir eventos. Infrastructure lo usa al leer.
update aplica sus propias invariantes; no reutiliza create ni rehydrate.
Los Value Objects se reconstruyen mediante create. Un fallo al leer datos persistidos se trata como inconsistencia de persistencia, no se oculta.
Cada agregado persistido tendrá un mapper bidireccional con toPersistence y toDomain.
Sequelize permanece en Infrastructure. El módulo de identidad ya usa MariaDB y Sequelize v6.
Toda entidad de negocio lleva un UniqueID. Las declaraciones DDD y reglas de UUID se definen en [Modelo DDD e identificadores](modelo-ddd-e-identificadores.md).
## Alcance de las abstracciones
Result, Maybe y Collection seguirán el estilo del ERP cuando tengan consumidores reales.
No se copia rdx-utils completo ni se depende del filesystem de factuges-erp.
No se generan módulos vacíos para funciones futuras.
Consultar también [composición](composicion-de-modulos.md), [flujo HTTP](flujo-http.md), [contratos](contratos-http.md) y [pruebas](pruebas.md).

View File

@ -0,0 +1,18 @@
# Composición de módulos
Cada módulo funcional tendrá un composition root explícito: construye sus dependencias y devuelve únicamente las APIs necesarias para sus consumidores.
Usar constructor injection. No usar Service Locator, contenedores DI, decoradores ni resolución implícita.
No copiar el registro dinámico del ERP: esta aplicación todavía no necesita esa capacidad.
Las rutas reciben dependencias ya construidas. No construyen repositorios, servicios de Application ni mappers de dominio.
Cuando sea necesario distinguir superficies:
- services: servicios públicos consumidos por otros módulos.
- internal: dependencias privadas necesarias para las rutas del propio módulo.
No exportar repositorios ni modelos ORM a otros módulos. No crear contratos públicos sin consumidores.
En esta entrega core expone registerCoreRoutes mediante ./module, una frontera de composición del servidor.
./common expone contratos y ./client expone la página y recursos de traducción. No hay dependencias locales del ERP.

View File

@ -0,0 +1,19 @@
# Contratos HTTP
Zod v4 es la fuente de verdad de los DTOs de entrada y salida.
Inferir todos sus tipos mediante z.infer<typeof Schema>; no escribir interfaces equivalentes.
Ubicación: modules/<module>/src/common/dto/request y response.
Crear shared solo cuando haya subcontratos estables usados por varias operaciones.
Los nombres expresan recurso y acción: GetHealthRequestSchema, GetHealthRequestDTO, GetHealthResponseSchema y GetHealthResponseDTO.
Distinguir campo omitido, null, cadena vacía y colección vacía.
Evitar default y transform en contratos de negocio salvo una necesidad justificada.
Usar schemas semánticos para UUID y timestamps ISO. Los decimales exactos viajan como cadenas.
La route valida la entrada una vez; el controller no repite Zod.
El navegador valida la respuesta externa antes de consumirla.
Los builders de respuesta se tipan con el DTO inferido exacto; no duplican interfaces Snapshot.
Son deterministas y no contienen lógica de negocio ni acceso a repositorios.

View File

@ -0,0 +1,32 @@
# Flujo HTTP
Para operaciones de negocio, usar:
```text
RequestSchema → validación en route → Controller
→ UseCase({ dto, context }) → InputMapper → Value Objects / Props
→ servicios de Application / Domain
```
Las rutas declaran validación y guards de autenticación y autorización.
El controller obtiene el contexto autenticado, invoca el caso de uso y traduce Result a HTTP.
Application no conoce Express y no construye agregados directamente desde DTOs HTTP.
El InputMapper adapta primitivas y contexto, crea Value Objects y devuelve Result.
Solo crear un RequestMapper adicional cuando exista adaptación HTTP real, como combinar body, params y query.
Los identificadores de la organización y del actor se obtienen del contexto autenticado.
customerId puede identificar al cliente atendido, pero siempre se comprueba que el actor tenga acceso antes de consultar o modificar sus datos.
## Entrega inicial
GET /api/__health devuelve status y time. Rechaza parámetros de consulta desconocidos.
Comprueba únicamente que el proceso responde; no demuestra disponibilidad de base de datos ni autorización.
No requiere un caso de uso porque no contiene comportamiento de negocio.
Cada petición obtiene un UUID generado por el servidor, devuelto como x-request-id.
Los errores HTTP tienen code y requestId. No devuelven detalles internos.
El middleware registra método, estado, duración e identificador; no registra URL, query, cabeceras ni cuerpo.
El servidor escucha en 127.0.0.1. La interfaz de desarrollo usa el proxy de Vite para /api.
No hay acceso a datos de clientes ni autenticación en esta entrega. Incorporar ambos de forma conjunta antes de habilitar esos datos.

View File

@ -0,0 +1,48 @@
# Modelo DDD e identificadores
## Regla de identidad
Toda entidad de negocio de Metis Desk, tanto raíz de agregado como entidad interna, tiene un identificador global único UniqueID. Se genera como UUID v7, igual que en factuges-erp. Clientes, contactos, sedes/ubicaciones, documentos, tickets, intervenciones, assets, contratos y las demás entidades seguirán esta regla.
La identidad no depende de nombres, correos, referencias visibles ni claves numéricas incrementales. No cambia al actualizar, activar o archivar. Las relaciones entre entidades usan el UniqueID de la entidad referenciada.
Los UUID anteriores válidos, incluidos los v4 de usuarios ya creados, se conservan al leer y escribir. No se reasignan identificadores ni se reescriben relaciones. UniqueID.create valida un identificador recibido; UniqueID.generateNewID genera uno nuevo.
Los identificadores viajan como cadenas UUID en DTOs y se validan con z.uuid. Dentro del dominio y de los puertos de repositorio se usa UniqueID, no string. Infrastructure convierte a la representación persistida. La clave primaria y las claves foráneas se declaran explícitamente en las migraciones.
El marcador técnico de configuración y el identificador opaco de cookie de express-session no representan entidades de negocio y mantienen sus contratos de infraestructura.
## Declaraciones
packages/rdx-ddd proporciona la base compartida, independiente del filesystem del ERP:
- ValueObject: valor escalar inmutable e igualdad semántica por valor y tipo. Los Value Objects no tienen identidad.
- UniqueID, Name y EmailAddress: Value Objects compartidos con create, getProps, toPrimitive, toString y equals.
- DomainEntity: identidad UniqueID y propiedades de dominio. La igualdad se basa en identidad y tipo, independientemente de los atributos.
- AggregateRoot: entidad que delimita una unidad de consistencia y es persistida por su repositorio.
La dependencia uuid 13 se usa para generar UUID v7, como en el ERP; se evita implementar un generador propio.
Se conserva la organización y los nombres del ERP, con tipos estrictos y sin copiar sus dependencias o utilidades no utilizadas. Esta base escalar no introduce comparación genérica de objetos compuestos ni un bus de eventos sin consumidores.
Las raíces se declaran en archivos *.aggregate.ts; las entidades internas en *.entity.ts. Los Value Objects específicos de un módulo viven en domain/value-objects. Domain permanece libre de Express, DTOs de transporte y Sequelize. La validación propia de los Value Objects puede usar Zod, igual que en el ERP.
## Ciclo de vida y fronteras
create valida invariantes de creación y devuelve Result. La nueva raíz genera su UniqueID cuando no recibe uno explícito. rehydrate exige el ID persistido, no genera otro y no aplica reglas de alta ni emite eventos. update aplica invariantes de actualización y compara valores con equals.
En esta entrega los agregados devuelven una instancia nueva al cambiar; una actualización semánticamente idéntica devuelve la misma instancia. Todas conservan el UniqueID original.
Application adapta DTOs a Value Objects mediante InputMapper. Los errores de entrada esperados se devuelven como Result. Los repositorios aceptan identificadores y valores de dominio.
Infrastructure reconstruye los Value Objects mediante create antes de llamar a rehydrate. Un valor persistido inválido provoca un error de consistencia sin incluir datos sensibles; no se oculta ni se reemplaza por un valor generado. toPersistence serializa primitivas. Los builders HTTP también serializan primitivas y solo exponen los campos previstos por el DTO.
## Aplicación actual y siguientes módulos
User es la raíz del agregado de identidad. Sus Value Objects son UniqueID, Name, EmailAddress y PasswordHash. Conserva las invariantes del administrador inicial, la activación y la invalidación de sesiones.
Company es la raíz del agregado de compañías clientes. Contact y Site son entidades internas con UniqueID propio y referencia UniqueID a su compañía. Name, EmailAddress, PhoneNumber y SiteAddress representan sus valores; la dirección opcional usa Maybe. El repositorio carga y guarda el agregado completo dentro de una transacción con bloqueo de la compañía.
Los módulos de documentos, tickets y assets 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.

View File

@ -0,0 +1,18 @@
# Pruebas
Las pruebas verifican comportamiento observable, no detalles internos.
- DTO: casos válidos, inválidos, límites, claves desconocidas y semántica de ausencia.
- InputMapper: adaptación a Value Objects y errores de dominio, sin repetir Zod.
- UseCase: colaboración, transacciones, propagación de Result e idempotencia cuando corresponda.
- HTTP: middleware, entrada, contratos de salida, estados y autorización.
- Frontend: textos traducidos, estados y acciones observables.
Usar el runner nativo de Node.js con tsx. La primera prueba HTTP abre Express en un puerto efímero y usa fetch real. No necesita una base de datos ni espera tiempos arbitrarios.
La prueba web inicial usa renderizado React en servidor para verificar es/en y el estado pendiente.
No sustituye una prueba de interacción en navegador.
Comprobaciones transversales: pnpm typecheck, pnpm lint, pnpm test, pnpm check y pnpm build.
Si una no se ejecuta o falla, documentar exactamente cuál y por qué.
La prueba web fija el tsconfig raíz mediante TSX_TSCONFIG_PATH para que el runtime JSX automático también se aplique al código importado desde módulos del workspace.

View File

@ -0,0 +1,46 @@
# Compañías, contactos y sedes
## Uso
Tras iniciar sesión, abrir Compañías. El listado permite buscar por nombre, crear compañías y abrir su ficha. El botón Nueva compañía está en la cabecera, a la derecha, y abre un modal. Dentro de cada ficha se puede editar la compañía y gestionar sus contactos y sedes mediante modales de alta y edición. Los listados no muestran formularios integrados.
- Compañía: nombre obligatorio (100 caracteres como máximo) y estado activo/inactivo.
- Contacto: nombre, puesto, email y teléfono obligatorios; estado activo/inactivo. Nombre y puesto admiten 100 caracteres. El email se normaliza a minúsculas; el teléfono admite prefijo internacional, espacios, paréntesis, guiones y extensión.
- Sede: nombre obligatorio y dirección opcional, multilínea, de hasta 10.000 caracteres. Una dirección omitida, vacía o con solo espacios se guarda como ausencia.
Las compañías representan empresas clientes atendidas, no la organización que presta soporte ni usuarios con acceso. Todos los usuarios autenticados activos pueden gestionarlas. El administrador inicial conserva su autorización exclusiva sobre la gestión de usuarios.
Desactivar una compañía no desactiva sus contactos ni oculta sus sedes. Los estados se editan por separado. No se han añadido eliminación ni un estado de activación a las sedes.
## Dominio y persistencia
El módulo companies sigue Domain, Application e Infrastructure. Company es raíz de agregado; Contact y Site son entidades internas, cada una con su UniqueID. Los identificadores nuevos son UUID v7; rehydrate y update conservan los persistidos.
Name, EmailAddress, PhoneNumber y SiteAddress encapsulan sus invariantes. Maybe representa la dirección ausente. Result expresa errores esperados. Los mappers convierten DTOs a valores de dominio y reconstruyen el agregado desde la persistencia; un registro inconsistente falla sin exponer sus datos.
MariaDB almacena companies, company_contacts y company_sites. Las claves foráneas relacionan cada contacto y sede con su compañía, con restricción de borrado. Las migraciones explícitas se aplican al arrancar y no borran datos existentes.
Las modificaciones bloquean la compañía dentro de una transacción antes de cargar y guardar el agregado. No se permite trasladar una entidad a otra compañía mediante una edición ni editar un contacto o sede usando el identificador de otra compañía.
## API
Todas las rutas requieren sesión válida. Las escrituras requieren el origen configurado y el token CSRF de la sesión.
| Método | Ruta | Operación |
| --- | --- | --- |
| GET | /api/companies | Listado |
| GET | /api/companies/:companyId | Ficha con contactos y sedes |
| POST | /api/companies | Crear compañía |
| POST | /api/companies/:companyId/update | Editar compañía |
| POST | /api/companies/:companyId/contacts | Crear contacto |
| POST | /api/companies/:companyId/contacts/:id/update | Editar contacto |
| POST | /api/companies/:companyId/sites | Crear sede |
| POST | /api/companies/:companyId/sites/:id/update | Editar sede |
Los contratos Zod se declaran en common/dto/request, response y shared. Las mutaciones devuelven la ficha completa para actualizar la caché de TanStack Query. TanStack Table filtra el listado; React Hook Form valida los formularios. Los controles proceden del paquete compartido shadcn/ui.
## Validación
Las pruebas cubren identidad UUID, invariantes, pertenencia al agregado, actualizaciones, dirección opcional y límites. Las pruebas HTTP usan exclusivamente metis_desk_test y comprueban autenticación, CSRF/origen, persistencia, relaciones y direcciones de 10.000 caracteres Unicode.
También se ha probado en el navegador la búsqueda, la creación de compañías y contactos, y la creación y edición de sedes con direcciones multilínea, usando datos ficticios en el entorno de pruebas aislado.

View File

@ -0,0 +1,56 @@
# Componentes de interfaz
Metis Desk usa shadcn/ui, con Base UI y el estilo base-vega, siguiendo la organización de factuges-erp.
Los componentes viven en packages/shadcn-ui y se consumen mediante el paquete @repo/shadcn-ui.
El código se genera desde el registro oficial y queda versionado en este repositorio, sin depender del ERP.
## Componentes actuales
- Button: volver a comprobar la conexión.
- Card: bloques de información y estado.
- Select: cambio entre castellano e inglés, con navegación por teclado.
- Input y Label: credenciales y datos de usuario.
- Checkbox: estado activo de usuarios, compañías y contactos.
- Table: listados de usuarios, compañías y contactos.
- Textarea: dirección opcional de las sedes, con varias líneas.
- Dialog: formularios sencillos de alta y edición, con foco y cierre por teclado. FormDialog comparte el título y la cancelación traducidos.
## Convención de navegación
Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios sencillos se abren en un modal; los complejos o con muchos campos se presentan en una página propia. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, contactos, sedes y usuarios siguen este criterio.
TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos.
El paquete exporta componentes individuales. Importar solo el componente necesario:
```tsx
import { Button } from "@repo/shadcn-ui/components/button";
```
El tema se define una sola vez en packages/shadcn-ui/src/styles/globals.css.
La aplicación importa ese CSS y declara las fuentes de Tailwind de los módulos y del paquete compartido.
Usar tokens semánticos como bg-background, text-foreground y border-border en vez de repetir colores por pantalla.
## Añadir componentes
Desde la raíz del proyecto, dentro de WSL:
```bash
pnpm --filter @repo/shadcn-ui ui:add input
```
El CLI está fijado en package.json. Revisar los archivos y dependencias añadidos antes de validar.
No usar --overwrite sin revisar las adaptaciones locales.
apps/web/components.json también permite al CLI localizar el paquete compartido desde la aplicación.
Después de generar código, separar los imports de tipos con import type y ejecutar las comprobaciones del proyecto.
No añadir todo el catálogo ni crear envoltorios sin una necesidad concreta.
## Dependencias
Base UI proporciona interacción accesible y gestión del foco.
class-variance-authority define variantes del botón; cn combina clases de Tailwind.
lucide-react aporta los iconos del selector y tw-animate-css sus transiciones.
shadcn es una herramienta de desarrollo; su hoja de estilos común se incorpora al compilar.
Guías oficiales: [monorepos](https://ui.shadcn.com/docs/monorepo) e [instalación manual](https://ui.shadcn.com/docs/installation/manual).

View File

@ -0,0 +1,48 @@
# Plan de desarrollo
Metis Desk es una aplicación interna para técnicos que dan soporte a pequeñas empresas.
El proyecto es independiente de factuges-erp y comparte sus convenciones de programación.
## Entrega 0: base técnica
Incluye monorepo PNPM y Turborepo, TypeScript estricto, Biome, Express, React, Vite, Tailwind, TanStack Query, shadcn/ui con Base UI, Zod y traducciones es/en.
La página inicial muestra la conexión real con la API. No contiene datos ficticios de clientes.
## Acceso y usuarios: implementado
MariaDB, Sequelize v6, migraciones iniciales, configuración única del administrador, login, sesiones y gestión de usuarios sin catálogo de roles. React Hook Form y componentes shadcn/ui para estos formularios.
## Entrega 1: compañías clientes, implementado
Crear, consultar, editar y buscar compañías; activar o desactivar compañías y contactos; crear y editar sedes con dirección opcional. Formularios con React Hook Form, listado con TanStack Table y componentes shadcn/ui. Consultar [Compañías, contactos y sedes](companias-contactos-y-sedes.md).
Distinguir:
- Organización que presta el soporte: contexto de acceso.
- Cliente atendido: empresa sobre la que se registran datos.
- Técnico: usuario autenticado de Metis Desk.
- Contacto: persona del cliente.
- Usuario de un equipo: persona asignada a un puesto; no implica acceso a la aplicación.
La adopción de una sola organización o de varias se decidirá antes del modelo persistente.
No crear un sistema multiempresa complejo sin necesidad, companyId identifica a la compañía cliente; no se utiliza como identificador de tenant.
## Entrega 2: inventario
Infraestructura por sede: routers, firewalls, switches, puntos de acceso, NAS y servidores.
Puestos de trabajo y asignaciones: equipos, monitores, impresoras y usuarios.
Definir identificación, estado e historial de movimientos.
## Entrega 3: tickets e intervenciones
Cliente, sede, activo afectado, técnico, fecha, tiempo empleado, diagnóstico, trabajo realizado y resolución.
Un ticket puede tener varias intervenciones. No confundir la solicitud de soporte con cada trabajo realizado.
## Entrega 4: servicios y documentación
Software, licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación.
Las credenciales se resolverán con una solución específica; no almacenarlas como texto libre.
## Decisiones pendientes
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, contactos y sedes está implementada. Inventario y tickets siguen pendientes.

46
docs/puesta-en-marcha.md Normal file
View File

@ -0,0 +1,46 @@
# Puesta en marcha
## Entorno
El repositorio está en /home/david/dev/metis-desk, dentro de WSL2 Ubuntu-24.04. Necesita Node.js 24.x, PNPM 11.25.0 y Docker disponible desde WSL. No depende de paquetes locales de factuges-erp.
Desde una terminal Ubuntu-24.04:
```bash
cd /home/david/dev/metis-desk
pnpm install --frozen-lockfile
pnpm db:up
pnpm dev
```
pnpm db:up genera .env con secretos locales si no existe e inicia MariaDB en un volumen persistente. No crea usuarios de la aplicación. El servidor aplica las migraciones al arrancar.
Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307.
Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes.
Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env.
## Verificaciones
```bash
pnpm typecheck
pnpm lint
pnpm test
pnpm check
pnpm build
```
La base local debe estar encendida para las pruebas de integración. Estas solo usan metis_desk_test. La compilación genera apps/server/dist y apps/web/dist.
Para ejecutar la API compilada, parar primero el servidor de desarrollo:
```bash
pnpm --filter @metis/server start
```
## Alcance
Hay acceso, sesiones persistentes, gestión de usuarios, compañías clientes con contactos y sedes y una página inicial que comprueba la conexión. Inventario y tickets siguen pendientes. Las tablas nuevas se crean al arrancar la API mediante migraciones explícitas.
La API compilada todavía no sirve la interfaz estática. El despliegue, proxy HTTPS, backups y recuperación de acceso se definirán en una entrega posterior.

37
docs/usuarios-y-acceso.md Normal file
View File

@ -0,0 +1,37 @@
# Usuarios y acceso
## Primera apertura
La instalación empieza sin cuentas ni contraseñas predeterminadas. En la primera visita a http://localhost:5173 aparece «Configura Metis Desk». Introducir nombre, correo y contraseña de al menos 8 caracteres para crear el administrador inicial.
Esa operación crea la cuenta y el marcador de instalación en una misma transacción. La configuración se cierra para siempre al completarla; dos peticiones simultáneas no pueden crear dos administradores. La cuenta inicial no se puede desactivar ni borrar. Su correo, nombre y contraseña sí se pueden cambiar.
Por ahora no existe un catálogo de roles. La cuenta inicial tiene una condición especial e inmutable y es la única que puede gestionar usuarios. El resto accede al espacio de soporte y puede cambiar su propia contraseña.
## Gestión
Desde «Usuarios» el administrador puede crear cuentas, editar nombre y correo, activar/desactivar usuarios y restablecer contraseñas. No se borran cuentas: la desactivación conserva su identidad para las futuras relaciones con tickets e intervenciones.
El administrador cambia su propia contraseña en «Mi contraseña», proporcionando la contraseña actual. El restablecimiento de otro usuario invalida sus sesiones. Su nueva contraseña debe comunicarse por un canal privado; no hay envío automático de correos.
Los correos se normalizan a minúsculas y son únicos. El nombre se recorta en Application. Los DTOs estrictos no admiten campos para otorgar privilegios.
## Persistencia y sesiones
MariaDB persiste usuarios, el marcador de configuración y las sesiones. Las migraciones iniciales son aditivas e idempotentes y no recrean tablas ni reinician el marcador. No se utiliza sync para modificar el esquema.
Las contraseñas se guardan con scrypt y una sal aleatoria, nunca como texto. Las sesiones usan una cookie HttpOnly, SameSite=Strict y duración de ocho horas. El identificador de sesión se renueva al entrar y al cambiar la contraseña propia. No se guarda ningún token en localStorage.
Cada operación comprueba el estado y la versión de autenticación del usuario. Cambiar contraseñas o activar/desactivar cuentas invalida las sesiones previas. Los POST requieren el origen configurado y un token CSRF asociado a la sesión. Los intentos fallidos de acceso están limitados por dirección IP; el contador se mantiene en memoria en esta entrega local.
## Entorno local
Docker Compose inicia una MariaDB independiente en 127.0.0.1:3307. Su volumen conserva los datos al reiniciar. El archivo .env se genera localmente con secretos aleatorios y no se versiona. SESSION_SECRET debe conservarse entre arranques para mantener las sesiones.
APP_ORIGIN debe coincidir exactamente con el origen usado en el navegador; el valor local es http://localhost:5173. Usar 127.0.0.1 en el navegador requiere cambiar esta configuración. En un futuro despliegue con HTTPS, NODE_ENV=production activa Secure en la cookie; el proxy, backups y recuperación administrativa siguen pendientes de diseño.
## Pruebas
pnpm db:up prepara el entorno. pnpm test ejecuta pruebas de dominio, hashes, interfaz y HTTP con MariaDB. La prueba de integración solo puede limpiar metis_desk_test y comprueba ese nombre antes de hacerlo. Nunca limpia metis_desk.
Cubre configuración concurrente, cierre del bootstrap, protección de administrador en HTTP y SQL, contratos estrictos, correos duplicados, login, autorización, CSRF, rotación de sesión, cambio y restablecimiento de contraseña, desactivación/reactivación, cierre de sesión y límite de intentos.

View File

@ -0,0 +1,40 @@
{
"name": "@metis/companies",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./common": "./src/common/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/web/components/companies-page.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"sequelize": "6.37.8",
"zod": "4.3.6",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"@tanstack/react-table": "8.21.3"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "25.6.0",
"@types/express": "4.17.21",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
},
"peerDependencies": {
"express": "4.22.1",
"react": "19.2.5",
"react-dom": "19.2.5",
"i18next": "26.0.4",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
}
}

View File

@ -0,0 +1,51 @@
import { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CompanyRequestDTO, ContactRequestDTO, SiteRequestDTO } from "../../common";
import { CompanyError } from "../domain/company-error";
import { PhoneNumber } from "../domain/value-objects/phone-number";
import { SiteAddress } from "../domain/value-objects/site-address";
export class CompanyInputMapper {
id(value: string): Result<UniqueID, CompanyError> {
const result = UniqueID.create(value);
return result.isSuccess
? Result.ok(result.data)
: Result.fail(new CompanyError("INVALID_REQUEST"));
}
company(dto: CompanyRequestDTO) {
const name = Name.create(dto.name, 100);
return name.isSuccess
? Result.ok({ name: name.data, active: dto.active })
: Result.fail(new CompanyError("INVALID_REQUEST"));
}
contact(dto: ContactRequestDTO) {
const name = Name.create(dto.name, 100);
const position = Name.create(dto.position, 100);
const email = EmailAddress.create(dto.email);
const phone = PhoneNumber.create(dto.phone);
if (!(name.isSuccess && position.isSuccess && email.isSuccess && phone.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return Result.ok({
name: name.data,
position: position.data,
email: email.data,
phone: phone.data,
active: dto.active,
});
}
site(dto: SiteRequestDTO) {
const name = Name.create(dto.name, 100);
if (!name.isSuccess) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
if (dto.address === null || dto.address === undefined || !dto.address.trim()) {
return Result.ok({ name: name.data, address: Maybe.none<SiteAddress>() });
}
const address = SiteAddress.create(dto.address);
return address.isSuccess
? Result.ok({ name: name.data, address: Maybe.some(address.data) })
: Result.fail(new CompanyError("INVALID_REQUEST"));
}
}

View File

@ -0,0 +1,26 @@
import type { CompanyDTO, CompanySummaryDTO } from "../../common";
import type { Company } from "../domain/company.aggregate";
export function buildCompanySummary(company: Company): CompanySummaryDTO {
return { id: company.id.toPrimitive(), name: company.name.toPrimitive(), active: company.active };
}
export function buildCompanyResponse(company: Company): CompanyDTO {
return {
...buildCompanySummary(company),
contacts: company.contacts.map((contact) => ({
id: contact.id.toPrimitive(),
companyId: contact.companyId.toPrimitive(),
name: contact.name.toPrimitive(),
position: contact.position.toPrimitive(),
email: contact.email.toPrimitive(),
phone: contact.phone.toPrimitive(),
active: contact.active,
})),
sites: company.sites.map((site) => ({
id: site.id.toPrimitive(),
companyId: site.companyId.toPrimitive(),
name: site.name.toPrimitive(),
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
})),
};
}

View File

@ -0,0 +1,15 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { Company } from "../domain/company.aggregate";
import type { CompanyError } from "../domain/company-error";
export interface ICompanyRepository {
list(): Promise<readonly Company[]>;
findById(id: UniqueID): Promise<Maybe<Company>>;
create(company: Company): Promise<Company>;
update(
id: UniqueID,
change: (company: Company) => Result<Company, CompanyError>
): Promise<Result<Company, CompanyError>>;
}

View File

@ -0,0 +1,88 @@
import { Result } from "@repo/rdx-utils";
import type { CompanyRequestDTO, ContactRequestDTO, SiteRequestDTO } from "../../common";
import { Company } from "../domain/company.aggregate";
import { CompanyError } from "../domain/company-error";
import { Contact } from "../domain/contact.entity";
import { Site } from "../domain/site.entity";
import type { ICompanyRepository } from "./company.repository";
import type { CompanyInputMapper } from "./company-input.mapper";
export class CompanyUseCases {
constructor(
private readonly companies: ICompanyRepository,
private readonly input: CompanyInputMapper
) {}
list() {
return this.companies.list();
}
async get(id: string) {
const parsed = this.input.id(id);
if (!parsed.isSuccess) {
return parsed;
}
const company = (await this.companies.findById(parsed.data)).getOrUndefined();
return company ? Result.ok(company) : Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
}
async create(dto: CompanyRequestDTO) {
const props = this.input.company(dto);
if (!props.isSuccess) {
return props;
}
const created = Company.create(props.data);
return created.isSuccess ? Result.ok(await this.companies.create(created.data)) : created;
}
async update(id: string, dto: CompanyRequestDTO) {
const parsed = this.input.id(id);
const props = this.input.company(dto);
if (!(parsed.isSuccess && props.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.companies.update(parsed.data, (company) => company.update(props.data));
}
async addContact(id: string, dto: ContactRequestDTO) {
const parsed = this.input.id(id);
const props = this.input.contact(dto);
if (!(parsed.isSuccess && props.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.companies.update(parsed.data, (company) => {
const contact = Contact.create({ ...props.data, companyId: company.id });
return contact.isSuccess ? company.addContact(contact.data) : contact;
});
}
async updateContact(companyId: string, id: string, dto: ContactRequestDTO) {
const parent = this.input.id(companyId);
const child = this.input.id(id);
const props = this.input.contact(dto);
if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.companies.update(parent.data, (company) =>
company.updateContact(child.data, props.data)
);
}
async addSite(id: string, dto: SiteRequestDTO) {
const parsed = this.input.id(id);
const props = this.input.site(dto);
if (!(parsed.isSuccess && props.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.companies.update(parsed.data, (company) => {
const site = Site.create({ ...props.data, companyId: company.id });
return site.isSuccess ? company.addSite(site.data) : site;
});
}
async updateSite(companyId: string, id: string, dto: SiteRequestDTO) {
const parent = this.input.id(companyId);
const child = this.input.id(id);
const props = this.input.site(dto);
if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.companies.update(parent.data, (company) =>
company.updateSite(child.data, props.data)
);
}
}

View File

@ -0,0 +1,10 @@
export type CompanyErrorCode =
| "INVALID_REQUEST"
| "COMPANY_NOT_FOUND"
| "CONTACT_NOT_FOUND"
| "SITE_NOT_FOUND";
export class CompanyError extends Error {
constructor(public readonly code: CompanyErrorCode) {
super(code);
}
}

View File

@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { CompanyInputMapper } from "../application/company-input.mapper";
import { Company } from "./company.aggregate";
import { Contact } from "./contact.entity";
import { Site } from "./site.entity";
const input = new CompanyInputMapper();
function companyFixture(name = "Empresa test") {
const props = input.company({ name, active: true });
assert.ok(props.isSuccess);
const created = Company.create(props.data);
assert.ok(created.isSuccess);
return created.data;
}
test("compañía, contacto y sede tienen UUID v7 propios y se conserva la identidad al editar", () => {
const company = companyFixture();
const props = input.contact({
name: "Ana",
position: "Responsable",
email: "ANA@example.test",
phone: "+34 900 123 456",
active: true,
});
assert.ok(props.isSuccess);
const contact = Contact.create({ ...props.data, companyId: company.id });
assert.ok(contact.isSuccess);
const added = company.addContact(contact.data);
assert.ok(added.isSuccess);
assert.notEqual(company.id.toPrimitive(), contact.data.id.toPrimitive());
assert.equal(contact.data.id.toPrimitive().charAt(14), "7");
const changed = added.data.updateContact(contact.data.id, { ...props.data, active: false });
assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(company.id));
assert.ok(changed.data.contacts[0]?.id.equals(contact.data.id));
assert.equal(changed.data.contacts[0]?.active, false);
assert.equal(added.data.contacts[0]?.active, true);
const repeated = changed.data.updateContact(contact.data.id, { ...props.data, active: false });
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, changed.data);
});
test("la pertenencia impide asociar o editar entidades de otra compañía", () => {
const company = companyFixture();
const other = companyFixture("Otra");
const props = input.site({ name: "Central", address: null });
assert.ok(props.isSuccess);
const site = Site.create({ ...props.data, companyId: other.id });
assert.ok(site.isSuccess);
assert.equal(company.addSite(site.data).isSuccess, false);
const missing = company.updateSite(site.data.id, props.data);
assert.equal(missing.isSuccess, false);
const contact = company.updateContact(UniqueID.generateNewID(), {
...inputContact(),
active: false,
});
assert.equal(contact.isSuccess, false);
});
function inputContact() {
const props = input.contact({
name: "Ana",
position: "Responsable",
email: "ana@example.test",
phone: "900123456",
active: true,
});
assert.ok(props.isSuccess);
return props.data;
}
test("la dirección es optativa, admite varias líneas y se puede borrar", () => {
const company = companyFixture();
const empty = input.site({ name: "Central", address: null });
const full = input.site({ name: "Central", address: "Calle Mayor, 10\n28001 Madrid" });
assert.ok(empty.isSuccess && full.isSuccess);
const site = Site.create({ ...full.data, companyId: company.id });
assert.ok(site.isSuccess);
assert.equal(site.data.id.toPrimitive().charAt(14), "7");
const added = company.addSite(site.data);
assert.ok(added.isSuccess);
const cleared = added.data.updateSite(site.data.id, empty.data);
assert.ok(cleared.isSuccess);
assert.equal(cleared.data.sites[0]?.address.getOrUndefined(), undefined);
assert.ok(cleared.data.sites[0]?.id.equals(site.data.id));
});
test("nombres, email, teléfono y longitud de dirección se validan en los Value Objects", () => {
assert.equal(input.company({ name: " ", active: true }).isSuccess, false);
assert.equal(
input.contact({ name: "Ana", position: "", email: "invalid", phone: "abc", active: true })
.isSuccess,
false
);
assert.equal(
input.contact({
name: "Ana",
position: "Responsable",
email: "ana@example.test",
phone: "abc",
active: true,
}).isSuccess,
false
);
assert.equal(input.site({ name: "Central", address: "x".repeat(10001) }).isSuccess, false);
assert.equal(input.site({ name: "Central", address: "x".repeat(10000) }).isSuccess, true);
});

View File

@ -0,0 +1,122 @@
import type { Name, UniqueID } from "@repo/rdx-ddd";
import { AggregateRoot } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CompanyError } from "./company-error";
import type { Contact, ContactProps } from "./contact.entity";
import type { Site, SiteProps } from "./site.entity";
export type CompanyProps = Readonly<{
name: Name;
active: boolean;
contacts: readonly Contact[];
sites: readonly Site[];
}>;
export class Company extends AggregateRoot<CompanyProps> {
protected constructor(props: CompanyProps, id?: UniqueID) {
super(
{
...props,
contacts: Object.freeze([...props.contacts]),
sites: Object.freeze([...props.sites]),
},
id
);
}
static create(props: Pick<CompanyProps, "name" | "active">): Result<Company, CompanyError> {
if (props.name.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return Result.ok(new Company({ ...props, contacts: [], sites: [] }));
}
static rehydrate(props: CompanyProps, id: UniqueID): Company {
return new Company(props, id);
}
get name() {
return this.props.name;
}
get active() {
return this.props.active;
}
get contacts() {
return this.props.contacts;
}
get sites() {
return this.props.sites;
}
update(values: Pick<CompanyProps, "name" | "active">): Result<Company, CompanyError> {
if (values.name.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.name.equals(values.name) && this.active === values.active
? Result.ok(this)
: Result.ok(new Company({ ...this.props, ...values }, this.id));
}
addContact(contact: Contact): Result<Company, CompanyError> {
if (!contact.companyId.equals(this.id)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
if (this.contacts.some((existing) => existing.id.equals(contact.id))) {
return Result.ok(this);
}
return Result.ok(
new Company({ ...this.props, contacts: [...this.contacts, contact] }, this.id)
);
}
updateContact(
id: UniqueID,
values: Omit<ContactProps, "companyId">
): Result<Company, CompanyError> {
const current = this.contacts.find((contact) => contact.id.equals(id));
if (!current) {
return Result.fail(new CompanyError("CONTACT_NOT_FOUND"));
}
const updated = current.update(values);
if (!updated.isSuccess) {
return updated;
}
return updated.data === current
? Result.ok(this)
: Result.ok(
new Company(
{
...this.props,
contacts: this.contacts.map((contact) =>
contact.id.equals(id) ? updated.data : contact
),
},
this.id
)
);
}
addSite(site: Site): Result<Company, CompanyError> {
if (!site.companyId.equals(this.id)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
if (this.sites.some((existing) => existing.id.equals(site.id))) {
return Result.ok(this);
}
return Result.ok(new Company({ ...this.props, sites: [...this.sites, site] }, this.id));
}
updateSite(id: UniqueID, values: Omit<SiteProps, "companyId">): Result<Company, CompanyError> {
const current = this.sites.find((site) => site.id.equals(id));
if (!current) {
return Result.fail(new CompanyError("SITE_NOT_FOUND"));
}
const updated = current.update(values);
if (!updated.isSuccess) {
return updated;
}
return updated.data === current
? Result.ok(this)
: Result.ok(
new Company(
{
...this.props,
sites: this.sites.map((site) => (site.id.equals(id) ? updated.data : site)),
},
this.id
)
);
}
}

View File

@ -0,0 +1,59 @@
import type { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CompanyError } from "./company-error";
import type { PhoneNumber } from "./value-objects/phone-number";
export type ContactProps = Readonly<{
companyId: UniqueID;
name: Name;
position: Name;
email: EmailAddress;
phone: PhoneNumber;
active: boolean;
}>;
export class Contact extends DomainEntity<ContactProps> {
static create(props: ContactProps): Result<Contact, CompanyError> {
if (props.name.toPrimitive().length > 100 || props.position.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return Result.ok(new Contact(props));
}
static rehydrate(props: ContactProps, id: UniqueID): Contact {
return new Contact(props, id);
}
get companyId() {
return this.props.companyId;
}
get name() {
return this.props.name;
}
get position() {
return this.props.position;
}
get email() {
return this.props.email;
}
get phone() {
return this.props.phone;
}
get active() {
return this.props.active;
}
update(values: Omit<ContactProps, "companyId">): Result<Contact, CompanyError> {
if (values.name.toPrimitive().length > 100 || values.position.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
if (
this.name.equals(values.name) &&
this.position.equals(values.position) &&
this.email.equals(values.email) &&
this.phone.equals(values.phone) &&
this.active === values.active
) {
return Result.ok(this);
}
return Result.ok(new Contact({ ...values, companyId: this.companyId }, this.id));
}
}

View File

@ -0,0 +1,40 @@
import type { Name, UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import { CompanyError } from "./company-error";
import type { SiteAddress } from "./value-objects/site-address";
export type SiteProps = Readonly<{ companyId: UniqueID; name: Name; address: Maybe<SiteAddress> }>;
export class Site extends DomainEntity<SiteProps> {
static create(props: SiteProps): Result<Site, CompanyError> {
if (props.name.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return Result.ok(new Site(props));
}
static rehydrate(props: SiteProps, id: UniqueID): Site {
return new Site(props, id);
}
get companyId() {
return this.props.companyId;
}
get name() {
return this.props.name;
}
get address() {
return this.props.address;
}
update(values: Omit<SiteProps, "companyId">): Result<Site, CompanyError> {
if (values.name.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
const current = this.address.getOrUndefined();
const next = values.address.getOrUndefined();
const sameAddress = current ? current.equals(next) : next === undefined;
return this.name.equals(values.name) && sameAddress
? Result.ok(this)
: Result.ok(new Site({ ...values, companyId: this.companyId }, this.id));
}
}

View File

@ -0,0 +1,16 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export class PhoneNumber extends ValueObject<string> {
static create(value: string): Result<PhoneNumber, Error> {
const normalized = value.trim();
if (
normalized.length > 50 ||
!/^\+?[0-9 ().-]{3,50}(?: ?(?:x|ext\.?) ?[0-9]{1,8})?$/.test(normalized) ||
!/[0-9]/.test(normalized)
) {
return Result.fail(new Error("Teléfono inválido."));
}
return Result.ok(new PhoneNumber(normalized));
}
}

View File

@ -0,0 +1,11 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export class SiteAddress extends ValueObject<string> {
static create(value: string): Result<SiteAddress, Error> {
const normalized = value.trim();
return normalized && normalized.length <= 10000
? Result.ok(new SiteAddress(normalized))
: Result.fail(new Error("Dirección inválida."));
}
}

View File

@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto";
import type { Result } from "@repo/rdx-utils";
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import {
ChildParamsSchema,
CompanyParamsSchema,
CompanyRequestSchema,
ContactRequestSchema,
SiteRequestSchema,
} from "../../../common";
import type { CompanyUseCases } from "../../application/company.use-cases";
import {
buildCompanyResponse,
buildCompanySummary,
} from "../../application/company-response.builder";
import type { Company } from "../../domain/company.aggregate";
import type { CompanyError } from "../../domain/company-error";
function sendError(response: Response, code: string, status: number): void {
response
.status(status)
.json({ code, requestId: response.getHeader("x-request-id")?.toString() ?? randomUUID() });
}
function sendResult(response: Response, result: Result<Company, CompanyError>, status = 200): void {
if (!result.isSuccess) {
sendError(response, result.error.code, result.error.code === "INVALID_REQUEST" ? 400 : 404);
return;
}
response.status(status).json({ company: buildCompanyResponse(result.data) });
}
export function registerCompanyRoutes(
app: Application,
useCases: CompanyUseCases,
authenticate: RequestHandler
): void {
const router = Router();
router.get("/", (request, response, next) => {
if (!z.strictObject({}).safeParse(request.query).success) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
useCases
.list()
.then((companies) => response.json({ companies: companies.map(buildCompanySummary) }))
.catch(next);
});
router.get("/:companyId", (request, response, next) => {
const parsed = CompanyParamsSchema.safeParse(request.params);
if (!parsed.success) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
useCases
.get(parsed.data.companyId)
.then((result) => sendResult(response, result))
.catch(next);
});
function post<T, P>(
path: string,
body: z.ZodType<T>,
params: z.ZodType<P>,
action: (dto: T, identifiers: P) => Promise<Result<Company, CompanyError>>,
status = 200
): void {
router.post(path, (request, response, next) => {
const dto = body.safeParse(request.body);
const identifiers = params.safeParse(request.params);
if (!(dto.success && identifiers.success)) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
action(dto.data, identifiers.data)
.then((result) => sendResult(response, result, status))
.catch(next);
});
}
post("/", CompanyRequestSchema, z.strictObject({}), (dto) => useCases.create(dto), 201);
post("/:companyId/update", CompanyRequestSchema, CompanyParamsSchema, (dto, params) =>
useCases.update(params.companyId, dto)
);
post(
"/:companyId/contacts",
ContactRequestSchema,
CompanyParamsSchema,
(dto, params) => useCases.addContact(params.companyId, dto),
201
);
post("/:companyId/contacts/:id/update", ContactRequestSchema, ChildParamsSchema, (dto, params) =>
useCases.updateContact(params.companyId, params.id, dto)
);
post(
"/:companyId/sites",
SiteRequestSchema,
CompanyParamsSchema,
(dto, params) => useCases.addSite(params.companyId, dto),
201
);
post("/:companyId/sites/:id/update", SiteRequestSchema, ChildParamsSchema, (dto, params) =>
useCases.updateSite(params.companyId, params.id, dto)
);
app.use("/api/companies", authenticate, router);
}

View File

@ -0,0 +1,73 @@
import type { Result as DomainResult } from "@repo/rdx-utils";
import type { InferAttributes } from "sequelize";
import { CompanyInputMapper } from "../../application/company-input.mapper";
import { Company } from "../../domain/company.aggregate";
import { Contact } from "../../domain/contact.entity";
import { Site } from "../../domain/site.entity";
import type { CompanyModel, ContactModel, SiteModel } from "./company.model";
export type CompanyRecord = {
company: InferAttributes<CompanyModel>;
contacts: InferAttributes<ContactModel>[];
sites: InferAttributes<SiteModel>[];
};
function persisted<T>(result: DomainResult<T, Error>): T {
if (!result.isSuccess) {
throw new Error("Persistencia de compañía inconsistente.");
}
return result.data;
}
export class CompanyPersistenceMapper {
private readonly input = new CompanyInputMapper();
toDomain(record: CompanyRecord): Company {
const id = persisted(this.input.id(record.company.id));
const props = persisted(this.input.company(record.company));
const contacts = record.contacts.map((row) => {
const companyId = persisted(this.input.id(row.companyId));
if (!companyId.equals(id)) {
throw new Error("Persistencia de contacto inconsistente.");
}
return Contact.rehydrate(
{ ...persisted(this.input.contact(row)), companyId },
persisted(this.input.id(row.id))
);
});
const sites = record.sites.map((row) => {
const companyId = persisted(this.input.id(row.companyId));
if (!companyId.equals(id)) {
throw new Error("Persistencia de sede inconsistente.");
}
return Site.rehydrate(
{ ...persisted(this.input.site(row)), companyId },
persisted(this.input.id(row.id))
);
});
return Company.rehydrate({ ...props, contacts, sites }, id);
}
toPersistence(company: Company): CompanyRecord {
return {
company: {
id: company.id.toPrimitive(),
name: company.name.toPrimitive(),
active: company.active,
},
contacts: company.contacts.map((contact) => ({
id: contact.id.toPrimitive(),
companyId: contact.companyId.toPrimitive(),
name: contact.name.toPrimitive(),
position: contact.position.toPrimitive(),
email: contact.email.toPrimitive(),
phone: contact.phone.toPrimitive(),
active: contact.active,
})),
sites: company.sites.map((site) => ({
id: site.id.toPrimitive(),
companyId: site.companyId.toPrimitive(),
name: site.name.toPrimitive(),
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
})),
};
}
}

View File

@ -0,0 +1,61 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model } from "sequelize";
export class CompanyModel extends Model<
InferAttributes<CompanyModel>,
InferCreationAttributes<CompanyModel>
> {
declare id: string;
declare name: string;
declare active: boolean;
}
export class ContactModel extends Model<
InferAttributes<ContactModel>,
InferCreationAttributes<ContactModel>
> {
declare id: string;
declare companyId: string;
declare name: string;
declare position: string;
declare email: string;
declare phone: string;
declare active: boolean;
}
export class SiteModel extends Model<
InferAttributes<SiteModel>,
InferCreationAttributes<SiteModel>
> {
declare id: string;
declare companyId: string;
declare name: string;
declare address: string | null;
}
export function initializeCompanyModels(database: Sequelize): void {
CompanyModel.init(
{
id: { type: DataTypes.UUID, primaryKey: true },
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
},
{ sequelize: database, tableName: "companies", timestamps: false }
);
const childFields = {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: { type: DataTypes.UUID, allowNull: false },
name: { type: DataTypes.STRING(100), allowNull: false },
};
ContactModel.init(
{
...childFields,
position: { type: DataTypes.STRING(100), allowNull: false },
email: { type: DataTypes.STRING(254), allowNull: false },
phone: { type: DataTypes.STRING(50), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
},
{ sequelize: database, tableName: "company_contacts", timestamps: false }
);
SiteModel.init(
{ ...childFields, address: { type: DataTypes.TEXT, allowNull: true } },
{ sequelize: database, tableName: "company_sites", timestamps: false }
);
}

View File

@ -0,0 +1,43 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
export async function migrateCompanies(database: Sequelize): Promise<void> {
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 tables = rows.map((row) => row.name);
if (!tables.includes("companies")) {
await query.createTable("companies", {
id: { type: DataTypes.UUID, primaryKey: true },
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
});
}
const childFields = {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "companies", key: "id" },
onDelete: "RESTRICT",
},
name: { type: DataTypes.STRING(100), allowNull: false },
};
if (!tables.includes("company_contacts")) {
await query.createTable("company_contacts", {
...childFields,
position: { type: DataTypes.STRING(100), allowNull: false },
email: { type: DataTypes.STRING(254), allowNull: false },
phone: { type: DataTypes.STRING(50), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
});
}
if (!tables.includes("company_sites")) {
await query.createTable("company_sites", {
...childFields,
address: { type: DataTypes.TEXT, allowNull: true },
});
}
}

View File

@ -0,0 +1,105 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result as DomainResult } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { Op } from "sequelize";
import type { ICompanyRepository } from "../../application/company.repository";
import type { Company } from "../../domain/company.aggregate";
import { CompanyError } from "../../domain/company-error";
import { CompanyModel, ContactModel, SiteModel } from "./company.model";
import type { CompanyPersistenceMapper } from "./company-persistence.mapper";
export class SequelizeCompanyRepository implements ICompanyRepository {
constructor(
private readonly database: Sequelize,
private readonly mapper: CompanyPersistenceMapper
) {}
async list(): Promise<readonly Company[]> {
const companies = await CompanyModel.findAll({
order: [
["name", "ASC"],
["id", "ASC"],
],
});
if (companies.length === 0) {
return [];
}
const ids = companies.map((company) => company.id);
const [contacts, sites] = await Promise.all([
ContactModel.findAll({
where: { companyId: { [Op.in]: ids } },
order: [
["name", "ASC"],
["id", "ASC"],
],
}),
SiteModel.findAll({
where: { companyId: { [Op.in]: ids } },
order: [
["name", "ASC"],
["id", "ASC"],
],
}),
]);
return companies.map((company) =>
this.mapper.toDomain({
company,
contacts: contacts.filter((contact) => contact.companyId === company.id),
sites: sites.filter((site) => site.companyId === company.id),
})
);
}
private async load(id: UniqueID, transaction?: Transaction): Promise<Maybe<Company>> {
const options = transaction ? { transaction, lock: transaction.LOCK.UPDATE } : {};
const company = await CompanyModel.findByPk(id.toPrimitive(), options);
if (!company) {
return Maybe.none();
}
const childOptions = {
where: { companyId: company.id },
...(transaction ? { transaction } : {}),
};
const contacts = await ContactModel.findAll({ ...childOptions, order: [["name", "ASC"]] });
const sites = await SiteModel.findAll({ ...childOptions, order: [["name", "ASC"]] });
return Maybe.some(this.mapper.toDomain({ company, contacts, sites }));
}
findById(id: UniqueID) {
return this.load(id);
}
async create(company: Company): Promise<Company> {
await CompanyModel.create(this.mapper.toPersistence(company).company);
return company;
}
async update(
id: UniqueID,
change: (company: Company) => DomainResult<Company, CompanyError>
): Promise<DomainResult<Company, CompanyError>> {
return this.database.transaction(async (transaction) => {
const current = (await this.load(id, transaction)).getOrUndefined();
if (!current) {
return Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
}
const changed = change(current);
if (!changed.isSuccess || changed.data === current) {
return changed;
}
const record = this.mapper.toPersistence(changed.data);
await CompanyModel.update(record.company, { where: { id: id.toPrimitive() }, transaction });
if (record.contacts.length > 0) {
await ContactModel.bulkCreate(record.contacts, {
transaction,
updateOnDuplicate: ["name", "position", "email", "phone", "active"],
});
}
if (record.sites.length > 0) {
await SiteModel.bulkCreate(record.sites, {
transaction,
updateOnDuplicate: ["name", "address"],
});
}
return changed;
});
}
}

View File

@ -0,0 +1,23 @@
import type { RequestHandler } from "express";
import type { Sequelize } from "sequelize";
import { CompanyUseCases } from "./application/company.use-cases";
import { CompanyInputMapper } from "./application/company-input.mapper";
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) {
await migrateCompanies(database);
initializeCompanyModels(database);
const useCases = new CompanyUseCases(
new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()),
new CompanyInputMapper()
);
return {
register: (app: Parameters<typeof registerCompanyRoutes>[0]) =>
registerCompanyRoutes(app, useCases, authenticate),
};
}

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
import { CompanyFieldsSchema, ContactFieldsSchema, SiteFieldsSchema } from "../shared/company.dto";
export const CompanyRequestSchema = CompanyFieldsSchema;
export type CompanyRequestDTO = z.infer<typeof CompanyRequestSchema>;
export const ContactRequestSchema = ContactFieldsSchema;
export type ContactRequestDTO = z.infer<typeof ContactRequestSchema>;
export const SiteRequestSchema = SiteFieldsSchema.extend({
address: SiteFieldsSchema.shape.address.optional(),
});
export type SiteRequestDTO = z.infer<typeof SiteRequestSchema>;
export const CompanyParamsSchema = z.strictObject({ companyId: z.uuid() });
export const ChildParamsSchema = CompanyParamsSchema.extend({ id: z.uuid() });

View File

@ -0,0 +1,17 @@
import { z } from "zod/v4";
import { CompanySchema, CompanySummarySchema } from "../shared/company.dto";
export const CompaniesResponseSchema = z.strictObject({ companies: z.array(CompanySummarySchema) });
export const CompanyResponseSchema = z.strictObject({ company: CompanySchema });
export const CompanyErrorResponseSchema = z.strictObject({
code: z.enum([
"INVALID_REQUEST",
"COMPANY_NOT_FOUND",
"CONTACT_NOT_FOUND",
"SITE_NOT_FOUND",
"UNAUTHENTICATED",
"FORBIDDEN",
"INTERNAL_ERROR",
]),
requestId: z.uuid(),
});

View File

@ -0,0 +1,33 @@
import { z } from "zod/v4";
const RequiredTextSchema = z
.string()
.min(1)
.max(100)
.refine((value) => value.trim().length > 0);
export const CompanyFieldsSchema = z.strictObject({
name: RequiredTextSchema,
active: z.boolean(),
});
export const ContactFieldsSchema = z.strictObject({
name: RequiredTextSchema,
position: RequiredTextSchema,
email: z.email().max(254),
phone: z.string().min(3).max(50),
active: z.boolean(),
});
export const SiteFieldsSchema = z.strictObject({
name: RequiredTextSchema,
address: z.string().max(10000).nullable(),
});
export const CompanySummarySchema = CompanyFieldsSchema.extend({ id: z.uuid() });
export type CompanySummaryDTO = z.infer<typeof CompanySummarySchema>;
export const ContactSchema = ContactFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() });
export type ContactDTO = z.infer<typeof ContactSchema>;
export const SiteSchema = SiteFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() });
export type SiteDTO = z.infer<typeof SiteSchema>;
export const CompanySchema = CompanySummarySchema.extend({
contacts: z.array(ContactSchema),
sites: z.array(SiteSchema),
});
export type CompanyDTO = z.infer<typeof CompanySchema>;

View File

@ -0,0 +1,3 @@
export * from "./dto/request/company.request.dto";
export * from "./dto/response/company.response.dto";
export * from "./dto/shared/company.dto";

View File

@ -0,0 +1,60 @@
import type { z } from "zod/v4";
import {
CompaniesResponseSchema,
CompanyErrorResponseSchema,
CompanyResponseSchema,
} from "../../common";
export const companyKeys = {
all: ["companies"],
list: ["companies", "list"],
detail: (id: string) => ["companies", "detail", id],
};
export class CompanyApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export async function companyRequest<T>(
path: string,
schema: z.ZodType<T>,
body?: unknown,
csrfToken?: string
): Promise<T> {
const response = await fetch(`/api/companies${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json: unknown = await response.json();
if (!response.ok) {
const error = CompanyErrorResponseSchema.safeParse(json);
throw new CompanyApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return schema.parse(json);
}
export const companyApi = {
list: () => companyRequest("", CompaniesResponseSchema),
get: (id: string) => companyRequest(`/${id}`, CompanyResponseSchema),
save: (id: string | undefined, body: unknown, token: string) =>
companyRequest(id ? `/${id}/update` : "", CompanyResponseSchema, body, token),
contact: (companyId: string, id: string | undefined, body: unknown, token: string) =>
companyRequest(
`/${companyId}/contacts${id ? `/${id}/update` : ""}`,
CompanyResponseSchema,
body,
token
),
site: (companyId: string, id: string | undefined, body: unknown, token: string) =>
companyRequest(
`/${companyId}/sites${id ? `/${id}/update` : ""}`,
CompanyResponseSchema,
body,
token
),
};

View File

@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { test } from "node:test";
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 { companyKeys } from "../api/company-api";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { CompaniesPage } from "./companies-page";
import { CompanyDetail } from "./company-detail";
import { SiteForm } from "./site-form";
const companyId = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4";
async function renderPage(
language: string,
children: ReactNode,
seed: (client: QueryClient) => void
) {
const i18n = createInstance();
await i18n.init({
lng: language,
fallbackLng: "es",
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
interpolation: { escapeValue: false },
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
seed(client);
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("el listado presenta estado vacío y acceso al alta sin formularios visibles", async () => {
for (const [language, title, empty] of [
["es", "Compañías", "Todavía no hay compañías"],
["en", "Companies", "No companies yet"],
] as const) {
const markup = await renderPage(language, <CompaniesPage csrfToken="test" />, (client) =>
client.setQueryData(companyKeys.list, { companies: [] })
);
assert.ok(markup.includes(title));
assert.ok(markup.includes(empty));
assert.ok(markup.includes(language === "es" ? "Nueva compañía" : "New company"));
assert.equal(markup.includes("<form"), false);
assert.equal(markup.includes('name="name"'), false);
}
});
test("la ficha presenta datos de la compañía, contactos y acceso a sedes", async () => {
const markup = await renderPage(
"es",
<CompanyDetail csrfToken="test" id={companyId} onBack={() => undefined} />,
(client) =>
client.setQueryData(companyKeys.detail(companyId), {
company: {
id: companyId,
name: "Empresa test",
active: true,
contacts: [
{
id: companyId,
companyId,
name: "Ana",
position: "Responsable IT",
email: "ana@example.test",
phone: "900123456",
active: false,
},
],
sites: [],
},
})
);
assert.ok(markup.includes("Empresa test"));
assert.ok(markup.includes("Responsable IT"));
assert.ok(markup.includes("ana@example.test"));
assert.ok(markup.includes("Sedes (0)"));
assert.ok(markup.includes("Nuevo contacto"));
assert.ok(markup.includes("Editar compañía"));
assert.equal(markup.includes("<form"), false);
});
test("el formulario de sede ofrece dirección multilínea optativa", async () => {
const markup = await renderPage(
"es",
<SiteForm companyId={companyId} csrfToken="test" />,
() => undefined
);
assert.ok(markup.includes("Dirección (opcional)"));
assert.match(markup, /<textarea[^>]*rows="6"/);
assert.match(markup, /<textarea[^>]*name="address"/);
const textarea = markup.match(/<textarea[^>]*>/)?.[0];
assert.ok(textarea);
assert.equal(textarea.includes("required"), false);
});

View File

@ -0,0 +1,143 @@
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 { Label } from "@repo/shadcn-ui/components/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanySummaryDTO } from "../../common";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyDetail } from "./company-detail";
import { CompanyFeedback } from "./company-feedback";
import { CompanyForm } from "./company-form";
const EMPTY_COMPANIES: CompanySummaryDTO[] = [];
const COMPANY_COLUMNS: ColumnDef<CompanySummaryDTO>[] = [
{ accessorKey: "name" },
{ accessorKey: "active", enableGlobalFilter: false },
];
export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
const { t } = useTranslation("companies");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [selectedId, setSelectedId] = useState<string | undefined>();
const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState("");
const id = useId();
const table = useReactTable({
data: query.data?.companies ?? EMPTY_COMPANIES,
columns: COMPANY_COLUMNS,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { globalFilter: search.trim() },
globalFilterFn: "includesString",
});
if (selectedId) {
return (
<CompanyDetail
csrfToken={csrfToken}
id={selectedId}
key={selectedId}
onBack={() => setSelectedId(undefined)}
/>
);
}
const companies = table.getRowModel().rows.map((row) => row.original);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="mt-2 text-muted-foreground">{t("help")}</p>
</div>
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>
</div>
<div className="space-y-2">
<Label htmlFor={id}>{t("search")}</Label>
<Input
className="max-w-md"
id={id}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("searchPlaceholder")}
value={search}
/>
</div>
{query.isPending && <p role="status">{t("loading")}</p>}
<CompanyFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<Card>
<CardContent className="pt-6">
{companies.length === 0 ? (
<p className="text-muted-foreground">{t(search ? "noResults" : "empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell>
<TableCell>{t(company.active ? "active" : "inactive")}</TableCell>
<TableCell>
<Button
onClick={() => setSelectedId(company.id)}
size="sm"
variant="outline"
>
{t("open")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={setCreateOpen}
open={createOpen}
title={t("newCompany")}
>
<CompanyForm
csrfToken={csrfToken}
onCreated={(companyId) => {
setCreateOpen(false);
setSelectedId(companyId);
}}
showTitle={false}
/>
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,262 @@
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
import { CompanyForm } from "./company-form";
import { ContactForm } from "./contact-form";
import { SiteForm } from "./site-form";
function ContactSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t } = useTranslation("companies");
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const selected = company.contacts.find((contact) => contact.id === selectedId);
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold">{t("contacts")}</h2>
<Button onClick={() => setCreateOpen(true)}>{t("newContact")}</Button>
</div>
{company.contacts.length === 0 ? (
<p className="text-muted-foreground">{t("noContacts")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("position")}</TableHead>
<TableHead>{t("email")}</TableHead>
<TableHead>{t("phone")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{company.contacts.map((contact) => (
<TableRow key={contact.id}>
<TableCell>{contact.name}</TableCell>
<TableCell>{contact.position}</TableCell>
<TableCell>{contact.email}</TableCell>
<TableCell>{contact.phone}</TableCell>
<TableCell>{t(contact.active ? "active" : "inactive")}</TableCell>
<TableCell>
<Button onClick={() => setSelectedId(contact.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={setCreateOpen}
open={createOpen}
title={t("newContact")}
>
<ContactForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={() => setCreateOpen(false)}
showTitle={false}
/>
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(open) => {
if (!open) {
setSelectedId(undefined);
}
}}
open={selected !== undefined}
title={t("editContact")}
>
{selected && (
<ContactForm
companyId={company.id}
contact={selected}
csrfToken={csrfToken}
key={selected.id}
onSaved={() => setSelectedId(undefined)}
showTitle={false}
/>
)}
</FormDialog>
</div>
);
}
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t } = useTranslation("companies");
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const selected = company.sites.find((site) => site.id === selectedId);
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold">{t("sites")}</h2>
<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>
</div>
{company.sites.length === 0 ? (
<p className="text-muted-foreground">{t("noSites")}</p>
) : (
<div className="divide-y">
{company.sites.map((site) => (
<div className="flex items-start justify-between gap-4 py-4" key={site.id}>
<div className="min-w-0">
<h3 className="font-medium">{site.name}</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{site.address ?? t("noAddress")}
</p>
</div>
<Button onClick={() => setSelectedId(site.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={setCreateOpen}
open={createOpen}
title={t("newSite")}
>
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={() => setCreateOpen(false)}
showTitle={false}
/>
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(open) => {
if (!open) {
setSelectedId(undefined);
}
}}
open={selected !== undefined}
title={t("editSite")}
>
{selected && (
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
key={selected.id}
onSaved={() => setSelectedId(undefined)}
showTitle={false}
site={selected}
/>
)}
</FormDialog>
</div>
);
}
export function CompanyDetail({
id,
csrfToken,
onBack,
}: {
id: string;
csrfToken: string;
onBack: () => void;
}) {
const { t } = useTranslation("companies");
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState<"contacts" | "sites">("contacts");
const company = query.data?.company;
return (
<div className="space-y-6">
<Button onClick={onBack} variant="outline">
{t("back")}
</Button>
{query.isPending && <p role="status">{t("loading")}</p>}
<CompanyFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{company && (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{company.name}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t(company.active ? "activeCompany" : "inactiveCompany")}
</p>
</div>
<Button onClick={() => setEditOpen(true)} variant="outline">
{t("editCompany")}
</Button>
</div>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={setEditOpen}
open={editOpen}
title={t("editCompany")}
>
<CompanyForm
company={company}
csrfToken={csrfToken}
key={company.id}
onSaved={() => setEditOpen(false)}
showTitle={false}
/>
</FormDialog>
<nav aria-label={t("companySections")} className="flex gap-2">
<Button
aria-pressed={tab === "contacts"}
onClick={() => setTab("contacts")}
variant={tab === "contacts" ? "secondary" : "outline"}
>
{t("contacts")} ({company.contacts.length})
</Button>
<Button
aria-pressed={tab === "sites"}
onClick={() => setTab("sites")}
variant={tab === "sites" ? "secondary" : "outline"}
>
{t("sites")} ({company.sites.length})
</Button>
</nav>
{tab === "contacts" ? (
<ContactSection company={company} csrfToken={csrfToken} />
) : (
<SiteSection company={company} csrfToken={csrfToken} />
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { CompanyApiError } from "../api/company-api";
export function CompanyFeedback({
error,
success = false,
invalid = false,
}: {
error: Error | null;
success?: boolean;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
return (
<>
{invalid && (
<p className="text-sm text-destructive" role="alert">
{t("invalidForm")}
</p>
)}
{error && (
<p className="text-sm text-destructive" role="alert">
{t(`errors.${error instanceof CompanyApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
)}
{success && (
<p className="text-sm text-primary" role="status">
{t("saved")}
</p>
)}
</>
);
}

View File

@ -0,0 +1,80 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CompanyRequestDTO, CompanySummaryDTO } from "../../common";
import { CompanyRequestSchema } from "../../common";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
export function CompanyForm({
company,
csrfToken,
onCreated,
onSaved,
showTitle = true,
}: {
company?: CompanySummaryDTO;
csrfToken: string;
onCreated?: (id: string) => void;
onSaved?: () => void;
showTitle?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const form = useForm<CompanyRequestDTO>({
resolver: zodResolver(CompanyRequestSchema),
defaultValues: { name: company?.name ?? "", active: company?.active ?? true },
});
const mutation = useMutation({
mutationFn: (values: CompanyRequestDTO) => companyApi.save(company?.id, values, csrfToken),
onSuccess: async (response) => {
client.setQueryData(companyKeys.detail(response.company.id), response);
await client.invalidateQueries({ queryKey: companyKeys.list });
if (company) {
form.reset({ name: response.company.name, active: response.company.active });
} else {
form.reset();
onCreated?.(response.company.id);
}
onSaved?.();
},
});
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
{showTitle && (
<h2 className="text-lg font-semibold">{t(company ? "editCompany" : "newCompany")}</h2>
)}
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
id={`${id}-active`}
onCheckedChange={(checked) =>
form.setValue("active", checked === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${id}-active`}>{t("activeCompany")}</Label>
</div>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
success={mutation.isSuccess}
/>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</form>
);
}

View File

@ -0,0 +1,110 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { ContactDTO, ContactRequestDTO } from "../../common";
import { ContactRequestSchema } from "../../common";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
export function ContactForm({
contact,
companyId,
csrfToken,
onSaved,
showTitle = true,
}: {
contact?: ContactDTO;
companyId: string;
csrfToken: string;
onSaved?: () => void;
showTitle?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const form = useForm<ContactRequestDTO>({
resolver: zodResolver(ContactRequestSchema),
defaultValues: {
name: contact?.name ?? "",
position: contact?.position ?? "",
email: contact?.email ?? "",
phone: contact?.phone ?? "",
active: contact?.active ?? true,
},
});
const mutation = useMutation({
mutationFn: (values: ContactRequestDTO) =>
companyApi.contact(companyId, contact?.id, values, csrfToken),
onSuccess: (response) => {
client.setQueryData(companyKeys.detail(companyId), response);
if (!contact) {
form.reset();
}
onSaved?.();
},
});
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
{showTitle && (
<h3 className="text-lg font-semibold">{t(contact ? "editContact" : "newContact")}</h3>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-position`}>{t("position")}</Label>
<Input id={`${id}-position`} maxLength={100} required {...form.register("position")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-email`}>{t("email")}</Label>
<Input
id={`${id}-email`}
maxLength={254}
required
type="email"
{...form.register("email")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-phone`}>{t("phone")}</Label>
<Input
id={`${id}-phone`}
maxLength={50}
minLength={3}
required
type="tel"
{...form.register("phone")}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
id={`${id}-active`}
onCheckedChange={(checked) =>
form.setValue("active", checked === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${id}-active`}>{t("activeContact")}</Label>
</div>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
success={mutation.isSuccess}
/>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</form>
);
}

View File

@ -0,0 +1,81 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { SiteDTO, SiteRequestDTO } from "../../common";
import { SiteRequestSchema } from "../../common";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
export function SiteForm({
site,
companyId,
csrfToken,
onSaved,
showTitle = true,
}: {
site?: SiteDTO;
companyId: string;
csrfToken: string;
onSaved?: () => void;
showTitle?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const form = useForm<SiteRequestDTO>({
resolver: zodResolver(SiteRequestSchema),
defaultValues: { name: site?.name ?? "", address: site?.address ?? "" },
});
const mutation = useMutation({
mutationFn: (values: SiteRequestDTO) =>
companyApi.site(
companyId,
site?.id,
{ ...values, address: values.address?.trim() ? values.address : null },
csrfToken
),
onSuccess: (response) => {
client.setQueryData(companyKeys.detail(companyId), response);
if (!site) {
form.reset();
}
onSaved?.();
},
});
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
{showTitle && <h3 className="text-lg font-semibold">{t(site ? "editSite" : "newSite")}</h3>}
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-address`}>{t("addressOptional")}</Label>
<Textarea
className="min-h-40"
id={`${id}-address`}
maxLength={10000}
rows={6}
{...form.register("address")}
/>
<p className="text-xs text-muted-foreground">{t("addressHelp")}</p>
</div>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
success={mutation.isSuccess}
/>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</form>
);
}

View File

@ -0,0 +1,106 @@
export const companiesSpanish = {
title: "Compañías",
help: "Gestiona las empresas clientes, sus personas de contacto y sus sedes.",
name: "Nombre",
status: "Estado",
actions: "Acciones",
active: "Activa",
inactive: "Inactiva",
activeCompany: "Compañía activa",
inactiveCompany: "Compañía inactiva",
activeContact: "Contacto activo",
newCompany: "Nueva compañía",
editCompany: "Editar compañía",
newContact: "Nuevo contacto",
editContact: "Editar contacto",
newSite: "Nueva sede",
editSite: "Editar sede",
contacts: "Contactos",
sites: "Sedes",
position: "Puesto",
email: "Email",
phone: "Teléfono",
addressOptional: "Dirección (opcional)",
addressHelp: "Puedes introducir varias líneas, hasta 10.000 caracteres.",
noAddress: "Sin dirección",
empty: "Todavía no hay compañías. Crea la primera para añadir contactos y sedes.",
noContacts: "Todavía no hay contactos en esta compañía.",
noSites: "Todavía no hay sedes en esta compañía.",
search: "Buscar compañía",
searchPlaceholder: "Nombre de la compañía…",
noResults: "No se han encontrado compañías.",
companySections: "Información de la compañía",
open: "Abrir ficha",
edit: "Editar",
back: "Volver a compañías",
close: "Cerrar edición",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando…",
saved: "Cambios guardados.",
loading: "Cargando compañías…",
retry: "Reintentar",
invalidForm: "Revisa los campos del formulario.",
errors: {
INVALID_REQUEST: "Revisa los datos enviados. El teléfono debe ser un número válido.",
COMPANY_NOT_FOUND: "La compañía no está disponible.",
CONTACT_NOT_FOUND: "El contacto no pertenece a esta compañía o no está disponible.",
SITE_NOT_FOUND: "La sede no pertenece a esta compañía o no está disponible.",
UNAUTHENTICATED: "La sesión ha caducado. Vuelve a entrar.",
FORBIDDEN: "No se ha podido autorizar la operación. Recarga la página.",
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
},
};
export const companiesEnglish = {
title: "Companies",
help: "Manage client companies, their contact people and their sites.",
name: "Name",
status: "Status",
actions: "Actions",
active: "Active",
inactive: "Inactive",
activeCompany: "Active company",
inactiveCompany: "Inactive company",
activeContact: "Active contact",
newCompany: "New company",
editCompany: "Edit company",
newContact: "New contact",
editContact: "Edit contact",
newSite: "New site",
editSite: "Edit site",
contacts: "Contacts",
sites: "Sites",
position: "Job title",
email: "Email",
phone: "Phone",
addressOptional: "Address (optional)",
addressHelp: "Enter multiple lines, up to 10,000 characters.",
noAddress: "No address",
empty: "No companies yet. Create the first one to add contacts and sites.",
noContacts: "No contacts in this company yet.",
noSites: "No sites in this company yet.",
search: "Find company",
searchPlaceholder: "Company name…",
noResults: "No companies found.",
companySections: "Company information",
open: "Open details",
edit: "Edit",
back: "Back to companies",
close: "Close editor",
cancel: "Cancel",
save: "Save",
saving: "Saving…",
saved: "Changes saved.",
loading: "Loading companies…",
retry: "Try again",
invalidForm: "Review the form fields.",
errors: {
INVALID_REQUEST: "Review the submitted data. Enter a valid phone number.",
COMPANY_NOT_FOUND: "The company is not available.",
CONTACT_NOT_FOUND: "The contact does not belong to this company or is not available.",
SITE_NOT_FOUND: "The site does not belong to this company or is not available.",
UNAUTHENTICATED: "Your session expired. Sign in again.",
FORBIDDEN: "The operation could not be authorized. Reload the page.",
INTERNAL_ERROR: "The operation could not be completed. Try again.",
},
};

View File

@ -0,0 +1,3 @@
export { companyKeys } from "./api/company-api";
export { CompaniesPage } from "./components/companies-page";
export { companiesEnglish, companiesSpanish } from "./i18n";

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src"]
}

35
modules/core/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "@metis/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./common": "./src/common/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "node --import tsx --test src/common/dto/response/get-health.response.dto.test.ts"
},
"dependencies": {
"zod": "4.3.6",
"@repo/shadcn-ui": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/express": "4.17.21",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"react": "19.2.5",
"react-dom": "19.2.5"
},
"peerDependencies": {
"express": "4.22.1",
"i18next": "26.0.4",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
}
}

View File

@ -0,0 +1,24 @@
import type { Application } from "express";
import { GetHealthRequestSchema } from "../../../common/dto/request/get-health.request.dto";
import type { GetHealthResponseDTO } from "../../../common/dto/response/get-health.response.dto";
import type { HttpErrorResponseDTO } from "../../../common/dto/response/http-error.response.dto";
/** Registra la comprobación de vida del proceso; no comprueba dependencias externas. */
export function registerCoreRoutes(app: Application): void {
app.get("/api/__health", (request, response) => {
if (!GetHealthRequestSchema.safeParse(request.query).success) {
const error: HttpErrorResponseDTO = {
code: "INVALID_REQUEST",
requestId: response.getHeader("x-request-id")?.toString() ?? "",
};
response.status(400).json(error);
return;
}
const health: GetHealthResponseDTO = {
status: "ok",
time: new Date().toISOString(),
};
response.status(200).json(health);
});
}

View File

@ -0,0 +1 @@
export { registerCoreRoutes } from "./infrastructure/http/core.routes";

View File

@ -0,0 +1,4 @@
import { z } from "zod/v4";
export const GetHealthRequestSchema = z.strictObject({});
export type GetHealthRequestDTO = z.infer<typeof GetHealthRequestSchema>;

View File

@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { GetHealthRequestSchema } from "../request/get-health.request.dto";
import { GetHealthResponseSchema } from "./get-health.response.dto";
test("el contrato de estado exige un timestamp UTC y un estado conocido", () => {
assert.equal(
GetHealthResponseSchema.safeParse({
status: "ok",
time: "2026-10-02T08:00:00.000Z",
}).success,
true
);
for (const value of [
{ status: "ready", time: "2026-10-02T08:00:00.000Z" },
{ status: "ok", time: "02/10/2026" },
{ status: "ok", time: "2026-10-02T08:00:00.000Z", secret: "hidden" },
]) {
assert.equal(GetHealthResponseSchema.safeParse(value).success, false);
}
});
test("el endpoint de estado no acepta parámetros de consulta", () => {
assert.equal(GetHealthRequestSchema.safeParse({}).success, true);
assert.equal(GetHealthRequestSchema.safeParse({ companyId: "external" }).success, false);
});

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const GetHealthResponseSchema = z.strictObject({
status: z.literal("ok"),
time: z.iso.datetime(),
});
export type GetHealthResponseDTO = z.infer<typeof GetHealthResponseSchema>;

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const HttpErrorResponseSchema = z.strictObject({
code: z.enum(["INVALID_REQUEST", "NOT_FOUND", "INTERNAL_ERROR"]),
requestId: z.uuid(),
});
export type HttpErrorResponseDTO = z.infer<typeof HttpErrorResponseSchema>;

View File

@ -0,0 +1,6 @@
export type { GetHealthRequestDTO } from "./dto/request/get-health.request.dto";
export { GetHealthRequestSchema } from "./dto/request/get-health.request.dto";
export type { GetHealthResponseDTO } from "./dto/response/get-health.response.dto";
export { GetHealthResponseSchema } from "./dto/response/get-health.response.dto";
export type { HttpErrorResponseDTO } from "./dto/response/http-error.response.dto";
export { HttpErrorResponseSchema } from "./dto/response/http-error.response.dto";

View File

@ -0,0 +1,16 @@
{
"subtitle": "Technical support, with context.",
"workspace": "Your support workspace",
"description": "Customers, devices and interventions connected in one place.",
"foundation": "We are preparing the foundation of your workspace.",
"next": "First functional release",
"nextDescription": "Customers, contacts and sites: the information you need before starting an intervention.",
"statusLabel": "Connection",
"loading": "Checking connection…",
"connected": "Connection available",
"error": "We cannot connect right now.",
"retry": "Check again",
"language": "Language",
"languageError": "The language could not be changed.",
"startupError": "Metis Desk could not start. Reload the page."
}

View File

@ -0,0 +1,16 @@
{
"subtitle": "Soporte técnico, con contexto.",
"workspace": "Tu espacio de soporte",
"description": "Clientes, equipos e intervenciones conectados en un mismo lugar.",
"foundation": "Estamos preparando la base de tu espacio de trabajo.",
"next": "Primera entrega funcional",
"nextDescription": "Clientes, contactos y sedes: la información que necesitas antes de empezar una intervención.",
"statusLabel": "Conexión",
"loading": "Comprobando conexión…",
"connected": "Conexión disponible",
"error": "No podemos conectar en este momento.",
"retry": "Volver a comprobar",
"language": "Idioma",
"languageError": "No se ha podido cambiar el idioma.",
"startupError": "No se ha podido iniciar Metis Desk. Recarga la página."
}

View File

@ -0,0 +1,10 @@
import type { GetHealthResponseDTO } from "../../common";
import { GetHealthResponseSchema } from "../../common";
export async function getHealth(signal: AbortSignal): Promise<GetHealthResponseDTO> {
const response = await fetch("/api/__health", { signal, cache: "no-store" });
if (!response.ok) {
throw new Error("Health request failed");
}
return GetHealthResponseSchema.parse(await response.json());
}

View File

@ -0,0 +1,4 @@
export { default as coreEnglish } from "../common/locales/en.json";
export { default as coreSpanish } from "../common/locales/es.json";
export { HomePage } from "./pages/home-page";

View File

@ -0,0 +1,76 @@
import { Button } from "@repo/shadcn-ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components/card";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getHealth } from "../api/core.api";
import { coreQueryKeys } from "../queries/core.query-keys";
export function HomePage() {
const { t } = useTranslation("core");
const health = useQuery({
queryKey: coreQueryKeys.health,
queryFn: ({ signal }) => getHealth(signal),
retry: 1,
staleTime: 30_000,
});
return (
<main className="mx-auto max-w-5xl px-6 py-14 md:py-24">
<p className="mb-4 text-sm font-semibold tracking-wide text-primary">{t("subtitle")}</p>
<h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-foreground md:text-6xl">
{t("workspace")}
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">{t("description")}</p>
<section aria-label={t("workspace")} className="mt-12 grid gap-6 md:grid-cols-[1fr_20rem]">
<Card>
<CardHeader>
<CardDescription>{t("foundation")}</CardDescription>
<CardTitle className="mt-3 text-xl font-semibold">
<h2>{t("next")}</h2>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-base leading-7 text-muted-foreground">{t("nextDescription")}</p>
</CardContent>
</Card>
<Card className="dark">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
<h2>{t("statusLabel")}</h2>
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-medium" role="status">
{health.isPending ? t("loading") : health.isError ? t("error") : t("connected")}
</p>
</CardContent>
<CardFooter>
<Button
disabled={health.isFetching}
onClick={() => {
health.refetch().catch((error: unknown) => {
console.error(
"Health refresh failed",
error instanceof Error ? error.name : "UnknownError"
);
});
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
</CardFooter>
</Card>
</section>
</main>
);
}

View File

@ -0,0 +1,3 @@
export const coreQueryKeys = {
health: ["core", "health"],
} as const;

View File

@ -0,0 +1,8 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}

View File

@ -0,0 +1,44 @@
{
"name": "@metis/identity",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./common": "./src/common/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "node --import tsx --test src/api/domain/user.aggregate.test.ts src/api/infrastructure/persistence/user-persistence.mapper.test.ts src/api/infrastructure/security/scrypt-password-hasher.test.ts"
},
"dependencies": {
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"zod": "4.3.6",
"sequelize": "6.37.8",
"mariadb": "3.5.4",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"express-rate-limit": "8.7.0",
"@repo/rdx-ddd": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/express": "4.17.21",
"@types/express-session": "^1.18.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"react": "19.2.5",
"react-dom": "19.2.5"
},
"peerDependencies": {
"express": "4.22.1",
"express-session": "1.19.0",
"i18next": "26.0.4",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
}
}

View File

@ -0,0 +1,22 @@
import type { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { IdentityError } from "../domain/identity-error";
import type { User } from "../domain/user.aggregate";
export interface IUserRepository {
findById(id: UniqueID): Promise<Maybe<User>>;
findByEmail(email: EmailAddress): Promise<Maybe<User>>;
list(): Promise<readonly User[]>;
setupRequired(): Promise<boolean>;
bootstrap(user: User): Promise<Result<User, IdentityError>>;
create(user: User): Promise<Result<User, IdentityError>>;
update(
id: UniqueID,
change: (user: User) => Result<User, IdentityError>
): Promise<Result<User, IdentityError>>;
}
export interface IPasswordHasher {
hash(password: string): Promise<string>;
verify(password: string, hash: string): Promise<boolean>;
}

View File

@ -0,0 +1,124 @@
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type {
BootstrapAdminRequestDTO,
ChangePasswordRequestDTO,
CreateUserRequestDTO,
LoginRequestDTO,
ResetPasswordRequestDTO,
UpdateUserRequestDTO,
} from "../../common";
import { IdentityError } from "../domain/identity-error";
import type { User } from "../domain/user.aggregate";
import type { IPasswordHasher, IUserRepository } from "./identity-ports";
import type { UserInputMapper } from "./user-input.mapper";
/** Contexto interno obtenido de la sesión del servidor, nunca del body. */
export type IdentityContext = Readonly<{ userId: string; authVersion: number }>;
export class IdentityUseCases {
constructor(
private readonly users: IUserRepository,
private readonly passwords: IPasswordHasher,
private readonly inputs: UserInputMapper,
private readonly dummyPasswordHash: string
) {}
async session(context: IdentityContext | undefined): Promise<User | undefined> {
if (!context) {
return undefined;
}
const id = UniqueID.create(context.userId);
if (!id.isSuccess) {
return undefined;
}
const user = (await this.users.findById(id.data)).getOrUndefined();
return user?.active && user.authVersion === context.authVersion ? user : undefined;
}
async setupRequired() {
return this.users.setupRequired();
}
async bootstrap(dto: BootstrapAdminRequestDTO) {
if (!(await this.users.setupRequired())) {
return Result.fail(new IdentityError("SETUP_CLOSED"));
}
const user = await this.inputs.map(dto, true);
return user.isSuccess ? this.users.bootstrap(user.data) : user;
}
async login(dto: LoginRequestDTO): Promise<Result<User, IdentityError>> {
const email = EmailAddress.create(dto.email);
if (!email.isSuccess) {
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
}
const user = (await this.users.findByEmail(email.data)).getOrUndefined();
const valid = await this.passwords.verify(
dto.password,
user?.passwordHash.toPrimitive() ?? this.dummyPasswordHash
);
if (!(valid && user?.active)) {
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
}
return Result.ok(user);
}
private async administrator(context: IdentityContext) {
const actor = await this.session(context);
return actor?.isInitialAdmin ? Result.ok(actor) : Result.fail(new IdentityError("FORBIDDEN"));
}
async list(context: IdentityContext) {
const access = await this.administrator(context);
return access.isSuccess ? Result.ok(await this.users.list()) : access;
}
async create(dto: CreateUserRequestDTO, context: IdentityContext) {
const access = await this.administrator(context);
if (!access.isSuccess) {
return access;
}
const user = await this.inputs.map(dto, false);
return user.isSuccess ? this.users.create(user.data) : user;
}
async update(id: string, dto: UpdateUserRequestDTO, context: IdentityContext) {
const access = await this.administrator(context);
if (!access.isSuccess) {
return access;
}
const userId = UniqueID.create(id);
const profile = this.inputs.mapProfile(dto);
if (!(userId.isSuccess && profile.isSuccess)) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
return this.users.update(userId.data, (user) =>
user.update({ ...profile.data, active: dto.active })
);
}
async resetPassword(id: string, dto: ResetPasswordRequestDTO, context: IdentityContext) {
const access = await this.administrator(context);
if (!access.isSuccess) {
return access;
}
const userId = UniqueID.create(id);
if (!userId.isSuccess) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
if (userId.data.equals(access.data.id)) {
return Result.fail(new IdentityError("ADMIN_PROTECTED"));
}
const hash = await this.inputs.hashPassword(dto.password);
return this.users.update(userId.data, (user) => Result.ok(user.changePassword(hash)));
}
async changePassword(dto: ChangePasswordRequestDTO, context: IdentityContext) {
const actor = await this.session(context);
if (!actor) {
return Result.fail(new IdentityError("UNAUTHENTICATED"));
}
if (!(await this.passwords.verify(dto.currentPassword, actor.passwordHash.toPrimitive()))) {
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
}
const hash = await this.inputs.hashPassword(dto.password);
return this.users.update(actor.id, (user) => {
if (user.authVersion !== actor.authVersion) {
return Result.fail(new IdentityError("UNAUTHENTICATED"));
}
return Result.ok(user.changePassword(hash));
});
}
}

View File

@ -0,0 +1,44 @@
import { EmailAddress, Name } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { BootstrapAdminRequestDTO } from "../../common";
import { IdentityError } from "../domain/identity-error";
import { User } from "../domain/user.aggregate";
import { PasswordHash } from "../domain/value-objects/password-hash";
import type { IPasswordHasher } from "./identity-ports";
export class UserInputMapper {
constructor(private readonly passwords: IPasswordHasher) {}
mapProfile(dto: {
name: string;
email: string;
}): Result<{ name: Name; email: EmailAddress }, IdentityError> {
const name = Name.create(dto.name, 100);
const email = EmailAddress.create(dto.email);
if (!(name.isSuccess && email.isSuccess)) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
return Result.ok({ name: name.data, email: email.data });
}
async hashPassword(password: string): Promise<PasswordHash> {
const hash = PasswordHash.create(await this.passwords.hash(password));
if (!hash.isSuccess) {
throw new Error("El servicio de contraseñas devolvió un hash inválido.");
}
return hash.data;
}
async map(dto: BootstrapAdminRequestDTO, isInitialAdmin: boolean) {
const profile = this.mapProfile(dto);
if (!profile.isSuccess) {
return profile;
}
return User.create({
...profile.data,
passwordHash: await this.hashPassword(dto.password),
active: true,
isInitialAdmin,
authVersion: 1,
});
}
}

View File

@ -0,0 +1,12 @@
import type { UserDTO } from "../../common";
import type { User } from "../domain/user.aggregate";
export function buildUserResponse(user: User): UserDTO {
return {
id: user.id.toPrimitive(),
name: user.name.toPrimitive(),
email: user.email.toPrimitive(),
active: user.active,
isInitialAdmin: user.isInitialAdmin,
};
}

View File

@ -0,0 +1,14 @@
export type IdentityErrorCode =
| "INVALID_REQUEST"
| "INVALID_CREDENTIALS"
| "UNAUTHENTICATED"
| "FORBIDDEN"
| "SETUP_CLOSED"
| "EMAIL_EXISTS"
| "USER_NOT_FOUND"
| "ADMIN_PROTECTED";
export class IdentityError extends Error {
constructor(public readonly code: IdentityErrorCode) {
super(code);
}
}

View File

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
import { User } from "./user.aggregate";
import { PasswordHash } from "./value-objects/password-hash";
function fixture() {
const id = UniqueID.create("e0420eb9-2aab-4368-96a0-2ce83dd23eb4");
const name = Name.create("Admin");
const email = EmailAddress.create("admin@example.test");
const passwordHash = PasswordHash.create("initial-password-hash-for-test");
assert.ok(id.isSuccess && name.isSuccess && email.isSuccess && passwordHash.isSuccess);
return {
id: id.data,
props: {
name: name.data,
email: email.data,
passwordHash: passwordHash.data,
active: true,
isInitialAdmin: true,
authVersion: 1,
},
};
}
test("el administrador inicial no se puede crear ni actualizar inactivo", () => {
const { id, props } = fixture();
assert.equal(User.create({ ...props, active: false }).isSuccess, false);
const user = User.rehydrate(props, id);
assert.equal(
user.update({ name: props.name, email: props.email, active: false }).isSuccess,
false
);
assert.equal(user.active, true);
});
test("actualizar es idempotente, conserva identidad y cambiar contraseña invalida sesiones", () => {
const { id, props } = fixture();
const user = User.rehydrate({ ...props, isInitialAdmin: false }, id);
const unchanged = user.update({ name: props.name, email: props.email, active: true });
assert.ok(unchanged.isSuccess);
assert.equal(unchanged.data, user);
const disabled = user.update({ name: props.name, email: props.email, active: false });
assert.ok(disabled.isSuccess);
assert.equal(disabled.data.authVersion, 2);
assert.ok(disabled.data.id.equals(user.id));
const repeated = disabled.data.update({ name: props.name, email: props.email, active: false });
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, disabled.data);
const hash = PasswordHash.create("new-password-hash-for-test");
assert.ok(hash.isSuccess);
const changed = user.changePassword(hash.data);
assert.equal(changed.authVersion, 2);
assert.ok(changed.id.equals(user.id));
assert.equal(user.passwordHash.toPrimitive(), "initial-password-hash-for-test");
assert.equal(changed.changePassword(hash.data), changed);
});
test("crear genera UUID v7 y rehidratar conserva el UUID original", () => {
const { id, props } = fixture();
const created = User.create(props);
assert.ok(created.isSuccess);
assert.equal(created.data.id.toPrimitive().charAt(14), "7");
assert.equal(User.rehydrate(props, id).id.toPrimitive(), id.toPrimitive());
assert.equal(User.create({ ...props, authVersion: 0 }).isSuccess, false);
});

View File

@ -0,0 +1,84 @@
import type { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd";
import { AggregateRoot } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { IdentityError } from "./identity-error";
import type { PasswordHash } from "./value-objects/password-hash";
export type UserProps = Readonly<{
name: Name;
email: EmailAddress;
passwordHash: PasswordHash;
active: boolean;
isInitialAdmin: boolean;
authVersion: number;
}>;
export class User extends AggregateRoot<UserProps> {
static create(props: UserProps, id?: UniqueID): Result<User, IdentityError> {
if (
!Number.isSafeInteger(props.authVersion) ||
props.authVersion < 1 ||
props.name.toPrimitive().length > 100
) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
if (props.isInitialAdmin && !props.active) {
return Result.fail(new IdentityError("ADMIN_PROTECTED"));
}
return Result.ok(new User(props, id));
}
static rehydrate(props: UserProps, id: UniqueID): User {
return new User(props, id);
}
get name(): Name {
return this.props.name;
}
get email(): EmailAddress {
return this.props.email;
}
get passwordHash(): PasswordHash {
return this.props.passwordHash;
}
get active(): boolean {
return this.props.active;
}
get isInitialAdmin(): boolean {
return this.props.isInitialAdmin;
}
get authVersion(): number {
return this.props.authVersion;
}
update(
values: Readonly<{ name: Name; email: EmailAddress; active: boolean }>
): Result<User, IdentityError> {
if (values.name.toPrimitive().length > 100) {
return Result.fail(new IdentityError("INVALID_REQUEST"));
}
if (this.isInitialAdmin && !values.active) {
return Result.fail(new IdentityError("ADMIN_PROTECTED"));
}
if (
this.name.equals(values.name) &&
this.email.equals(values.email) &&
this.active === values.active
) {
return Result.ok(this);
}
return Result.ok(
new User(
{
...this.props,
...values,
authVersion: this.authVersion + (values.active === this.active ? 0 : 1),
},
this.id
)
);
}
changePassword(passwordHash: PasswordHash): User {
if (this.passwordHash.equals(passwordHash)) {
return this;
}
return new User({ ...this.props, passwordHash, authVersion: this.authVersion + 1 }, this.id);
}
}

View File

@ -0,0 +1,11 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export class PasswordHash extends ValueObject<string> {
static create(value: string): Result<PasswordHash, Error> {
if (value.length < 20 || value.length > 256 || /\s/.test(value)) {
return Result.fail(new Error("Hash de contraseña inválido."));
}
return Result.ok(new PasswordHash(value));
}
}

View File

@ -0,0 +1,251 @@
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import type { Application, Request, Response } from "express";
import { Router } from "express";
import { rateLimit } from "express-rate-limit";
import type { z } from "zod/v4";
import type { SessionResponseDTO } from "../../../common";
import {
BootstrapAdminRequestSchema,
ChangePasswordRequestSchema,
CreateUserRequestSchema,
LoginRequestSchema,
ResetPasswordRequestSchema,
UpdateUserRequestSchema,
UserParamsRequestSchema,
} from "../../../common";
import type { IdentityUseCases } from "../../application/identity.use-cases";
import { buildUserResponse } from "../../application/user-response.builder";
import type { IdentityError } from "../../domain/identity-error";
import type { AuthenticatedSession } from "./session-types";
function sendError(response: Response, code: string, status: number): void {
response
.status(status)
.json({ code, requestId: response.getHeader("x-request-id")?.toString() ?? randomUUID() });
}
function businessError(response: Response, error: IdentityError): void {
const statuses = {
INVALID_REQUEST: 400,
INVALID_CREDENTIALS: 401,
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
SETUP_CLOSED: 409,
EMAIL_EXISTS: 409,
USER_NOT_FOUND: 404,
ADMIN_PROTECTED: 409,
};
sendError(response, error.code, statuses[error.code]);
}
function getContext(request: Request): AuthenticatedSession | undefined {
const { userId, authVersion } = request.session;
return userId && authVersion !== undefined ? { userId, authVersion } : undefined;
}
function saveSession(request: Request): Promise<void> {
return new Promise((resolve, reject) =>
request.session.save((error) => (error ? reject(error) : resolve()))
);
}
async function signIn(request: Request, userId: string, authVersion: number): Promise<void> {
await new Promise<void>((resolve, reject) =>
request.session.regenerate((error) => (error ? reject(error) : resolve()))
);
request.session.userId = userId;
request.session.authVersion = authVersion;
request.session.csrfToken = randomBytes(32).toString("hex");
await saveSession(request);
}
export function registerIdentityRoutes(
app: Application,
useCases: IdentityUseCases,
origin: string
): void {
const router = Router();
router.get("/auth/session", (request, response, next) => {
const handle = async () => {
request.session.csrfToken ??= randomBytes(32).toString("hex");
const user = await useCases.session(getContext(request));
if (!user) {
request.session.userId = undefined;
request.session.authVersion = undefined;
}
const snapshot: SessionResponseDTO = {
user: user ? buildUserResponse(user) : null,
setupRequired: await useCases.setupRequired(),
csrfToken: request.session.csrfToken,
};
await saveSession(request);
response.json(snapshot);
};
handle().catch(next);
});
router.use((request, response, next) => {
if (request.method === "GET") {
next();
return;
}
const token = request.get("x-csrf-token");
const expected = request.session.csrfToken;
if (
request.get("origin") !== origin ||
!token ||
!expected ||
!/^[a-f0-9]{64}$/.test(token) ||
!timingSafeEqual(Buffer.from(token), Buffer.from(expected))
) {
sendError(response, "FORBIDDEN", 403);
return;
}
next();
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
skipSuccessfulRequests: true,
handler: (_request, response) => sendError(response, "TOO_MANY_REQUESTS", 429),
});
router.use(["/auth/login", "/auth/bootstrap"], authLimiter);
const registerBody = <T>(
path: string,
schema: z.ZodType<T>,
execute: (dto: T, request: Request, response: Response) => Promise<void>
) => {
router.post(path, (request, response, next) => {
const parsed = schema.safeParse(request.body);
if (!parsed.success) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
execute(parsed.data, request, response).catch(next);
});
};
registerBody("/auth/bootstrap", BootstrapAdminRequestSchema, async (dto, request, response) => {
const result = await useCases.bootstrap(dto);
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
await signIn(request, result.data.id.toPrimitive(), result.data.authVersion);
response.status(201).json({ user: buildUserResponse(result.data) });
});
registerBody("/auth/login", LoginRequestSchema, async (dto, request, response) => {
const result = await useCases.login(dto);
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
await signIn(request, result.data.id.toPrimitive(), result.data.authVersion);
response.json({ user: buildUserResponse(result.data) });
});
registerBody(
"/auth/logout",
BootstrapAdminRequestSchema.pick({}),
async (_dto, request, response) => {
await new Promise<void>((resolve, reject) =>
request.session.destroy((error) => (error ? reject(error) : resolve()))
);
response.clearCookie("metis.sid", { httpOnly: true, sameSite: "strict", path: "/" });
response.json({ success: true });
}
);
registerBody(
"/auth/change-password",
ChangePasswordRequestSchema,
async (dto, request, response) => {
const context = getContext(request);
if (!context) {
sendError(response, "UNAUTHENTICATED", 401);
return;
}
const result = await useCases.changePassword(dto, context);
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
await signIn(request, result.data.id.toPrimitive(), result.data.authVersion);
response.json({ user: buildUserResponse(result.data) });
}
);
router.use("/users", (request, response, next) => {
const handle = async () => {
const user = await useCases.session(getContext(request));
if (!user) {
sendError(response, "UNAUTHENTICATED", 401);
return;
}
if (!user.isInitialAdmin) {
sendError(response, "FORBIDDEN", 403);
return;
}
next();
};
handle().catch(next);
});
router.get("/users", (request, response, next) => {
const context = getContext(request);
if (!context) {
sendError(response, "UNAUTHENTICATED", 401);
return;
}
useCases
.list(context)
.then((result) => {
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
response.json({ users: result.data.map(buildUserResponse) });
})
.catch(next);
});
registerBody("/users", CreateUserRequestSchema, async (dto, request, response) => {
const context = getContext(request);
if (!context) {
sendError(response, "UNAUTHENTICATED", 401);
return;
}
const result = await useCases.create(dto, context);
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
response.status(201).json({ user: buildUserResponse(result.data) });
});
registerBody("/users/:id/update", UpdateUserRequestSchema, async (dto, request, response) => {
const context = getContext(request);
const params = UserParamsRequestSchema.safeParse(request.params);
if (!(context && params.success)) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
const result = await useCases.update(params.data.id, dto, context);
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
response.json({ user: buildUserResponse(result.data) });
});
registerBody(
"/users/:id/reset-password",
ResetPasswordRequestSchema,
async (dto, request, response) => {
const context = getContext(request);
const params = UserParamsRequestSchema.safeParse(request.params);
if (!(context && params.success)) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
const result = await useCases.resetPassword(params.data.id, dto, context);
if (!result.isSuccess) {
businessError(response, result.error);
return;
}
response.json({ user: buildUserResponse(result.data) });
}
);
app.use("/api", router);
}

Some files were not shown because too many files have changed in this diff Show More