feat(companies): implement update current company use case and associated controllers
- Add UpdateCurrentCompanyUseCase to handle company updates. - Create update-current-company.controller.ts to manage update requests. - Implement validation and mapping for update requests using Zod. - Add tests for the update use case and controller to ensure functionality. - Introduce error handling and feedback state management for company details settings. - Create utility functions for error normalization and feedback state resolution. - Implement tests for error handling and feedback state utilities. - Ensure nullable fields are handled correctly in DTOs and forms.
This commit is contained in:
parent
cf07de4836
commit
a4f82decc6
37
apps/server/.env.test
Normal file
37
apps/server/.env.test
Normal file
@ -0,0 +1,37 @@
|
||||
NODE_ENV=test
|
||||
PORT=3002
|
||||
API_BASE_PATH=/api/v1
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
TRUST_PROXY=0
|
||||
TIMEZONE=Europe/Madrid
|
||||
|
||||
DB_DIALECT=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=uecko_erp_http_test
|
||||
DB_USER=rodax
|
||||
DB_PASS=rodax
|
||||
|
||||
DB_LOGGING=false
|
||||
DB_SSL=false
|
||||
DB_SYNC_MODE=force
|
||||
|
||||
WARMUP_TIMEOUT_MS=10000
|
||||
WARMUP_STRICT=false
|
||||
|
||||
JWT_SECRET=supersecretkeysupersecretkeysupersecretkey
|
||||
JWT_ACCESS_EXPIRATION=1h
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
|
||||
TEMPLATES_PATH=/home/rodax/Documentos/uecko-erp/modules/
|
||||
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
|
||||
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
|
||||
|
||||
FASTREPORT_BIN=/home/rodax/Documentos/uecko-erp/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator
|
||||
|
||||
SIGNING_SERVICE_URL=http://127.0.0.1:8000/documents/sign
|
||||
SIGNING_SERVICE_METHOD=POST
|
||||
SIGNING_SERVICE_TIMEOUT_MS=15000
|
||||
SIGNING_SERVICE_MAX_RETRIES=2
|
||||
|
||||
COMPANY_CERTIFICATES_PATH=/home/rodax/Documentos/uecko-erp/apps/server/src/config/company-certificates.json
|
||||
37
apps/server/.env.test.example
Normal file
37
apps/server/.env.test.example
Normal file
@ -0,0 +1,37 @@
|
||||
NODE_ENV=test
|
||||
PORT=3002
|
||||
API_BASE_PATH=/api/v1
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
TRUST_PROXY=0
|
||||
TIMEZONE=Europe/Madrid
|
||||
|
||||
DB_DIALECT=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=uecko_erp_http_test
|
||||
DB_USER=rodax
|
||||
DB_PASS=rodax
|
||||
|
||||
DB_LOGGING=false
|
||||
DB_SSL=false
|
||||
DB_SYNC_MODE=force
|
||||
|
||||
WARMUP_TIMEOUT_MS=10000
|
||||
WARMUP_STRICT=false
|
||||
|
||||
JWT_SECRET=supersecretkeysupersecretkeysupersecretkey
|
||||
JWT_ACCESS_EXPIRATION=1h
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
|
||||
TEMPLATES_PATH=/home/rodax/Documentos/uecko-erp/modules/
|
||||
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
|
||||
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
|
||||
|
||||
FASTREPORT_BIN=/home/rodax/Documentos/uecko-erp/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator
|
||||
|
||||
SIGNING_SERVICE_URL=http://127.0.0.1:8000/documents/sign
|
||||
SIGNING_SERVICE_METHOD=POST
|
||||
SIGNING_SERVICE_TIMEOUT_MS=15000
|
||||
SIGNING_SERVICE_MAX_RETRIES=2
|
||||
|
||||
COMPANY_CERTIFICATES_PATH=/home/rodax/Documentos/uecko-erp/apps/server/src/config/company-certificates.json
|
||||
@ -7,6 +7,8 @@
|
||||
"start": "NODE_ENV=production node --env-file=.env.production dist/index.js",
|
||||
"dev": "node --import=tsx --watch src/index.ts",
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"test": "pnpm run test:http",
|
||||
"test:http": "find ./src/testing/http -name '*.test.ts' -print0 | xargs -0 node --import tsx --test",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"check": "biome check .",
|
||||
"lint": "biome lint .",
|
||||
@ -76,4 +78,4 @@
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,3 +152,13 @@ export const Config = (): ConfigType => {
|
||||
}
|
||||
return _config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset explícito para tests de integración.
|
||||
*
|
||||
* Evita que varias suites reutilicen configuración cacheada cuando cargan
|
||||
* `.env.test` con overrides distintos o reinician la base de datos.
|
||||
*/
|
||||
export const resetConfigForTests = (): void => {
|
||||
_config = null;
|
||||
};
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./model-loader";
|
||||
export * from "./module-loader";
|
||||
export * from "./service-registry";
|
||||
|
||||
152
apps/server/src/lib/modules/module-loader.test.ts
Normal file
152
apps/server/src/lib/modules/module-loader.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { IModuleServer, ModuleParams } from "@erp/core/api";
|
||||
|
||||
import { resetModelsRegistry } from "./model-loader";
|
||||
import { initModules, registerModule, resetModuleRegistry } from "./module-loader";
|
||||
import { listServices, resetServiceRegistry } from "./service-registry";
|
||||
|
||||
type FakeDatabase = {
|
||||
sync: () => Promise<void>;
|
||||
models: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function buildModuleParams(): ModuleParams & {
|
||||
config: { database: { syncMode: string } };
|
||||
database: FakeDatabase;
|
||||
logger: Console;
|
||||
} {
|
||||
return {
|
||||
getService: () => {
|
||||
throw new Error("Base params getService should not be called directly in tests.");
|
||||
},
|
||||
config: {
|
||||
database: {
|
||||
syncMode: "none",
|
||||
},
|
||||
},
|
||||
database: {
|
||||
sync: async () => undefined,
|
||||
models: {},
|
||||
},
|
||||
logger: console,
|
||||
};
|
||||
}
|
||||
|
||||
function resetModuleTestState(): void {
|
||||
resetServiceRegistry();
|
||||
resetModuleRegistry();
|
||||
resetModelsRegistry();
|
||||
}
|
||||
|
||||
test("module-loader permite dependencias externas declaradas", async (t) => {
|
||||
resetModuleTestState();
|
||||
t.after(resetModuleTestState);
|
||||
|
||||
let receivedService = "";
|
||||
|
||||
const catalogsModule: IModuleServer = {
|
||||
name: "catalogs",
|
||||
version: "1.0.0",
|
||||
setup: () => ({
|
||||
services: {
|
||||
general: {
|
||||
name: "catalogs-service",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const companiesModule: IModuleServer = {
|
||||
name: "companies",
|
||||
version: "1.0.0",
|
||||
dependencies: ["catalogs"],
|
||||
start: ({ getService }) => {
|
||||
const service = getService<{ name: string }>("catalogs:general");
|
||||
receivedService = service.name;
|
||||
},
|
||||
};
|
||||
|
||||
registerModule(catalogsModule);
|
||||
registerModule(companiesModule);
|
||||
|
||||
await initModules(buildModuleParams());
|
||||
|
||||
assert.equal(receivedService, "catalogs-service");
|
||||
assert.deepEqual(listServices().sort(), ["catalogs:general"]);
|
||||
});
|
||||
|
||||
test("module-loader falla cuando una dependencia usada no está declarada", async (t) => {
|
||||
resetModuleTestState();
|
||||
t.after(resetModuleTestState);
|
||||
|
||||
registerModule({
|
||||
name: "catalogs",
|
||||
version: "1.0.0",
|
||||
setup: () => ({
|
||||
services: {
|
||||
general: {
|
||||
name: "catalogs-service",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
registerModule({
|
||||
name: "companies",
|
||||
version: "1.0.0",
|
||||
start: ({ getService }) => {
|
||||
getService("catalogs:general");
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => initModules(buildModuleParams()),
|
||||
/without declaring dependency on "catalogs"/i
|
||||
);
|
||||
});
|
||||
|
||||
test("module-loader sigue detectando ciclos reales entre módulos", async (t) => {
|
||||
resetModuleTestState();
|
||||
t.after(resetModuleTestState);
|
||||
|
||||
registerModule({
|
||||
name: "companies",
|
||||
version: "1.0.0",
|
||||
dependencies: ["catalogs"],
|
||||
});
|
||||
|
||||
registerModule({
|
||||
name: "catalogs",
|
||||
version: "1.0.0",
|
||||
dependencies: ["companies"],
|
||||
});
|
||||
|
||||
await assert.rejects(() => initModules(buildModuleParams()), /cyclic dependency detected/i);
|
||||
});
|
||||
|
||||
test("module-loader rechaza la autoreferencia explícita por nombre de módulo", async (t) => {
|
||||
resetModuleTestState();
|
||||
t.after(resetModuleTestState);
|
||||
|
||||
registerModule({
|
||||
name: "companies",
|
||||
version: "1.0.0",
|
||||
setup: () => ({
|
||||
services: {
|
||||
general: {
|
||||
name: "companies-service",
|
||||
},
|
||||
},
|
||||
}),
|
||||
start: ({ getService }) => {
|
||||
getService("companies:general");
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => initModules(buildModuleParams()),
|
||||
/without declaring dependency on "companies"/i
|
||||
);
|
||||
});
|
||||
@ -1,4 +1,11 @@
|
||||
import type { IModuleServer, ModuleParams } from "@erp/core/api";
|
||||
import type {
|
||||
IModuleServer,
|
||||
ModuleParams,
|
||||
ModuleSetupResult,
|
||||
SetupParams,
|
||||
StartParams,
|
||||
WarmupParams,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import { logger } from "../logger";
|
||||
|
||||
@ -37,6 +44,21 @@ export function registerModule(pkg: IModuleServer) {
|
||||
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset del estado global del loader para entornos de test.
|
||||
*
|
||||
* En producción el proceso bootstrappea una sola vez. En tests HTTP necesitamos
|
||||
* poder recrear app + módulos + servicios varias veces dentro del mismo proceso.
|
||||
*/
|
||||
export function resetModuleRegistry(): void {
|
||||
registeredModules.clear();
|
||||
setupDone.clear();
|
||||
visiting.clear();
|
||||
setupOrder.length = 0;
|
||||
internalByModule.clear();
|
||||
usedDependenciesByModule.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
- Inicializa todos los módulos registrados (resolviendo dependencias),
|
||||
- luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
|
||||
@ -59,15 +81,15 @@ export async function initModules(params: ModuleParams) {
|
||||
// - start() ≈ init() en el contrato nuevo
|
||||
// - setup() ≈ registerDependencies() en el contrato nuevo
|
||||
await withPhase(name, "start", async () => {
|
||||
const startParams: StartParams = {
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
};
|
||||
|
||||
// Aquí YA se permite getService/getInternal
|
||||
await Promise.resolve(
|
||||
pkg.start?.({
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices, // opcional: útil en debugging
|
||||
} as any)
|
||||
);
|
||||
await Promise.resolve(pkg.start?.(startParams));
|
||||
});
|
||||
}
|
||||
|
||||
@ -105,36 +127,40 @@ async function setupModule(name: string, params: ModuleParams, stack: string[])
|
||||
|
||||
// 2) setup phase (contrato nuevo) = registerDependencies (en tu código actual)
|
||||
const pkgApi = await withPhase(name, "setup", async () => {
|
||||
return await Promise.resolve(
|
||||
pkg.setup?.({
|
||||
...params,
|
||||
// En setup se permite getService para wiring.
|
||||
getService: makeGetService(name, pkg),
|
||||
listServices,
|
||||
} as any)
|
||||
);
|
||||
const setupParams: SetupParams = {
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
listServices,
|
||||
};
|
||||
|
||||
return await Promise.resolve(pkg.setup?.(setupParams));
|
||||
});
|
||||
const moduleSetupResult: ModuleSetupResult | undefined = pkgApi;
|
||||
|
||||
// 3) internal store
|
||||
if (pkgApi?.internal) {
|
||||
internalByModule.set(name, pkgApi.internal as Record<string, unknown>);
|
||||
if (moduleSetupResult?.internal) {
|
||||
internalByModule.set(name, moduleSetupResult.internal);
|
||||
} else {
|
||||
internalByModule.set(name, {});
|
||||
}
|
||||
|
||||
// 4) models
|
||||
if (pkgApi?.models) {
|
||||
if (moduleSetupResult?.models) {
|
||||
const models = moduleSetupResult.models;
|
||||
|
||||
await withPhase(name, "registerModels", async () => {
|
||||
await Promise.resolve(registerModels(pkgApi.models!, params, { moduleName: name }));
|
||||
await Promise.resolve(registerModels(models, params, { moduleName: name }));
|
||||
});
|
||||
}
|
||||
|
||||
// 5) services (namespaced)
|
||||
if (pkgApi?.services) {
|
||||
await withPhase(name, "registerServices", async () => {
|
||||
validateModuleServices(name, pkgApi.services!);
|
||||
if (moduleSetupResult?.services) {
|
||||
const services = moduleSetupResult.services;
|
||||
|
||||
for (const [serviceKey, serviceApi] of Object.entries(pkgApi.services!)) {
|
||||
await withPhase(name, "registerServices", async () => {
|
||||
validateModuleServices(name, services);
|
||||
|
||||
for (const [serviceKey, serviceApi] of Object.entries(services)) {
|
||||
const fullName = buildServiceName(name, serviceKey);
|
||||
registerService(fullName, serviceApi);
|
||||
}
|
||||
@ -249,24 +275,19 @@ async function warmupModules(params: ModuleParams) {
|
||||
const pkg = registeredModules.get(name);
|
||||
if (!pkg?.warmup) continue;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <Para que funione>
|
||||
const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise<void> | void);
|
||||
const maybeWarmup = pkg.warmup;
|
||||
if (typeof maybeWarmup !== "function") continue;
|
||||
|
||||
try {
|
||||
const warmupParams: WarmupParams = {
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
};
|
||||
|
||||
await withPhase(name, "warmup", () =>
|
||||
withTimeout(
|
||||
Promise.resolve(
|
||||
maybeWarmup({
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
})
|
||||
),
|
||||
WARMUP_TIMEOUT_MS,
|
||||
`${name}.warmup`
|
||||
)
|
||||
withTimeout(Promise.resolve(maybeWarmup(warmupParams)), WARMUP_TIMEOUT_MS, `${name}.warmup`)
|
||||
);
|
||||
} catch (error) {
|
||||
if (WARMUP_STRICT) {
|
||||
|
||||
@ -67,3 +67,15 @@ export function hasService(name: string): boolean {
|
||||
export function listServices(): string[] {
|
||||
return Object.keys(services);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el registry entre arranques de test.
|
||||
*
|
||||
* El runtime productivo arranca una sola vez, pero los tests HTTP necesitan
|
||||
* reinicializar módulos y servicios sin contaminar el siguiente caso.
|
||||
*/
|
||||
export function resetServiceRegistry(): void {
|
||||
for (const name of Object.keys(services)) {
|
||||
delete services[name];
|
||||
}
|
||||
}
|
||||
|
||||
468
apps/server/src/testing/http/companies-current.http.test.ts
Normal file
468
apps/server/src/testing/http/companies-current.http.test.ts
Normal file
@ -0,0 +1,468 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCompanyHeaders,
|
||||
createTestAccount,
|
||||
createTestCompany,
|
||||
createTestMembership,
|
||||
createTestPaymentTerm,
|
||||
findCompanyRow,
|
||||
loginByApi,
|
||||
} from "./http-test-fixtures.ts";
|
||||
import { createHttpTestRuntime } from "./http-test-runtime.ts";
|
||||
|
||||
type ProblemDetails = {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
status?: number;
|
||||
errors?: Array<{ field?: string; message?: string }>;
|
||||
};
|
||||
|
||||
type LoginResponseBody = {
|
||||
access_token: string;
|
||||
};
|
||||
|
||||
type CompanyResponseBody = {
|
||||
id: string;
|
||||
legal_name: string;
|
||||
trade_name: string | null;
|
||||
tin: string | null;
|
||||
slug: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
default_payment_term_id: string | null;
|
||||
status: "active" | "disabled";
|
||||
};
|
||||
|
||||
test("GET /api/v1/companies/current devuelve la company activa del miembro autenticado", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createTestAccount(runtime.database, {
|
||||
email: `account-${randomUUID()}@example.com`,
|
||||
password: "Secret123!",
|
||||
});
|
||||
const company = await createTestCompany(runtime.database, {
|
||||
legalName: "Acme Legal SL",
|
||||
tradeName: "Acme Trade",
|
||||
tin: "B12345678",
|
||||
email: "billing@acme.test",
|
||||
phone: "+34910000000",
|
||||
website: "https://acme.test",
|
||||
});
|
||||
|
||||
await createTestMembership(runtime.database, {
|
||||
accountId: account.id,
|
||||
companyId: company.id,
|
||||
});
|
||||
|
||||
const loginResponse = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
});
|
||||
|
||||
assert.equal(loginResponse.status, 200);
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
headers: buildCompanyHeaders({
|
||||
accessToken: getLoginBody(loginResponse.body).access_token,
|
||||
companyId: company.id,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body, {
|
||||
id: company.id,
|
||||
legal_name: "Acme Legal SL",
|
||||
trade_name: "Acme Trade",
|
||||
tin: "B12345678",
|
||||
slug: company.slug,
|
||||
email: "billing@acme.test",
|
||||
phone: "+34910000000",
|
||||
website: "https://acme.test",
|
||||
default_payment_term_id: null,
|
||||
status: "active",
|
||||
} satisfies CompanyResponseBody);
|
||||
});
|
||||
|
||||
test("GET /api/v1/companies/current devuelve 401 sin autenticación", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
headers: buildCompanyHeaders({
|
||||
companyId: randomUUID(),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(getProblemDetails(response.body).detail, "Unauthorized");
|
||||
});
|
||||
|
||||
test("GET /api/v1/companies/current devuelve 403 cuando falta X-Company-Id", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime);
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
headers: {
|
||||
authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(getProblemDetails(response.body).detail, "Company context required");
|
||||
});
|
||||
|
||||
test("GET /api/v1/companies/current devuelve 403 cuando la membership no está activa", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime, {
|
||||
membershipStatus: "disabled",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
headers: buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(getProblemDetails(response.body).detail, "Company context required");
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/companies/current actualiza un subconjunto de campos e ignora claves ajenas al schema", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime, {
|
||||
company: {
|
||||
legalName: "Original Legal Name",
|
||||
tradeName: "Original Trade Name",
|
||||
tin: "B12345678",
|
||||
email: "old@example.com",
|
||||
phone: "+34911111111",
|
||||
website: "https://old.example.com",
|
||||
slug: "original-slug",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legal_name: "Updated Legal Name",
|
||||
email: "new@example.com",
|
||||
unexpected_flag: true,
|
||||
slug: "should-not-change",
|
||||
status: "disabled",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.body, {
|
||||
id: session.company.id,
|
||||
legal_name: "Updated Legal Name",
|
||||
trade_name: "Original Trade Name",
|
||||
tin: "B12345678",
|
||||
slug: "original-slug",
|
||||
email: "new@example.com",
|
||||
phone: "+34911111111",
|
||||
website: "https://old.example.com",
|
||||
default_payment_term_id: null,
|
||||
status: "active",
|
||||
} satisfies CompanyResponseBody);
|
||||
|
||||
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
|
||||
|
||||
assert.ok(persistedCompany);
|
||||
assert.equal(persistedCompany.legal_name, "Updated Legal Name");
|
||||
assert.equal(persistedCompany.email, "new@example.com");
|
||||
assert.equal(persistedCompany.trade_name, "Original Trade Name");
|
||||
assert.equal(persistedCompany.slug, "original-slug");
|
||||
assert.equal(persistedCompany.status, "active");
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/companies/current permite limpiar trade_name con null explícito", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime, {
|
||||
company: {
|
||||
legalName: "Acme Legal SL",
|
||||
tradeName: "Acme Trade",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
trade_name: null,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(getCompanyBody(response.body).trade_name, null);
|
||||
|
||||
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
|
||||
|
||||
assert.ok(persistedCompany);
|
||||
assert.equal(persistedCompany.trade_name, null);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/companies/current asigna y desasigna default_payment_term_id dentro de la misma company", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime);
|
||||
const paymentTerm = await createTestPaymentTerm(runtime.database, {
|
||||
companyId: session.company.id,
|
||||
name: "30 días",
|
||||
});
|
||||
|
||||
const assignResponse = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
default_payment_term_id: paymentTerm.id,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(assignResponse.status, 200);
|
||||
assert.equal(getCompanyBody(assignResponse.body).default_payment_term_id, paymentTerm.id);
|
||||
|
||||
const unassignResponse = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
default_payment_term_id: null,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(unassignResponse.status, 200);
|
||||
assert.equal(getCompanyBody(unassignResponse.body).default_payment_term_id, null);
|
||||
|
||||
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
|
||||
|
||||
assert.ok(persistedCompany);
|
||||
assert.equal(persistedCompany.default_payment_term_id, null);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/companies/current devuelve 422 cuando default_payment_term_id pertenece a otra company", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime, {
|
||||
company: {
|
||||
legalName: "Original Legal Name",
|
||||
},
|
||||
});
|
||||
const otherCompany = await createTestCompany(runtime.database, {
|
||||
legalName: "Other Company SL",
|
||||
});
|
||||
const otherCompanyPaymentTerm = await createTestPaymentTerm(runtime.database, {
|
||||
companyId: otherCompany.id,
|
||||
name: "60 días",
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legal_name: "Should Not Persist",
|
||||
default_payment_term_id: otherCompanyPaymentTerm.id,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(
|
||||
getProblemDetails(response.body).detail,
|
||||
"Default payment term is not assignable to the active company."
|
||||
);
|
||||
|
||||
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
|
||||
|
||||
assert.ok(persistedCompany);
|
||||
assert.equal(persistedCompany.legal_name, "Original Legal Name");
|
||||
assert.equal(persistedCompany.default_payment_term_id, null);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/companies/current devuelve 422 cuando default_payment_term_id está inactivo", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime, {
|
||||
company: {
|
||||
legalName: "Original Legal Name",
|
||||
},
|
||||
});
|
||||
const inactivePaymentTerm = await createTestPaymentTerm(runtime.database, {
|
||||
companyId: session.company.id,
|
||||
name: "90 días",
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legal_name: "Should Not Persist",
|
||||
default_payment_term_id: inactivePaymentTerm.id,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
assert.equal(
|
||||
getProblemDetails(response.body).detail,
|
||||
"Default payment term is not assignable to the active company."
|
||||
);
|
||||
|
||||
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
|
||||
|
||||
assert.ok(persistedCompany);
|
||||
assert.equal(persistedCompany.legal_name, "Original Legal Name");
|
||||
assert.equal(persistedCompany.default_payment_term_id, null);
|
||||
});
|
||||
|
||||
test("PATCH /api/v1/companies/current devuelve 422 cuando el email es inválido", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const session = await createAuthenticatedSession(runtime);
|
||||
|
||||
const response = await runtime.request("/api/v1/companies/current", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...buildCompanyHeaders({
|
||||
accessToken: session.accessToken,
|
||||
companyId: session.company.id,
|
||||
}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "not-an-email",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 422);
|
||||
|
||||
const body = getProblemDetails(response.body);
|
||||
|
||||
assert.equal(body.title, "Validation Failed");
|
||||
assert.ok(Array.isArray(body.errors));
|
||||
assert.ok(body.errors.some((error) => error.field === "email"));
|
||||
});
|
||||
|
||||
async function createAuthenticatedSession(
|
||||
runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>,
|
||||
options?: {
|
||||
membershipStatus?: "active" | "disabled" | "invited";
|
||||
company?: {
|
||||
legalName?: string;
|
||||
tradeName?: string | null;
|
||||
tin?: string | null;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
website?: string | null;
|
||||
status?: "active" | "disabled";
|
||||
};
|
||||
}
|
||||
) {
|
||||
const account = await createTestAccount(runtime.database, {
|
||||
email: `account-${randomUUID()}@example.com`,
|
||||
password: "Secret123!",
|
||||
});
|
||||
const company = await createTestCompany(runtime.database, {
|
||||
legalName: options?.company?.legalName ?? "Acme Legal SL",
|
||||
tradeName: options?.company?.tradeName,
|
||||
tin: options?.company?.tin ?? null,
|
||||
slug: options?.company?.slug,
|
||||
email: options?.company?.email ?? null,
|
||||
phone: options?.company?.phone ?? null,
|
||||
website: options?.company?.website ?? null,
|
||||
status: options?.company?.status ?? "active",
|
||||
});
|
||||
|
||||
await createTestMembership(runtime.database, {
|
||||
accountId: account.id,
|
||||
companyId: company.id,
|
||||
status: options?.membershipStatus ?? "active",
|
||||
});
|
||||
|
||||
const loginResponse = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
});
|
||||
|
||||
assert.equal(loginResponse.status, 200);
|
||||
|
||||
return {
|
||||
account,
|
||||
company,
|
||||
accessToken: getLoginBody(loginResponse.body).access_token,
|
||||
};
|
||||
}
|
||||
|
||||
function getProblemDetails(body: unknown): ProblemDetails {
|
||||
assert.equal(typeof body, "object");
|
||||
assert.notEqual(body, null);
|
||||
return body as ProblemDetails;
|
||||
}
|
||||
|
||||
function getLoginBody(body: unknown): LoginResponseBody {
|
||||
assert.equal(typeof body, "object");
|
||||
assert.notEqual(body, null);
|
||||
return body as LoginResponseBody;
|
||||
}
|
||||
|
||||
function getCompanyBody(body: unknown): CompanyResponseBody {
|
||||
assert.equal(typeof body, "object");
|
||||
assert.notEqual(body, null);
|
||||
return body as CompanyResponseBody;
|
||||
}
|
||||
233
apps/server/src/testing/http/http-test-fixtures.ts
Normal file
233
apps/server/src/testing/http/http-test-fixtures.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import bcrypt from "bcrypt";
|
||||
import type { QueryInterface, Sequelize } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
type CreateAccountParams = {
|
||||
id?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
status?: "active" | "disabled";
|
||||
languageCode?: string;
|
||||
};
|
||||
|
||||
type CreateCompanyParams = {
|
||||
id?: string;
|
||||
legalName: string;
|
||||
tradeName?: string | null;
|
||||
tin?: string | null;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
website?: string | null;
|
||||
defaultPaymentTermId?: string | null;
|
||||
status?: "active" | "disabled";
|
||||
};
|
||||
|
||||
type CreateMembershipParams = {
|
||||
id?: string;
|
||||
accountId: string;
|
||||
companyId: string;
|
||||
status?: "active" | "disabled" | "invited";
|
||||
};
|
||||
|
||||
type CreatePaymentTermParams = {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isActive?: boolean;
|
||||
isSystem?: boolean;
|
||||
dueRules?: Array<{
|
||||
dueDays: number;
|
||||
percentageValue: number;
|
||||
percentageScale?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function createTestAccount(database: Sequelize, params: CreateAccountParams) {
|
||||
const queryInterface = database.getQueryInterface();
|
||||
const now = new Date();
|
||||
const id = params.id ?? randomUUID();
|
||||
const passwordHash = await bcrypt.hash(params.password, 10);
|
||||
|
||||
await queryInterface.bulkInsert("identity_accounts", [
|
||||
{
|
||||
id,
|
||||
email: params.email,
|
||||
password_hash: passwordHash,
|
||||
name: params.name ?? "Test Account",
|
||||
avatar_url: null,
|
||||
language_code: params.languageCode ?? "es",
|
||||
status: params.status ?? "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
id,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTestCompany(database: Sequelize, params: CreateCompanyParams) {
|
||||
const queryInterface = database.getQueryInterface();
|
||||
const now = new Date();
|
||||
const id = params.id ?? randomUUID();
|
||||
|
||||
await queryInterface.bulkInsert("companies", [
|
||||
{
|
||||
id,
|
||||
legal_name: params.legalName,
|
||||
trade_name: params.tradeName ?? null,
|
||||
tin: params.tin ?? null,
|
||||
slug: params.slug ?? `company-${id.slice(0, 8)}`,
|
||||
email: params.email ?? null,
|
||||
phone: params.phone ?? null,
|
||||
website: params.website ?? null,
|
||||
default_payment_term_id: params.defaultPaymentTermId ?? null,
|
||||
status: params.status ?? "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
id,
|
||||
slug: params.slug ?? `company-${id.slice(0, 8)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTestMembership(database: Sequelize, params: CreateMembershipParams) {
|
||||
const queryInterface = database.getQueryInterface();
|
||||
const now = new Date();
|
||||
const id = params.id ?? randomUUID();
|
||||
|
||||
await queryInterface.bulkInsert("identity_company_memberships", [
|
||||
{
|
||||
id,
|
||||
account_id: params.accountId,
|
||||
company_id: params.companyId,
|
||||
status: params.status ?? "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTestPaymentTerm(database: Sequelize, params: CreatePaymentTermParams) {
|
||||
const queryInterface = database.getQueryInterface();
|
||||
const now = new Date();
|
||||
const id = params.id ?? randomUUID();
|
||||
const dueRules = params.dueRules ?? [
|
||||
{
|
||||
dueDays: 30,
|
||||
percentageValue: 10000,
|
||||
percentageScale: 2,
|
||||
},
|
||||
];
|
||||
|
||||
await queryInterface.bulkInsert("payment_terms", [
|
||||
{
|
||||
id,
|
||||
company_id: params.companyId,
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
is_active: params.isActive ?? true,
|
||||
is_system: params.isSystem ?? false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await queryInterface.bulkInsert(
|
||||
"payment_term_due_rules",
|
||||
dueRules.map((rule) => ({
|
||||
due_id: randomUUID(),
|
||||
payment_term_id: id,
|
||||
due_days: rule.dueDays,
|
||||
percentage_value: rule.percentageValue,
|
||||
percentage_scale: rule.percentageScale ?? 2,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findCompanyRow(database: Sequelize, companyId: string) {
|
||||
const rows = await database.query<{
|
||||
id: string;
|
||||
legal_name: string;
|
||||
trade_name: string | null;
|
||||
tin: string | null;
|
||||
slug: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
default_payment_term_id: string | null;
|
||||
status: string;
|
||||
}>("SELECT * FROM companies WHERE id = :companyId", {
|
||||
replacements: { companyId },
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function loginByApi(params: {
|
||||
request: (
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
) => Promise<{
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: unknown;
|
||||
}>;
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
return params.request("/api/v1/identity/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCompanyHeaders(params: {
|
||||
accessToken?: string;
|
||||
companyId?: string;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (params.accessToken) {
|
||||
headers.authorization = `Bearer ${params.accessToken}`;
|
||||
}
|
||||
|
||||
if (params.companyId) {
|
||||
headers["x-company-id"] = params.companyId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function getQueryInterface(database: Sequelize): QueryInterface {
|
||||
return database.getQueryInterface();
|
||||
}
|
||||
222
apps/server/src/testing/http/http-test-runtime.ts
Normal file
222
apps/server/src/testing/http/http-test-runtime.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type Server, createServer } from "node:http";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import mysql from "mysql2/promise";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createApp } from "@/app.ts";
|
||||
import { closeDatabase, tryConnectToDatabase } from "@/config/database.ts";
|
||||
import type { ConfigType } from "@/config/index.ts";
|
||||
import { initConfig, resetConfigForTests } from "@/config/index.ts";
|
||||
import { registerHealthRoutes } from "@/health.ts";
|
||||
import { logger } from "@/lib/logger.ts";
|
||||
import {
|
||||
initModules,
|
||||
resetModelsRegistry,
|
||||
resetModuleRegistry,
|
||||
resetServiceRegistry,
|
||||
} from "@/lib/modules";
|
||||
import { registerModules } from "@/register-modules.ts";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_ENV_FILE = resolve(currentDir, "../../../.env.test");
|
||||
const FORBIDDEN_DATABASE_NAMES = new Set(["uecko_erp", "uecko_erp_sync", "rodax_db"]);
|
||||
|
||||
export interface IHttpTestRuntime {
|
||||
readonly baseUrl: string;
|
||||
readonly config: ConfigType;
|
||||
readonly database: Sequelize;
|
||||
request(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<{
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: unknown;
|
||||
}>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un runtime HTTP real contra MariaDB de test.
|
||||
*
|
||||
* La infraestructura reutiliza:
|
||||
* - Express real
|
||||
* - middlewares reales
|
||||
* - módulos reales
|
||||
* - Sequelize real
|
||||
* - login real
|
||||
*/
|
||||
export async function createHttpTestRuntime(): Promise<IHttpTestRuntime> {
|
||||
loadTestEnvironment();
|
||||
assignUniqueTestDatabaseName();
|
||||
resetServerRuntimeForTests();
|
||||
z.config(z.locales.es());
|
||||
|
||||
const config = initConfig(logger);
|
||||
assertSafeTestConfig(config);
|
||||
|
||||
await recreateTestDatabase(config);
|
||||
|
||||
const database = await tryConnectToDatabase(config);
|
||||
const app = createApp(config);
|
||||
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: true }));
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
try {
|
||||
registerModules();
|
||||
await initModules({
|
||||
app,
|
||||
config,
|
||||
database,
|
||||
logger,
|
||||
});
|
||||
|
||||
server = createServer(app);
|
||||
await listen(server);
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("HTTP test server did not expose an ephemeral TCP port.");
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
config,
|
||||
database,
|
||||
request: async (path: string, init?: RequestInit) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, init);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const body = contentType.includes("json") ? await response.json() : await response.text();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body,
|
||||
};
|
||||
},
|
||||
stop: async () => {
|
||||
if (server) {
|
||||
await closeServer(server);
|
||||
}
|
||||
|
||||
await closeDatabase(database);
|
||||
resetServerRuntimeForTests();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (server) {
|
||||
await closeServer(server);
|
||||
}
|
||||
|
||||
await closeDatabase(database);
|
||||
resetServerRuntimeForTests();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function loadTestEnvironment(): void {
|
||||
const result = dotenv.config({
|
||||
path: TEST_ENV_FILE,
|
||||
override: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Unable to load test environment file: ${TEST_ENV_FILE}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resetServerRuntimeForTests(): void {
|
||||
resetConfigForTests();
|
||||
resetServiceRegistry();
|
||||
resetModuleRegistry();
|
||||
resetModelsRegistry();
|
||||
}
|
||||
|
||||
function assignUniqueTestDatabaseName(): void {
|
||||
const configuredName = process.env.DB_NAME?.trim();
|
||||
|
||||
if (!configuredName) {
|
||||
throw new Error("HTTP integration tests require DB_NAME in .env.test.");
|
||||
}
|
||||
|
||||
process.env.DB_NAME = `${configuredName}_${randomUUID().replaceAll("-", "")}`;
|
||||
}
|
||||
|
||||
function assertSafeTestConfig(config: ConfigType): void {
|
||||
if (!config.flags.isTest) {
|
||||
throw new Error("HTTP integration tests require NODE_ENV=test.");
|
||||
}
|
||||
|
||||
const databaseName = config.database.name.trim().toLowerCase();
|
||||
|
||||
if (!databaseName.includes("test")) {
|
||||
throw new Error(
|
||||
`Refusing to run HTTP integration tests against DB "${config.database.name}" because it does not look like a test database.`
|
||||
);
|
||||
}
|
||||
|
||||
if (FORBIDDEN_DATABASE_NAMES.has(databaseName)) {
|
||||
throw new Error(
|
||||
`Refusing to run HTTP integration tests against forbidden DB "${config.database.name}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function recreateTestDatabase(config: ConfigType): Promise<void> {
|
||||
const databaseName = config.database.name.trim();
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(databaseName)) {
|
||||
throw new Error(`Unsafe test database name "${databaseName}".`);
|
||||
}
|
||||
|
||||
const connection = await mysql.createConnection({
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
multipleStatements: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await connection.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
|
||||
await connection.query(
|
||||
`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(0, "127.0.0.1", (error?: Error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -52,6 +52,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
|
||||
- [README de `modules/companies`](../modules/companies/README.md)
|
||||
- [README de `modules/auth`](../modules/auth/README.md)
|
||||
- [API pública de selección y alta rápida de clientes](./customers/customer-selection-and-quick-create.md)
|
||||
- [Tests HTTP backend](./testing/backend-http-integration.md)
|
||||
|
||||
#### Guías y estándares
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ slug
|
||||
email
|
||||
phone
|
||||
website
|
||||
default_payment_term_id
|
||||
status
|
||||
created_at
|
||||
updated_at
|
||||
@ -134,6 +135,22 @@ GET /companies/available
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Para settings de empresa activa, los endpoints vigentes son:
|
||||
|
||||
```http
|
||||
GET /companies/current
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
Esos endpoints resuelven la empresa exclusivamente desde:
|
||||
|
||||
```txt
|
||||
X-Company-Id
|
||||
+ requireIdentityTenant(params)
|
||||
```
|
||||
|
||||
No desde `companyId` enviado por body, query o params arbitrarios.
|
||||
|
||||
Tampoco debe documentarse ni introducirse `GET /account/companies` como variante válida.
|
||||
|
||||
## Dependencias permitidas
|
||||
@ -176,3 +193,22 @@ membership active en identity
|
||||
+
|
||||
company active en companies
|
||||
```
|
||||
|
||||
## Autorización V1 de edición de company
|
||||
|
||||
En la V1 actual:
|
||||
|
||||
```txt
|
||||
cualquier miembro activo de la company
|
||||
puede editar sus datos generales
|
||||
```
|
||||
|
||||
Esto aplica a:
|
||||
|
||||
```http
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
No debe documentarse como permiso granular real.
|
||||
|
||||
`identity:companies:update` puede existir como código futuro del catálogo de permisos, pero no representa enforcement runtime hoy.
|
||||
|
||||
@ -39,6 +39,32 @@ POST /companies/:company_id/enable
|
||||
POST /companies/:company_id/disable
|
||||
```
|
||||
|
||||
### Backend tenant-scoped en `modules/companies`
|
||||
|
||||
`modules/companies` también expone endpoints tenant-scoped para operar sobre la company activa o endurecer endpoints legacy.
|
||||
|
||||
Esto aplica a:
|
||||
|
||||
```http
|
||||
GET /companies/current
|
||||
PATCH /companies/current
|
||||
GET /companies/:company_id
|
||||
PATCH /companies/:company_id
|
||||
POST /companies/:company_id/enable
|
||||
POST /companies/:company_id/disable
|
||||
```
|
||||
|
||||
Política V1 actual para `PATCH /companies/current`:
|
||||
|
||||
```txt
|
||||
usuario autenticado
|
||||
+ X-Company-Id válido
|
||||
+ membership activa
|
||||
+ company activa
|
||||
```
|
||||
|
||||
No existe todavía enforcement real de `identity:companies:update`.
|
||||
|
||||
## Frontend actual
|
||||
|
||||
El runtime nuevo ya usa `@erp/identity/client`.
|
||||
|
||||
@ -51,6 +51,15 @@ Actualmente ya valida:
|
||||
6. company `active` en `companies`
|
||||
7. solo entonces rellena `req.user.companyId`
|
||||
|
||||
Esto significa que hoy el contexto tenant garantiza:
|
||||
|
||||
```txt
|
||||
membership activa
|
||||
+ company activa
|
||||
```
|
||||
|
||||
pero no enforcement de permisos granulares por operación.
|
||||
|
||||
## Semántica esperada de errores
|
||||
|
||||
```txt
|
||||
@ -88,6 +97,7 @@ No añadir:
|
||||
|
||||
Actualmente usan `requireIdentityTenant(params)`:
|
||||
|
||||
- `modules/companies` para `GET /companies/current`, `PATCH /companies/current` y endpoints company-scoped legacy
|
||||
- `modules/catalogs`
|
||||
- `modules/customers`
|
||||
- `modules/customer-invoices`
|
||||
@ -106,6 +116,28 @@ El access token no debe incluir:
|
||||
|
||||
El contexto tenant se resuelve por request con `X-Company-Id`.
|
||||
|
||||
## Caso actual: company settings
|
||||
|
||||
`/settings/company/details` opera siempre sobre la company activa.
|
||||
|
||||
Endpoints:
|
||||
|
||||
```http
|
||||
GET /companies/current
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
Política V1:
|
||||
|
||||
```txt
|
||||
usuario autenticado
|
||||
+ X-Company-Id válido
|
||||
+ membership activa en esa company
|
||||
+ company activa
|
||||
```
|
||||
|
||||
No se aplica todavía un permiso específico como `identity:companies:update`.
|
||||
|
||||
## Document context
|
||||
|
||||
Si un flujo documental necesita `companySlug`, debe resolverlo desde `companies`, por ejemplo:
|
||||
|
||||
@ -58,6 +58,16 @@ Ejemplos previstos:
|
||||
|
||||
Una configuración situada bajo `/settings` debe operar sobre la empresa activa resuelta por la infraestructura global de identidad.
|
||||
|
||||
Caso ya implementado:
|
||||
|
||||
```txt
|
||||
/settings/company/details
|
||||
-> GET /companies/current
|
||||
-> PATCH /companies/current
|
||||
```
|
||||
|
||||
La pantalla no selecciona el target mediante `companyId`.
|
||||
|
||||
### 3.2 `/account`
|
||||
|
||||
`/account` queda reservado para configuración personal del usuario.
|
||||
@ -168,6 +178,99 @@ reglas de negocio
|
||||
UI específica
|
||||
```
|
||||
|
||||
En `company/details`, los campos editables actuales son:
|
||||
|
||||
```txt
|
||||
legal_name
|
||||
trade_name
|
||||
tin
|
||||
email
|
||||
phone
|
||||
website
|
||||
default_payment_term_id
|
||||
```
|
||||
|
||||
La UI actual de esta pantalla se organiza en:
|
||||
|
||||
```txt
|
||||
Datos generales
|
||||
→ legal_name
|
||||
→ trade_name
|
||||
→ tin
|
||||
|
||||
Datos de contacto
|
||||
→ email
|
||||
→ phone
|
||||
→ website
|
||||
|
||||
Condiciones comerciales
|
||||
→ default_payment_term_id
|
||||
```
|
||||
|
||||
Semántica de guardado esperada para esta pantalla:
|
||||
|
||||
```txt
|
||||
PATCH parcial
|
||||
→ solo viajan los campos dirty
|
||||
|
||||
campo omitido
|
||||
→ no modificar
|
||||
|
||||
campo nullable enviado como null
|
||||
→ limpiar valor
|
||||
```
|
||||
|
||||
La pantalla sigue operando exclusivamente contra:
|
||||
|
||||
```txt
|
||||
GET /companies/current
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
sin usar `companyId` como selector del target.
|
||||
|
||||
Tras un guardado correcto, la respuesta del servidor debe seguir siendo la fuente única de verdad:
|
||||
|
||||
```txt
|
||||
response
|
||||
→ set/reset del formulario
|
||||
→ sincronización de active company visible
|
||||
```
|
||||
|
||||
Política de errores consolidada para esta pantalla:
|
||||
|
||||
```txt
|
||||
error inicial sin datos requeridos
|
||||
→ PageErrorState
|
||||
|
||||
error de refetch con datos previos
|
||||
→ mantener contenido
|
||||
→ ErrorAlert inline
|
||||
|
||||
cancelled
|
||||
→ no elevar a pantalla bloqueante
|
||||
|
||||
422 de backend con path mapeable
|
||||
→ error de campo en RHF
|
||||
|
||||
error general de mutation
|
||||
→ feedback local
|
||||
```
|
||||
|
||||
Fuera de alcance del formulario actual:
|
||||
|
||||
```txt
|
||||
slug
|
||||
status
|
||||
fiscalidad avanzada
|
||||
VeriFactu
|
||||
document series
|
||||
payment methods
|
||||
branding
|
||||
bank accounts
|
||||
defaultPaymentMethodId
|
||||
```
|
||||
|
||||
El hecho de que una Page aparezca dentro de `/settings` no cambia su arquitectura interna.
|
||||
|
||||
Flujo obligatorio:
|
||||
|
||||
130
docs/testing/backend-http-integration.md
Normal file
130
docs/testing/backend-http-integration.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Tests HTTP backend
|
||||
|
||||
Esta guía pone en marcha un entorno real de integración HTTP para el backend del ERP usando:
|
||||
|
||||
- Express real
|
||||
- módulos reales
|
||||
- middleware real de auth y tenant
|
||||
- MariaDB real
|
||||
- Sequelize real
|
||||
|
||||
## Estado actual
|
||||
|
||||
La suite vive en:
|
||||
|
||||
```txt
|
||||
apps/server/src/testing/http
|
||||
```
|
||||
|
||||
Script disponible:
|
||||
|
||||
```bash
|
||||
cd /home/rodax/Documentos/uecko-erp
|
||||
pnpm --filter @erp/factuges-server test:http
|
||||
```
|
||||
|
||||
## Entorno requerido
|
||||
|
||||
El runtime carga:
|
||||
|
||||
```txt
|
||||
apps/server/.env.test
|
||||
```
|
||||
|
||||
Contrato mínimo:
|
||||
|
||||
- `NODE_ENV=test`
|
||||
- `DB_NAME` debe contener `test`
|
||||
- la base se recrea en cada arranque de runtime
|
||||
- el usuario configurado debe poder hacer `CREATE DATABASE` y `DROP DATABASE`
|
||||
|
||||
Para evitar colisiones entre suites o casos concurrentes:
|
||||
|
||||
- el runtime genera un nombre de base efímero a partir de `DB_NAME`
|
||||
- cada test trabaja sobre una base distinta
|
||||
|
||||
## Runtime reutilizable
|
||||
|
||||
Pieza principal:
|
||||
|
||||
```txt
|
||||
apps/server/src/testing/http/http-test-runtime.ts
|
||||
```
|
||||
|
||||
Responsabilidades:
|
||||
|
||||
- cargar `.env.test`
|
||||
- resetear registries globales del server
|
||||
- validar configuración segura de test
|
||||
- recrear la base de datos
|
||||
- arrancar la app real en un puerto efímero
|
||||
- exponer `request()` y `stop()`
|
||||
|
||||
## Fixtures actuales
|
||||
|
||||
Helpers disponibles:
|
||||
|
||||
```txt
|
||||
apps/server/src/testing/http/http-test-fixtures.ts
|
||||
```
|
||||
|
||||
Cobertura inicial:
|
||||
|
||||
- cuentas de identity
|
||||
- companies
|
||||
- memberships account-company
|
||||
- payment terms
|
||||
- login real por API
|
||||
- consulta directa a la fila persistida de `companies`
|
||||
|
||||
## Suite piloto actual
|
||||
|
||||
Archivo:
|
||||
|
||||
```txt
|
||||
apps/server/src/testing/http/companies-current.http.test.ts
|
||||
```
|
||||
|
||||
Cobertura inicial confirmada:
|
||||
|
||||
- `GET /api/v1/companies/current`
|
||||
- `PATCH /api/v1/companies/current`
|
||||
- 401 sin auth
|
||||
- 403 sin `X-Company-Id`
|
||||
- 403 con membership inactiva
|
||||
- update parcial
|
||||
- limpieza explícita con `null`
|
||||
- asignación y desasignación de `default_payment_term_id`
|
||||
- `default_payment_term_id` cross-company => `422`
|
||||
- `default_payment_term_id` inactivo => `422`
|
||||
- rollback transaccional cuando falla la asignación de `default_payment_term_id`
|
||||
- 422 por payload inválido
|
||||
|
||||
## Convenciones prácticas
|
||||
|
||||
- usar login real en vez de fabricar JWTs
|
||||
- sembrar solo los datos mínimos del caso
|
||||
- cerrar siempre el runtime con `stop()`
|
||||
- no compartir base de datos entre tests
|
||||
- validar tanto respuesta HTTP como persistencia cuando la mutación lo requiera
|
||||
|
||||
## Contrato de `default_payment_term_id`
|
||||
|
||||
Semántica actual:
|
||||
|
||||
```txt
|
||||
omitido -> conservar
|
||||
null -> desvincular
|
||||
UUID activo de la misma company -> asignar
|
||||
UUID no asignable -> 422
|
||||
```
|
||||
|
||||
Se considera no asignable:
|
||||
|
||||
- un `PaymentTerm` de otra company
|
||||
- un `PaymentTerm` inactivo
|
||||
|
||||
Además, el `PATCH /companies/current` es transaccional:
|
||||
|
||||
- si falla `default_payment_term_id`
|
||||
- no se persisten otros cambios del mismo request
|
||||
@ -129,7 +129,7 @@
|
||||
}
|
||||
},
|
||||
"paymentTerms": {
|
||||
"title": "Plazos de pago",
|
||||
"title": "Vencimientos",
|
||||
"description": "Gestiona las plantillas de vencimientos disponibles para la empresa.",
|
||||
"toolbar": {
|
||||
"searchPlaceholder": "Buscar por nombre o descripción…",
|
||||
@ -139,8 +139,8 @@
|
||||
"active": "Activos",
|
||||
"inactive": "Inactivos"
|
||||
},
|
||||
"totalCount_one": "{{count}} plazo en total",
|
||||
"totalCount_other": "{{count}} plazos en total"
|
||||
"totalCount_one": "{{count}} vencimiento en total",
|
||||
"totalCount_other": "{{count}} vencimientos en total"
|
||||
},
|
||||
"list": {
|
||||
"rulesLabel": "Vencimientos"
|
||||
@ -159,13 +159,13 @@
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"nameDescription": "Nombre visible del plazo de pago.",
|
||||
"nameDescription": "Nombre visible del vencimiento.",
|
||||
"namePlaceholder": "Ej. 30/60/90",
|
||||
"description": "Descripción",
|
||||
"descriptionDescription": "Información adicional opcional para uso interno.",
|
||||
"descriptionPlaceholder": "Añade una descripción opcional",
|
||||
"active": "Activo",
|
||||
"activeDescription": "Controla si el plazo de pago aparece como disponible.",
|
||||
"activeDescription": "Controla si el vencimiento aparece como disponible.",
|
||||
"dueDays": "Días",
|
||||
"dueDaysDescription": "Días desde la fecha base del documento.",
|
||||
"dueDaysPlaceholder": "0",
|
||||
@ -199,46 +199,46 @@
|
||||
"system": "Sistema"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nuevo plazo de pago",
|
||||
"title": "Nuevo vencimiento",
|
||||
"description": "Crea una nueva plantilla de vencimientos para esta empresa."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar plazo de pago",
|
||||
"description": "Actualiza la información y los vencimientos del plazo de pago seleccionado."
|
||||
"title": "Editar vencimiento",
|
||||
"description": "Actualiza la información y los vencimientos del elemento seleccionado."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar plazo de pago",
|
||||
"description": "Este plazo dejará de estar disponible. Si está siendo utilizado, la operación no se completará.",
|
||||
"errorTitle": "No se pudo eliminar el plazo de pago",
|
||||
"inUse": "No se puede eliminar este plazo de pago porque está siendo utilizado."
|
||||
"title": "Eliminar vencimiento",
|
||||
"description": "Este vencimiento dejará de estar disponible. Si está siendo utilizado, la operación no se completará.",
|
||||
"errorTitle": "No se pudo eliminar el vencimiento",
|
||||
"inUse": "No se puede eliminar este vencimiento porque está siendo utilizado."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No hay plazos de pago configurados.",
|
||||
"description": "Cuando existan plazos de pago disponibles aparecerán aquí."
|
||||
"title": "No hay vencimientos configurados.",
|
||||
"description": "Cuando existan vencimientos disponibles aparecerán aquí."
|
||||
},
|
||||
"emptyFiltered": {
|
||||
"title": "No se encontraron plazos con los criterios actuales.",
|
||||
"description": "Prueba con otra búsqueda o limpia el filtro para volver a ver todos los plazos.",
|
||||
"title": "No se encontraron vencimientos con los criterios actuales.",
|
||||
"description": "Prueba con otra búsqueda o limpia el filtro para volver a ver todos los vencimientos.",
|
||||
"clear": "Limpiar búsqueda y filtro"
|
||||
},
|
||||
"messages": {
|
||||
"createdTitle": "Plazo de pago creado",
|
||||
"created": "El plazo de pago se ha creado correctamente.",
|
||||
"updatedTitle": "Plazo de pago actualizado",
|
||||
"createdTitle": "Vencimiento creado",
|
||||
"created": "El vencimiento se ha creado correctamente.",
|
||||
"updatedTitle": "Vencimiento actualizado",
|
||||
"updated": "Los cambios se han guardado correctamente.",
|
||||
"enabledTitle": "Plazo de pago activado",
|
||||
"enabled": "El plazo vuelve a estar disponible.",
|
||||
"disabledTitle": "Plazo de pago desactivado",
|
||||
"disabled": "El plazo ha dejado de estar disponible.",
|
||||
"deletedTitle": "Plazo de pago eliminado",
|
||||
"deleted": "El plazo de pago ha dejado de estar disponible.",
|
||||
"enabledTitle": "Vencimiento activado",
|
||||
"enabled": "El vencimiento vuelve a estar disponible.",
|
||||
"disabledTitle": "Vencimiento desactivado",
|
||||
"disabled": "El vencimiento ha dejado de estar disponible.",
|
||||
"deletedTitle": "Vencimiento eliminado",
|
||||
"deleted": "El vencimiento ha dejado de estar disponible.",
|
||||
"validationTitle": "Revisa los campos",
|
||||
"validationDescription": "Hay errores de validación en el formulario."
|
||||
},
|
||||
"protections": {
|
||||
"systemEdit": "Los plazos del sistema no se pueden editar.",
|
||||
"systemDisable": "Los plazos del sistema no se pueden desactivar.",
|
||||
"systemDelete": "Los plazos del sistema no se pueden eliminar."
|
||||
"systemEdit": "Los vencimientos del sistema no se pueden editar.",
|
||||
"systemDisable": "Los vencimientos del sistema no se pueden desactivar.",
|
||||
"systemDelete": "Los vencimientos del sistema no se pueden eliminar."
|
||||
},
|
||||
"form": {
|
||||
"rules": {
|
||||
@ -259,7 +259,7 @@
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "No tienes acceso a esta configuración",
|
||||
"description": "No dispones de permisos para consultar los plazos de pago."
|
||||
"description": "No dispones de permisos para consultar los vencimientos."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "No se pudo cargar la configuración",
|
||||
@ -279,11 +279,11 @@
|
||||
},
|
||||
"server": {
|
||||
"title": "No se pudo cargar la configuración",
|
||||
"description": "Se produjo un error interno al obtener los plazos de pago."
|
||||
"description": "Se produjo un error interno al obtener los vencimientos."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "No se pudo cargar la configuración",
|
||||
"description": "Se produjo un error inesperado al obtener los plazos de pago."
|
||||
"description": "Se produjo un error inesperado al obtener los vencimientos."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -8,6 +8,7 @@ Gestiona la entidad `Company` funcional del ERP:
|
||||
|
||||
- datos legales
|
||||
- datos operativos básicos
|
||||
- `defaultPaymentTermId`
|
||||
- `slug`
|
||||
- `status`
|
||||
|
||||
@ -29,6 +30,7 @@ slug
|
||||
email
|
||||
phone
|
||||
website
|
||||
default_payment_term_id
|
||||
status
|
||||
created_at
|
||||
updated_at
|
||||
@ -63,12 +65,228 @@ No debe asumir:
|
||||
|
||||
## Dependencias
|
||||
|
||||
`modules/companies` depende de `identity`.
|
||||
`modules/companies` depende de `identity` y `catalogs`.
|
||||
|
||||
Uso actual:
|
||||
|
||||
- sus endpoints usan `requireIdentityAuthenticated(params)`
|
||||
- `GET /companies/available` no usa `requireIdentityTenant(params)`
|
||||
- `GET /companies/current` y `PATCH /companies/current` usan auth de `identity` + `requireCompanyContext(...)` cableado con `companyAccessChecker` de `identity` y `publicFinder` propio de `companies`
|
||||
- los endpoints legacy por `:company_id` siguen tenant-scoped y comparan `req.user.companyId` con `:company_id`
|
||||
|
||||
## Endpoints relevantes
|
||||
|
||||
```http
|
||||
POST /companies
|
||||
GET /companies/available
|
||||
GET /companies/current
|
||||
PATCH /companies/current
|
||||
GET /companies/:company_id
|
||||
PATCH /companies/:company_id
|
||||
POST /companies/:company_id/enable
|
||||
POST /companies/:company_id/disable
|
||||
```
|
||||
|
||||
### Settings de empresa activa
|
||||
|
||||
`/settings/company/details` consume:
|
||||
|
||||
```http
|
||||
GET /companies/current
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
Resolución real del target:
|
||||
|
||||
```txt
|
||||
Authorization
|
||||
+ X-Company-Id
|
||||
+ requireCompanyContext(...)
|
||||
=> req.user.companyId
|
||||
```
|
||||
|
||||
El cliente no selecciona la empresa a editar mediante `companyId` en URL, body ni query.
|
||||
|
||||
## Política V1 de autorización
|
||||
|
||||
En la V1 cualquier miembro activo de la compañía puede editar sus datos generales mediante:
|
||||
|
||||
```http
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
Política real:
|
||||
|
||||
```txt
|
||||
usuario autenticado
|
||||
+ X-Company-Id válido
|
||||
+ membership activa en esa company
|
||||
+ company activa
|
||||
```
|
||||
|
||||
No existe todavía enforcement real de RBAC ni permisos granulares en runtime.
|
||||
|
||||
`identity:companies:update` puede existir como código futuro de catálogo, pero no representa autorización aplicada hoy.
|
||||
|
||||
## Campos editables actuales
|
||||
|
||||
`PATCH /companies/current` admite solo:
|
||||
|
||||
```txt
|
||||
legal_name
|
||||
trade_name
|
||||
tin
|
||||
email
|
||||
phone
|
||||
website
|
||||
default_payment_term_id
|
||||
```
|
||||
|
||||
En frontend, la feature:
|
||||
|
||||
```txt
|
||||
/settings/company/details
|
||||
```
|
||||
|
||||
modela esos mismos campos como ficha editable de la company activa.
|
||||
|
||||
La UI actual agrupa la edición en tres secciones:
|
||||
|
||||
```txt
|
||||
Datos generales
|
||||
→ legalName
|
||||
→ tradeName
|
||||
→ tin
|
||||
|
||||
Datos de contacto
|
||||
→ email
|
||||
→ phone
|
||||
→ website
|
||||
|
||||
Condiciones comerciales
|
||||
→ defaultPaymentTermId
|
||||
```
|
||||
|
||||
Semántica PATCH esperada en la pantalla:
|
||||
|
||||
```txt
|
||||
propiedad omitida -> conservar valor actual
|
||||
propiedad presente con valor -> actualizar
|
||||
propiedad nullable presente con null -> limpiar
|
||||
```
|
||||
|
||||
Flujo de guardado esperado en frontend:
|
||||
|
||||
```txt
|
||||
GET /companies/current
|
||||
→ hydrata formulario
|
||||
|
||||
editar campos dirty
|
||||
→ construir PATCH parcial
|
||||
|
||||
PATCH /companies/current
|
||||
→ respuesta servidor como SSOT
|
||||
→ updateActiveCompany(...)
|
||||
→ reset del formulario con la respuesta
|
||||
```
|
||||
|
||||
Política de errores frontend en esta pantalla:
|
||||
|
||||
```txt
|
||||
fallo inicial sin datos utilizables
|
||||
→ PageErrorState
|
||||
|
||||
error de refetch con datos previos
|
||||
→ mantener formulario
|
||||
→ ErrorAlert inline
|
||||
|
||||
cancelled
|
||||
→ sin pantalla bloqueante
|
||||
|
||||
422 con paths mapeables
|
||||
→ errores de campo en RHF
|
||||
|
||||
error general de guardado
|
||||
→ toast
|
||||
→ sin reset del formulario
|
||||
```
|
||||
|
||||
La UI no debe seleccionar el target mediante `companyId`.
|
||||
|
||||
Fuera de alcance de esta API:
|
||||
|
||||
```txt
|
||||
slug
|
||||
status
|
||||
fiscalidad avanzada
|
||||
VeriFactu
|
||||
document series
|
||||
payment methods
|
||||
branding
|
||||
bank accounts
|
||||
defaultPaymentMethodId
|
||||
```
|
||||
|
||||
## `defaultPaymentTermId`
|
||||
|
||||
Contrato actual:
|
||||
|
||||
```txt
|
||||
omitido -> mantener
|
||||
null -> desvincular
|
||||
UUID -> validar dentro de la company activa
|
||||
```
|
||||
|
||||
La referencia es:
|
||||
|
||||
```txt
|
||||
nullable
|
||||
company-scoped
|
||||
validada mediante catalogs
|
||||
```
|
||||
|
||||
Semántica HTTP actual:
|
||||
|
||||
```txt
|
||||
UUID activo de la misma company -> 200
|
||||
UUID de otra company -> 422
|
||||
UUID inactivo -> 422
|
||||
```
|
||||
|
||||
El error de asignación es deliberadamente opaco:
|
||||
|
||||
```txt
|
||||
Default payment term is not assignable to the active company.
|
||||
```
|
||||
|
||||
No expone si el problema concreto es:
|
||||
|
||||
- inexistente
|
||||
- cross-company
|
||||
- inactivo
|
||||
|
||||
El PATCH es transaccional:
|
||||
|
||||
- si falla `default_payment_term_id`
|
||||
- no se persisten otros cambios del mismo request
|
||||
|
||||
## Limitaciones V1 de `Company Details`
|
||||
|
||||
Fuera del alcance funcional actual:
|
||||
|
||||
- RBAC granular
|
||||
- address
|
||||
- country
|
||||
- language
|
||||
- currency
|
||||
- timezone
|
||||
- branding
|
||||
- logo
|
||||
- bank accounts
|
||||
- fiscalidad avanzada
|
||||
- VeriFactu
|
||||
- series documentales
|
||||
- `defaultPaymentMethodId`
|
||||
|
||||
## Reglas
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ import type { IPaymentTermPublicFinder } from "@erp/catalogs/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyDefaultPaymentTermNotAssignableError } from "../../../domain";
|
||||
|
||||
export interface ICompanyPaymentTermResolver {
|
||||
ensureAssignable(params: {
|
||||
companyId: UniqueID;
|
||||
@ -43,11 +45,19 @@ export class CompanyPaymentTermResolver implements ICompanyPaymentTermResolver {
|
||||
}
|
||||
|
||||
if (result.data.isNone()) {
|
||||
return Result.fail(new Error("Default payment term not found in current company."));
|
||||
return Result.fail(
|
||||
new CompanyDefaultPaymentTermNotAssignableError(
|
||||
"Default payment term is not assignable to the active company."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.data.unwrap().isActive) {
|
||||
return Result.fail(new Error("Default payment term must be active."));
|
||||
return Result.fail(
|
||||
new CompanyDefaultPaymentTermNotAssignableError(
|
||||
"Default payment term is not assignable to the active company."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CompanyResponseDTO } from "../../../../common";
|
||||
import type { CompanyPublicModelMapper } from "../public";
|
||||
import type { ICompanyFinder } from "../services";
|
||||
|
||||
export class GetCurrentCompanyUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
finder: ICompanyFinder;
|
||||
responseMapper: CompanyPublicModelMapper;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public execute(params: { companyId: string }): Promise<Result<CompanyResponseDTO, Error>> {
|
||||
const companyIdResult = UniqueID.create(params.companyId);
|
||||
if (companyIdResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(companyIdResult.error));
|
||||
}
|
||||
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
const companyResult = await this.deps.finder.findCompanyById(companyIdResult.data, transaction);
|
||||
if (companyResult.isFailure) {
|
||||
return Result.fail(companyResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(companyResult.data)));
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toCompanyResponseDTO(model: {
|
||||
id: string;
|
||||
legalName: string;
|
||||
tradeName: string | null;
|
||||
tin: string | null;
|
||||
slug: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
defaultPaymentTermId: string | null;
|
||||
status: "active" | "disabled";
|
||||
}): CompanyResponseDTO {
|
||||
return {
|
||||
id: model.id,
|
||||
legal_name: model.legalName,
|
||||
trade_name: model.tradeName,
|
||||
tin: model.tin,
|
||||
slug: model.slug,
|
||||
email: model.email,
|
||||
phone: model.phone,
|
||||
website: model.website,
|
||||
default_payment_term_id: model.defaultPaymentTermId,
|
||||
status: model.status,
|
||||
};
|
||||
}
|
||||
@ -3,4 +3,6 @@ export * from "./disable-company.use-case";
|
||||
export * from "./enable-company.use-case";
|
||||
export * from "./find-available-companies-for-account.use-case";
|
||||
export * from "./get-company-by-id.use-case";
|
||||
export * from "./get-current-company.use-case";
|
||||
export * from "./update-current-company.use-case";
|
||||
export * from "./update-company.use-case";
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import {
|
||||
EmailAddress,
|
||||
Name,
|
||||
PhoneNumber,
|
||||
TextValue,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
||||
import { CompanyPublicModelMapper } from "../public";
|
||||
|
||||
import { UpdateCurrentCompanyUseCase } from "./update-current-company.use-case";
|
||||
|
||||
const buildTransactionManager = (): ITransactionManager => ({
|
||||
start: async () => undefined,
|
||||
getTransaction: () => undefined,
|
||||
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
|
||||
commit: async () => undefined,
|
||||
rollback: async () => undefined,
|
||||
});
|
||||
|
||||
const buildCompany = () => {
|
||||
const now = UtcDate.createFromISO("2026-08-16");
|
||||
const id = UniqueID.generateNewID();
|
||||
const legalName = Name.create("Rodax Labs");
|
||||
const tradeName = Name.create("Rodax");
|
||||
const slug = CompanySlug.create("rodax-labs");
|
||||
const email = EmailAddress.create("hello@rodax.test");
|
||||
const phone = PhoneNumber.create("+34911111111");
|
||||
const website = URLAddress.create("https://rodax.test");
|
||||
const tin = TextValue.create("B12345678");
|
||||
|
||||
assert.equal(now.isSuccess, true);
|
||||
assert.equal(legalName.isSuccess, true);
|
||||
assert.equal(tradeName.isSuccess, true);
|
||||
assert.equal(slug.isSuccess, true);
|
||||
assert.equal(email.isSuccess, true);
|
||||
assert.equal(phone.isSuccess, true);
|
||||
assert.equal(website.isSuccess, true);
|
||||
assert.equal(tin.isSuccess, true);
|
||||
|
||||
const companyResult = Company.create(
|
||||
{
|
||||
legalName: legalName.data,
|
||||
tradeName: Maybe.some(tradeName.data),
|
||||
tin: Maybe.some(tin.data),
|
||||
slug: slug.data,
|
||||
email: Maybe.some(email.data),
|
||||
phone: Maybe.some(phone.data),
|
||||
website: Maybe.some(website.data),
|
||||
defaultPaymentTermId: Maybe.some(UniqueID.generateNewID()),
|
||||
status: CompanyStatus.createActive(),
|
||||
createdAt: now.data,
|
||||
updatedAt: now.data,
|
||||
},
|
||||
id
|
||||
);
|
||||
|
||||
assert.equal(companyResult.isSuccess, true);
|
||||
return companyResult.data;
|
||||
};
|
||||
|
||||
test("update current company permite limpiar el default payment term con null", async () => {
|
||||
const company = buildCompany();
|
||||
let receivedDefaultPaymentTermId: Maybe<UniqueID> | undefined;
|
||||
let receivedId: Maybe<UniqueID> | undefined;
|
||||
|
||||
const useCase = new UpdateCurrentCompanyUseCase({
|
||||
updater: {
|
||||
update: async ({ patchProps }) => {
|
||||
receivedDefaultPaymentTermId = patchProps.defaultPaymentTermId;
|
||||
|
||||
const updateResult = company.update(patchProps);
|
||||
assert.equal(updateResult.isSuccess, true);
|
||||
|
||||
return Result.ok(company);
|
||||
},
|
||||
},
|
||||
paymentTermResolver: {
|
||||
ensureAssignable: async ({ id }) => {
|
||||
receivedId = id;
|
||||
return Result.ok(undefined);
|
||||
},
|
||||
},
|
||||
responseMapper: new CompanyPublicModelMapper(),
|
||||
transactionManager: buildTransactionManager(),
|
||||
});
|
||||
|
||||
const result = await useCase.execute({
|
||||
companyId: company.id.toPrimitive(),
|
||||
dto: {
|
||||
default_payment_term_id: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
if (receivedDefaultPaymentTermId === undefined) {
|
||||
throw new Error("Expected defaultPaymentTermId patch to be captured.");
|
||||
}
|
||||
assert.equal(receivedDefaultPaymentTermId.isNone(), true);
|
||||
assert.equal(receivedId?.isNone(), true);
|
||||
assert.equal(result.data.default_payment_term_id, null);
|
||||
});
|
||||
|
||||
test("update current company no permite cambiar el slug", async () => {
|
||||
const company = buildCompany();
|
||||
let receivedPatch: Record<string, unknown> | null = null;
|
||||
|
||||
const useCase = new UpdateCurrentCompanyUseCase({
|
||||
updater: {
|
||||
update: async ({ patchProps }) => {
|
||||
receivedPatch = patchProps;
|
||||
return Result.ok(company);
|
||||
},
|
||||
},
|
||||
paymentTermResolver: {
|
||||
ensureAssignable: async () => Result.ok(undefined),
|
||||
},
|
||||
responseMapper: new CompanyPublicModelMapper(),
|
||||
transactionManager: buildTransactionManager(),
|
||||
});
|
||||
|
||||
const result = await useCase.execute({
|
||||
companyId: company.id.toPrimitive(),
|
||||
dto: {
|
||||
// @ts-expect-error Verifica que el contrato HTTP no admita slug.
|
||||
slug: "otro-slug",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal("slug" in (receivedPatch ?? {}), false);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data.slug, company.slug.toPrimitive());
|
||||
});
|
||||
@ -0,0 +1,270 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import {
|
||||
EmailAddress,
|
||||
Name,
|
||||
PhoneNumber,
|
||||
TextValue,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
maybeFromNullableResult,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../common";
|
||||
import type { CompanyPatchProps } from "../../../domain";
|
||||
import type { CompanyPublicModelMapper } from "../public";
|
||||
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
||||
|
||||
export class UpdateCurrentCompanyUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
updater: ICompanyUpdater;
|
||||
paymentTermResolver: ICompanyPaymentTermResolver;
|
||||
responseMapper: CompanyPublicModelMapper;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public execute(params: {
|
||||
companyId: string;
|
||||
dto: UpdateCurrentCompanyRequestDTO;
|
||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||
const companyIdResult = UniqueID.create(params.companyId);
|
||||
if (companyIdResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(companyIdResult.error));
|
||||
}
|
||||
|
||||
const patchPropsResult = mapUpdateCurrentCompanyProps(params.dto);
|
||||
if (patchPropsResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(patchPropsResult.error));
|
||||
}
|
||||
|
||||
const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto);
|
||||
if (defaultPaymentTermIdResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error));
|
||||
}
|
||||
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
if (defaultPaymentTermIdResult.data !== undefined) {
|
||||
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
|
||||
companyId: companyIdResult.data,
|
||||
id: defaultPaymentTermIdResult.data,
|
||||
transaction,
|
||||
});
|
||||
if (ensureResult.isFailure) {
|
||||
return Result.fail(ensureResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
const updateResult = await this.deps.updater.update({
|
||||
id: companyIdResult.data,
|
||||
patchProps: patchPropsResult.data,
|
||||
transaction,
|
||||
});
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapUpdateCurrentCompanyProps(
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<CompanyPatchProps, Error> {
|
||||
const patchProps: CompanyPatchProps = {
|
||||
updatedAt: UtcDate.today(),
|
||||
};
|
||||
|
||||
const assignments = [
|
||||
assignLegalNamePatch(patchProps, dto),
|
||||
assignTradeNamePatch(patchProps, dto),
|
||||
assignTinPatch(patchProps, dto),
|
||||
assignEmailPatch(patchProps, dto),
|
||||
assignPhonePatch(patchProps, dto),
|
||||
assignWebsitePatch(patchProps, dto),
|
||||
assignDefaultPaymentTermPatch(patchProps, dto),
|
||||
];
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.isFailure) {
|
||||
return Result.fail(assignment.error);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(patchProps);
|
||||
}
|
||||
|
||||
function assignLegalNamePatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.legal_name === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = Name.create(dto.legal_name);
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.legalName = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function assignTradeNamePatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.trade_name === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = maybeFromNullableResult(dto.trade_name, (value) => Name.create(value));
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.tradeName = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function assignTinPatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.tin === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = maybeFromNullableResult(dto.tin, (value) => TextValue.create(value));
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.tin = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function assignEmailPatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.email === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = maybeFromNullableResult(dto.email, (value) => EmailAddress.create(value));
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.email = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function assignPhonePatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.phone === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = maybeFromNullableResult(dto.phone, (value) => PhoneNumber.create(value));
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.phone = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function assignWebsitePatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.website === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = maybeFromNullableResult(dto.website, (value) => URLAddress.create(value));
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.website = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function assignDefaultPaymentTermPatch(
|
||||
patchProps: CompanyPatchProps,
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<void, Error> {
|
||||
if (dto.default_payment_term_id === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const result = maybeFromNullableResult(dto.default_payment_term_id, (value) =>
|
||||
UniqueID.create(value)
|
||||
);
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
patchProps.defaultPaymentTermId = result.data;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
function mapDefaultPaymentTermId(
|
||||
dto: UpdateCurrentCompanyRequestDTO
|
||||
): Result<Maybe<UniqueID> | undefined, Error> {
|
||||
if (dto.default_payment_term_id === undefined) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
if (dto.default_payment_term_id === null) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
|
||||
const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id);
|
||||
if (defaultPaymentTermIdResult.isFailure) {
|
||||
return Result.fail(defaultPaymentTermIdResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(Maybe.some(defaultPaymentTermIdResult.data));
|
||||
}
|
||||
|
||||
function toCompanyResponseDTO(model: {
|
||||
id: string;
|
||||
legalName: string;
|
||||
tradeName: string | null;
|
||||
tin: string | null;
|
||||
slug: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
defaultPaymentTermId: string | null;
|
||||
status: "active" | "disabled";
|
||||
}): CompanyResponseDTO {
|
||||
return {
|
||||
id: model.id,
|
||||
legal_name: model.legalName,
|
||||
trade_name: model.tradeName,
|
||||
tin: model.tin,
|
||||
slug: model.slug,
|
||||
email: model.email,
|
||||
phone: model.phone,
|
||||
website: model.website,
|
||||
default_payment_term_id: model.defaultPaymentTermId,
|
||||
status: model.status,
|
||||
};
|
||||
}
|
||||
@ -16,6 +16,14 @@ export class CompanySlugAlreadyExistsError extends DomainError {
|
||||
public readonly code = "COMPANY_SLUG_ALREADY_EXISTS" as const;
|
||||
}
|
||||
|
||||
export const isCompanySlugAlreadyExistsError = (
|
||||
export const isCompanySlugAlreadyExistsError = (e: unknown): e is CompanySlugAlreadyExistsError =>
|
||||
e instanceof CompanySlugAlreadyExistsError;
|
||||
|
||||
export class CompanyDefaultPaymentTermNotAssignableError extends DomainError {
|
||||
public readonly code = "COMPANY_DEFAULT_PAYMENT_TERM_NOT_ASSIGNABLE" as const;
|
||||
}
|
||||
|
||||
export const isCompanyDefaultPaymentTermNotAssignableError = (
|
||||
e: unknown
|
||||
): e is CompanySlugAlreadyExistsError => e instanceof CompanySlugAlreadyExistsError;
|
||||
): e is CompanyDefaultPaymentTermNotAssignableError =>
|
||||
e instanceof CompanyDefaultPaymentTermNotAssignableError;
|
||||
|
||||
@ -15,8 +15,10 @@ import {
|
||||
EnableCompanyUseCase,
|
||||
FindAvailableCompaniesForAccountUseCase,
|
||||
GetCompanyByIdUseCase,
|
||||
GetCurrentCompanyUseCase,
|
||||
type ICompanyPublicServices,
|
||||
UpdateCompanyUseCase,
|
||||
UpdateCurrentCompanyUseCase,
|
||||
buildCompanyPublicServices,
|
||||
} from "../../application";
|
||||
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
||||
@ -28,8 +30,10 @@ export type CompaniesInternalDeps = {
|
||||
useCases: {
|
||||
createCompany: () => CreateCompanyUseCase;
|
||||
getCompanyById: () => GetCompanyByIdUseCase;
|
||||
getCurrentCompany: () => GetCurrentCompanyUseCase;
|
||||
findAvailableCompaniesForAccount: () => FindAvailableCompaniesForAccountUseCase;
|
||||
updateCompany: () => UpdateCompanyUseCase;
|
||||
updateCurrentCompany: () => UpdateCurrentCompanyUseCase;
|
||||
enableCompany: () => EnableCompanyUseCase;
|
||||
disableCompany: () => DisableCompanyUseCase;
|
||||
};
|
||||
@ -71,6 +75,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
getCurrentCompany: () =>
|
||||
new GetCurrentCompanyUseCase({
|
||||
finder,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
findAvailableCompaniesForAccount: () =>
|
||||
new FindAvailableCompaniesForAccountUseCase({
|
||||
identityServices,
|
||||
@ -85,6 +95,13 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
updateCurrentCompany: () =>
|
||||
new UpdateCurrentCompanyUseCase({
|
||||
updater,
|
||||
paymentTermResolver,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
enableCompany: () =>
|
||||
new EnableCompanyUseCase({
|
||||
finder,
|
||||
|
||||
@ -8,9 +8,11 @@ import {
|
||||
|
||||
import {
|
||||
type CompanyCannotBeUpdatedError,
|
||||
type CompanyDefaultPaymentTermNotAssignableError,
|
||||
type CompanyNotFoundError,
|
||||
type CompanySlugAlreadyExistsError,
|
||||
isCompanyCannotBeUpdatedError,
|
||||
isCompanyDefaultPaymentTermNotAssignableError,
|
||||
isCompanyNotFoundError,
|
||||
isCompanySlugAlreadyExistsError,
|
||||
} from "../../domain";
|
||||
@ -40,7 +42,18 @@ const companyCannotBeUpdatedRule: ErrorToApiRule = {
|
||||
),
|
||||
};
|
||||
|
||||
const companyDefaultPaymentTermNotAssignableRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: isCompanyDefaultPaymentTermNotAssignableError,
|
||||
build: (error) =>
|
||||
new ValidationApiError(
|
||||
(error as CompanyDefaultPaymentTermNotAssignableError).message ||
|
||||
"Default payment term is not assignable to the active company."
|
||||
),
|
||||
};
|
||||
|
||||
export const companiesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
|
||||
.register(companyNotFoundRule)
|
||||
.register(companySlugAlreadyExistsRule)
|
||||
.register(companyDefaultPaymentTermNotAssignableRule)
|
||||
.register(companyCannotBeUpdatedRule);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
||||
import { requireIdentityAuthenticated } from "@erp/identity/api";
|
||||
import type { IIdentityPublicServices } from "@erp/identity/api";
|
||||
import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { Router } from "express";
|
||||
|
||||
@ -9,20 +10,29 @@ import {
|
||||
GetCompanyByIdRequestSchema,
|
||||
UpdateCompanyByIdParamsRequestSchema,
|
||||
UpdateCompanyRequestSchema,
|
||||
UpdateCurrentCompanyRequestSchema,
|
||||
} from "../../../common";
|
||||
import type { CompaniesInternalDeps } from "../di";
|
||||
|
||||
import {
|
||||
CreateCompanyController,
|
||||
DisableCompanyController,
|
||||
EnableCompanyController,
|
||||
FindAvailableCompaniesController,
|
||||
GetCompanyByIdController,
|
||||
GetCurrentCompanyController,
|
||||
UpdateCompanyController,
|
||||
UpdateCurrentCompanyController,
|
||||
} from "./controllers";
|
||||
|
||||
export const companiesRouter = (params: StartParams) => {
|
||||
const { app, config, getInternal } = params;
|
||||
const { app, config, getInternal, getService } = params;
|
||||
const deps = getInternal<CompaniesInternalDeps>("companies");
|
||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
||||
const requireCurrentCompanyContext = requireCompanyContext({
|
||||
companyAccessChecker: identityServices.companyAccess,
|
||||
companyFinder: deps.publicFinder,
|
||||
});
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
@ -44,8 +54,28 @@ export const companiesRouter = (params: StartParams) => {
|
||||
return controller.execute(req, res, next);
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/current",
|
||||
requireCurrentCompanyContext,
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new GetCurrentCompanyController(deps.useCases.getCurrentCompany());
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/current",
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany());
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:company_id",
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(GetCompanyByIdRequestSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById());
|
||||
@ -55,6 +85,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
|
||||
router.patch(
|
||||
"/:company_id",
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
|
||||
validateRequest(UpdateCompanyRequestSchema, "body"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
@ -65,6 +96,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
|
||||
router.post(
|
||||
"/:company_id/enable",
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new EnableCompanyController(deps.useCases.enableCompany());
|
||||
@ -74,6 +106,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
|
||||
router.post(
|
||||
"/:company_id/disable",
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new DisableCompanyController(deps.useCases.disableCompany());
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { DisableCompanyUseCase } from "../../../application";
|
||||
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
|
||||
@ -7,15 +11,20 @@ export class DisableCompanyController extends ExpressController {
|
||||
public constructor(private readonly useCase: DisableCompanyUseCase) {
|
||||
super();
|
||||
this.errorMapper = companiesApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const tenantId = this.getTenantId();
|
||||
const { company_id } = this.req.params;
|
||||
if (!company_id) {
|
||||
if (!company_id || !tenantId) {
|
||||
return this.invalidInputError("Company ID missing");
|
||||
}
|
||||
|
||||
if (tenantId.toPrimitive() !== company_id) {
|
||||
return this.forbiddenError("Company context does not match requested company.");
|
||||
}
|
||||
|
||||
const result = await this.useCase.execute({ company_id });
|
||||
|
||||
return result.match(
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { EnableCompanyUseCase } from "../../../application";
|
||||
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
|
||||
@ -7,15 +11,20 @@ export class EnableCompanyController extends ExpressController {
|
||||
public constructor(private readonly useCase: EnableCompanyUseCase) {
|
||||
super();
|
||||
this.errorMapper = companiesApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const tenantId = this.getTenantId();
|
||||
const { company_id } = this.req.params;
|
||||
if (!company_id) {
|
||||
if (!company_id || !tenantId) {
|
||||
return this.invalidInputError("Company ID missing");
|
||||
}
|
||||
|
||||
if (tenantId.toPrimitive() !== company_id) {
|
||||
return this.forbiddenError("Company context does not match requested company.");
|
||||
}
|
||||
|
||||
const result = await this.useCase.execute({ company_id });
|
||||
|
||||
return result.match(
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { GetCompanyByIdUseCase } from "../../../application";
|
||||
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
|
||||
@ -7,15 +11,20 @@ export class GetCompanyByIdController extends ExpressController {
|
||||
public constructor(private readonly useCase: GetCompanyByIdUseCase) {
|
||||
super();
|
||||
this.errorMapper = companiesApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const tenantId = this.getTenantId();
|
||||
const { company_id } = this.req.params;
|
||||
if (!company_id) {
|
||||
if (!company_id || !tenantId) {
|
||||
return this.invalidInputError("Company ID missing");
|
||||
}
|
||||
|
||||
if (tenantId.toPrimitive() !== company_id) {
|
||||
return this.forbiddenError("Company context does not match requested company.");
|
||||
}
|
||||
|
||||
const result = await this.useCase.execute({ company_id });
|
||||
|
||||
return result.match(
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import {
|
||||
ExpressController,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { GetCurrentCompanyUseCase } from "../../../application";
|
||||
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
|
||||
|
||||
export class GetCurrentCompanyController extends ExpressController {
|
||||
public constructor(private readonly useCase: GetCurrentCompanyUseCase) {
|
||||
super();
|
||||
this.errorMapper = companiesApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const companyId = this.getTenantId();
|
||||
if (!companyId) {
|
||||
return this.forbiddenError("Company context required");
|
||||
}
|
||||
|
||||
const result = await this.useCase.execute({ companyId: companyId.toPrimitive() });
|
||||
|
||||
return result.match(
|
||||
(data) => this.ok(data),
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,4 +3,6 @@ export * from "./disable-company.controller";
|
||||
export * from "./enable-company.controller";
|
||||
export * from "./find-available-companies.controller";
|
||||
export * from "./get-company-by-id.controller";
|
||||
export * from "./get-current-company.controller";
|
||||
export * from "./update-company.controller";
|
||||
export * from "./update-current-company.controller";
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { UpdateCompanyRequestDTO } from "../../../../common";
|
||||
import type { UpdateCompanyUseCase } from "../../../application";
|
||||
@ -8,15 +12,20 @@ export class UpdateCompanyController extends ExpressController {
|
||||
public constructor(private readonly useCase: UpdateCompanyUseCase) {
|
||||
super();
|
||||
this.errorMapper = companiesApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard());
|
||||
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const tenantId = this.getTenantId();
|
||||
const { company_id } = this.req.params;
|
||||
if (!company_id) {
|
||||
if (!company_id || !tenantId) {
|
||||
return this.invalidInputError("Company ID missing");
|
||||
}
|
||||
|
||||
if (tenantId.toPrimitive() !== company_id) {
|
||||
return this.forbiddenError("Company context does not match requested company.");
|
||||
}
|
||||
|
||||
const dto = this.req.body as UpdateCompanyRequestDTO;
|
||||
const result = await this.useCase.execute({ company_id, dto });
|
||||
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import {
|
||||
ExpressController,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { UpdateCurrentCompanyRequestDTO } from "../../../../common";
|
||||
import type { UpdateCurrentCompanyUseCase } from "../../../application";
|
||||
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
|
||||
|
||||
export class UpdateCurrentCompanyController extends ExpressController {
|
||||
public constructor(private readonly useCase: UpdateCurrentCompanyUseCase) {
|
||||
super();
|
||||
this.errorMapper = companiesApiErrorMapper;
|
||||
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const companyId = this.getTenantId();
|
||||
if (!companyId) {
|
||||
return this.forbiddenError("Company context required");
|
||||
}
|
||||
|
||||
const dto = this.req.body as UpdateCurrentCompanyRequestDTO;
|
||||
const result = await this.useCase.execute({
|
||||
companyId: companyId.toPrimitive(),
|
||||
dto,
|
||||
});
|
||||
|
||||
return result.match(
|
||||
(data) => this.ok(data),
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./change-company-status.request.dto";
|
||||
export * from "./create-company.request.dto";
|
||||
export * from "./get-company-by-id.request.dto";
|
||||
export * from "./update-current-company.request.dto";
|
||||
export * from "./update-company.request.dto";
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UpdateCurrentCompanyRequestSchema = z.object({
|
||||
legal_name: z.string().min(1).optional(),
|
||||
trade_name: z.string().nullable().optional(),
|
||||
tin: TinSchema.nullable().optional(),
|
||||
email: EmailSchema.nullable().optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
website: WebsiteSchema.nullable().optional(),
|
||||
default_payment_term_id: z.uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateCurrentCompanyRequestDTO = z.infer<typeof UpdateCurrentCompanyRequestSchema>;
|
||||
@ -2,11 +2,54 @@
|
||||
"settings": {
|
||||
"companyDetails": {
|
||||
"title": "Company details",
|
||||
"description": "Configure the active company general data and its default payment term.",
|
||||
"loading": "Loading active company details...",
|
||||
"description": "Configure the active company general data, contact details, and default payment term.",
|
||||
"sections": {
|
||||
"general": {
|
||||
"title": "General details",
|
||||
"description": "Update the primary identification data of the active company.",
|
||||
"fields": {
|
||||
"legalName": {
|
||||
"label": "Legal name",
|
||||
"description": "This is the main legal name of the company.",
|
||||
"placeholder": "Enter the legal name"
|
||||
},
|
||||
"tradeName": {
|
||||
"label": "Trade name",
|
||||
"description": "Use it when the company operates under a name different from its legal name.",
|
||||
"placeholder": "Enter the trade name"
|
||||
},
|
||||
"tin": {
|
||||
"label": "TIN/VAT",
|
||||
"description": "Tax identification of the active company.",
|
||||
"placeholder": "Enter the TIN/VAT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact details",
|
||||
"description": "Keep the visible contact channels of the active company up to date.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"description": "Primary company contact email.",
|
||||
"placeholder": "name@company.com"
|
||||
},
|
||||
"phone": {
|
||||
"label": "Phone",
|
||||
"description": "Primary company contact phone number.",
|
||||
"placeholder": "Enter the phone number"
|
||||
},
|
||||
"website": {
|
||||
"label": "Website",
|
||||
"description": "Public website of the company.",
|
||||
"placeholder": "https://company.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"paymentTerm": {
|
||||
"title": "Commercial settings",
|
||||
"description": "This term will only be used as the initial value for future configurations.",
|
||||
"title": "Commercial terms",
|
||||
"description": "This term will be used as the initial value in future configurations for the active company.",
|
||||
"field": {
|
||||
"label": "Default payment term",
|
||||
"description": "It must belong to the active company and stay active for new assignments.",
|
||||
@ -26,7 +69,7 @@
|
||||
"saving": "Saving company settings.",
|
||||
"success": {
|
||||
"title": "Settings updated",
|
||||
"message": "The default payment term was saved successfully."
|
||||
"message": "The company details were saved successfully."
|
||||
},
|
||||
"error": {
|
||||
"title": "Could not save",
|
||||
@ -42,10 +85,38 @@
|
||||
}
|
||||
},
|
||||
"pageError": {
|
||||
"title": "Could not load the active company",
|
||||
"description": "Reload the page to try again.",
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying..."
|
||||
"actions": {
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying..."
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "You do not have access to the active company",
|
||||
"description": "You do not have permission to view this configuration."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "The active company was not found",
|
||||
"description": "The selected company no longer exists or is not available."
|
||||
},
|
||||
"network": {
|
||||
"title": "Could not connect to the server",
|
||||
"description": "Check your connection and try again."
|
||||
},
|
||||
"timeout": {
|
||||
"title": "The request is taking too long",
|
||||
"description": "The server did not respond within the expected time."
|
||||
},
|
||||
"server": {
|
||||
"title": "Could not load the active company",
|
||||
"description": "An internal error occurred while retrieving the company settings."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Service temporarily unavailable",
|
||||
"description": "The companies service is not available at the moment."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Could not load the active company",
|
||||
"description": "An unexpected error occurred while loading the company settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,16 +2,59 @@
|
||||
"settings": {
|
||||
"companyDetails": {
|
||||
"title": "Datos de empresa",
|
||||
"description": "Configura los datos generales de la empresa activa y su plazo de pago por defecto.",
|
||||
"loading": "Cargando datos de la empresa activa...",
|
||||
"description": "Configura los datos generales, de contacto y el vencimiento por defecto de la empresa activa.",
|
||||
"sections": {
|
||||
"general": {
|
||||
"title": "Datos generales",
|
||||
"description": "Actualiza la identificación principal de la empresa activa.",
|
||||
"fields": {
|
||||
"legalName": {
|
||||
"label": "Razón social",
|
||||
"description": "Es el nombre legal principal de la empresa.",
|
||||
"placeholder": "Introduce la razón social"
|
||||
},
|
||||
"tradeName": {
|
||||
"label": "Nombre comercial",
|
||||
"description": "Se usa cuando la empresa opera con una denominación distinta de la razón social.",
|
||||
"placeholder": "Introduce el nombre comercial"
|
||||
},
|
||||
"tin": {
|
||||
"label": "NIF/CIF/TIN",
|
||||
"description": "Identificador fiscal de la empresa activa.",
|
||||
"placeholder": "Introduce el NIF/CIF/TIN"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "Datos de contacto",
|
||||
"description": "Mantén actualizados los canales de contacto visibles de la empresa activa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"description": "Correo principal de contacto de la empresa.",
|
||||
"placeholder": "nombre@empresa.com"
|
||||
},
|
||||
"phone": {
|
||||
"label": "Teléfono",
|
||||
"description": "Teléfono principal de contacto de la empresa.",
|
||||
"placeholder": "Introduce el teléfono"
|
||||
},
|
||||
"website": {
|
||||
"label": "Web",
|
||||
"description": "Sitio web público de la empresa.",
|
||||
"placeholder": "https://empresa.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"paymentTerm": {
|
||||
"title": "Configuración comercial",
|
||||
"description": "Este plazo solo se usará como valor inicial en futuras configuraciones.",
|
||||
"title": "Condiciones comerciales",
|
||||
"description": "Este vencimiento se usará como valor inicial en futuras configuraciones de la empresa activa.",
|
||||
"field": {
|
||||
"label": "Plazo de pago por defecto",
|
||||
"label": "Vencimiento por defecto",
|
||||
"description": "Debe pertenecer a la empresa activa y estar activo para nuevas asignaciones.",
|
||||
"placeholder": "Selecciona un plazo de pago",
|
||||
"none": "Sin plazo por defecto",
|
||||
"placeholder": "Selecciona un vencimiento",
|
||||
"none": "Sin vencimiento por defecto",
|
||||
"currentInactive": "{{name}} (inactivo actual)"
|
||||
}
|
||||
}
|
||||
@ -26,7 +69,7 @@
|
||||
"saving": "Guardando configuración de empresa.",
|
||||
"success": {
|
||||
"title": "Configuración actualizada",
|
||||
"message": "El plazo de pago por defecto se ha guardado correctamente."
|
||||
"message": "Los datos de la empresa se han guardado correctamente."
|
||||
},
|
||||
"error": {
|
||||
"title": "No se pudo guardar",
|
||||
@ -42,10 +85,38 @@
|
||||
}
|
||||
},
|
||||
"pageError": {
|
||||
"title": "No se pudo cargar la empresa activa",
|
||||
"description": "Recarga la pantalla para volver a intentarlo.",
|
||||
"retry": "Reintentar",
|
||||
"retrying": "Reintentando..."
|
||||
"actions": {
|
||||
"retry": "Reintentar",
|
||||
"retrying": "Reintentando..."
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "No tienes acceso a la empresa activa",
|
||||
"description": "No dispones de permisos para consultar esta configuración."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "No se encontró la empresa activa",
|
||||
"description": "La empresa seleccionada ya no existe o no está disponible."
|
||||
},
|
||||
"network": {
|
||||
"title": "No se pudo conectar con el servidor",
|
||||
"description": "Comprueba tu conexión e inténtalo de nuevo."
|
||||
},
|
||||
"timeout": {
|
||||
"title": "La carga está tardando demasiado",
|
||||
"description": "El servidor no respondió dentro del tiempo esperado."
|
||||
},
|
||||
"server": {
|
||||
"title": "No se pudo cargar la empresa activa",
|
||||
"description": "Se produjo un error interno al recuperar la configuración de empresa."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Servicio temporalmente no disponible",
|
||||
"description": "El servicio de empresas no está disponible en este momento."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "No se pudo cargar la empresa activa",
|
||||
"description": "Ha ocurrido un error inesperado al cargar la configuración de empresa."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +1,42 @@
|
||||
import { applyValidationErrorCollection } from "@erp/core";
|
||||
import { formHasAnyDirty } from "@erp/core/client";
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import {
|
||||
getPaymentTermOptions,
|
||||
usePaymentTermDetailQuery,
|
||||
usePaymentTermListQuery,
|
||||
} from "@erp/catalogs/client/payment-terms";
|
||||
import { applyValidationErrorCollection } from "@erp/core";
|
||||
import { formHasAnyDirty, getUserErrorMessage } from "@erp/core/client";
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import { useIdentityAuthSession } from "@erp/identity/client";
|
||||
import { isValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import type { SelectFieldItem } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
focusFirstInputFormError,
|
||||
getFirstFormErrorMessage,
|
||||
preventEnterKeySubmitForm,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showWarningToast,
|
||||
} from "@repo/rdx-ui/helpers";
|
||||
import type { SelectFieldItem } from "@repo/rdx-ui/components";
|
||||
import { isValidationErrorCollection, type ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { useEffect, useId, useMemo, type SubmitEvent } from "react";
|
||||
import { type SubmitEvent, useEffect, useId, useMemo } from "react";
|
||||
import type { FieldErrors } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { CompanySettingsFormSchema, type CompanySettingsForm } from "../entities";
|
||||
import { type CompanySettingsForm, CompanySettingsFormSchema } from "../entities";
|
||||
import { useCompanySettingsQuery, useCompanySettingsUpdateMutation } from "../shared/hooks";
|
||||
import { buildCompanySettingsForm, buildUpdateCompanySettingsParams } from "../utils";
|
||||
|
||||
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
|
||||
if (isValidationErrorCollection(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return new Error("Unknown error");
|
||||
};
|
||||
import {
|
||||
buildCompanyDetailsSettingsPageErrorViewModel,
|
||||
buildCompanySettingsForm,
|
||||
buildUpdateCompanySettingsParams,
|
||||
normalizeCompanyDetailsSubmitError,
|
||||
resolveCompanyDetailsSettingsFeedbackState,
|
||||
} from "../utils";
|
||||
|
||||
export const useCompanyDetailsSettingsController = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activeCompany } = useIdentityAuthSession();
|
||||
const companyId = activeCompany?.id;
|
||||
const { activeCompany, updateActiveCompany } = useIdentityAuthSession();
|
||||
const formId = useId();
|
||||
|
||||
const companyQuery = useCompanySettingsQuery(companyId);
|
||||
const companyQuery = useCompanySettingsQuery();
|
||||
const mutation = useCompanySettingsUpdateMutation();
|
||||
|
||||
const paymentTermsQuery = usePaymentTermListQuery({
|
||||
@ -72,7 +66,13 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
const initialValues = useMemo<CompanySettingsForm>(() => {
|
||||
if (!company) {
|
||||
return {
|
||||
defaultPaymentTermId: null,
|
||||
legalName: "",
|
||||
tradeName: "",
|
||||
tin: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
defaultPaymentTermId: "",
|
||||
};
|
||||
}
|
||||
|
||||
@ -85,15 +85,47 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
disabled: mutation.isPending,
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
companyQuery.isLoading ||
|
||||
paymentTermsQuery.isLoading ||
|
||||
(needsCurrentPaymentTerm && currentPaymentTermQuery.isLoading);
|
||||
|
||||
const isRetrying =
|
||||
(companyQuery.isFetching && !companyQuery.isLoading) ||
|
||||
(paymentTermsQuery.isFetching && !paymentTermsQuery.isLoading) ||
|
||||
(needsCurrentPaymentTerm &&
|
||||
currentPaymentTermQuery.isFetching &&
|
||||
!currentPaymentTermQuery.isLoading);
|
||||
|
||||
const loadError =
|
||||
companyQuery.error ??
|
||||
paymentTermsQuery.error ??
|
||||
(needsCurrentPaymentTerm ? currentPaymentTermQuery.error : null);
|
||||
|
||||
const hasRequiredData = Boolean(company) &&
|
||||
Boolean(paymentTermsQuery.data) &&
|
||||
(!needsCurrentPaymentTerm || Boolean(currentPaymentTermQuery.data));
|
||||
|
||||
const feedbackState = resolveCompanyDetailsSettingsFeedbackState({
|
||||
isLoading,
|
||||
isLoadError: Boolean(loadError),
|
||||
hasRequiredData,
|
||||
error: loadError,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.formState.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(buildCompanySettingsForm(company), {
|
||||
keepDirty: false,
|
||||
});
|
||||
}, [company, form]);
|
||||
}, [company, form, form.formState.isDirty]);
|
||||
|
||||
const paymentTermOptions = useMemo<SelectFieldItem[]>(() => {
|
||||
const options = getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
|
||||
@ -107,18 +139,28 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: "",
|
||||
label: t("settings.companyDetails.sections.paymentTerm.field.none"),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
return options;
|
||||
}, [currentPaymentTermQuery.data, needsCurrentPaymentTerm, paymentTermsQuery.data?.items, t]);
|
||||
|
||||
const pageError = useMemo(() => {
|
||||
if (!feedbackState.hasBlockingError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildCompanyDetailsSettingsPageErrorViewModel(loadError, (key) => t(key));
|
||||
}, [feedbackState.hasBlockingError, loadError, t]);
|
||||
|
||||
const inlineLoadError = useMemo(() => {
|
||||
if (!feedbackState.hasInlineError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildCompanyDetailsSettingsPageErrorViewModel(loadError, (key) => t(key));
|
||||
}, [feedbackState.hasInlineError, loadError, t]);
|
||||
|
||||
const submitHandler = form.handleSubmit(
|
||||
async (formData) => {
|
||||
if (!companyId) {
|
||||
if (!activeCompany?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -133,11 +175,17 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
try {
|
||||
const updated = await mutation.mutateAsync(
|
||||
buildUpdateCompanySettingsParams({
|
||||
companyId,
|
||||
form: formData,
|
||||
dirtyFields: form.formState.dirtyFields,
|
||||
})
|
||||
);
|
||||
|
||||
updateActiveCompany({
|
||||
id: updated.id,
|
||||
legalName: updated.legalName,
|
||||
tradeName: updated.tradeName,
|
||||
});
|
||||
|
||||
form.reset(buildCompanySettingsForm(updated), {
|
||||
keepDirty: false,
|
||||
});
|
||||
@ -147,7 +195,7 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
t("settings.companyDetails.feedback.success.message")
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = normalizeSubmitError(error);
|
||||
const normalizedError = normalizeCompanyDetailsSubmitError(error);
|
||||
|
||||
if (isValidationErrorCollection(normalizedError)) {
|
||||
applyValidationErrorCollection(form, normalizedError);
|
||||
@ -159,10 +207,8 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorToast(
|
||||
t("settings.companyDetails.feedback.error.title"),
|
||||
normalizedError.message || t("settings.companyDetails.feedback.error.message")
|
||||
);
|
||||
const userMessage = getUserErrorMessage(normalizedError);
|
||||
showErrorToast(userMessage.title, userMessage.description);
|
||||
}
|
||||
},
|
||||
(errors: FieldErrors<CompanySettingsForm>) => {
|
||||
@ -186,7 +232,11 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
companyQuery,
|
||||
form,
|
||||
formId,
|
||||
inlineLoadError,
|
||||
isInitialLoading: feedbackState.isInitialLoading,
|
||||
onFormKeyDown: preventEnterKeySubmitForm,
|
||||
onSubmit,
|
||||
pageError,
|
||||
paymentTermOptions,
|
||||
resetForm: () => {
|
||||
if (!company) {
|
||||
@ -197,16 +247,9 @@ export const useCompanyDetailsSettingsController = () => {
|
||||
keepDirty: false,
|
||||
});
|
||||
},
|
||||
isLoading:
|
||||
companyQuery.isLoading ||
|
||||
paymentTermsQuery.isLoading ||
|
||||
(needsCurrentPaymentTerm && currentPaymentTermQuery.isLoading),
|
||||
isRetrying: companyQuery.isFetching && !companyQuery.isLoading,
|
||||
isLoading,
|
||||
isRetrying,
|
||||
isSaving: mutation.isPending,
|
||||
pageError:
|
||||
companyQuery.error ??
|
||||
paymentTermsQuery.error ??
|
||||
(needsCurrentPaymentTerm ? currentPaymentTermQuery.error : null),
|
||||
retry: async () => {
|
||||
await Promise.all([
|
||||
companyQuery.refetch(),
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import type { PageErrorStateVariant } from "@erp/core/components";
|
||||
|
||||
export interface CompanyDetailsSettingsPageErrorViewModel {
|
||||
variant: PageErrorStateVariant;
|
||||
title: string;
|
||||
description: string;
|
||||
canRetry: boolean;
|
||||
reference?: string;
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const CompanySettingsFormSchema = z.object({
|
||||
defaultPaymentTermId: z.union([z.uuid(), z.literal(""), z.null()]),
|
||||
legalName: z.string().min(1),
|
||||
tradeName: z.string(),
|
||||
tin: TinSchema.or(z.literal("")),
|
||||
email: EmailSchema.or(z.literal("")),
|
||||
phone: z.string(),
|
||||
website: WebsiteSchema.or(z.literal("")),
|
||||
defaultPaymentTermId: z.string(),
|
||||
});
|
||||
|
||||
export type CompanySettingsForm = z.infer<typeof CompanySettingsFormSchema>;
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./company-details-settings-page-error-view-model.entity";
|
||||
export * from "./company-settings-form.schema";
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CompanyResponseDTO } from "../../../../../common";
|
||||
|
||||
import { CompanySettingsAdapter } from "./company-settings.adapter";
|
||||
|
||||
test("preserva null en los campos nullable del DTO", () => {
|
||||
const dto: CompanyResponseDTO = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
legal_name: "Acme Legal SL",
|
||||
trade_name: null,
|
||||
tin: null,
|
||||
slug: "acme-legal-sl",
|
||||
email: null,
|
||||
phone: null,
|
||||
website: null,
|
||||
default_payment_term_id: null,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
assert.deepEqual(CompanySettingsAdapter.fromDto(dto), {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
legalName: "Acme Legal SL",
|
||||
tradeName: null,
|
||||
tin: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
website: null,
|
||||
defaultPaymentTermId: null,
|
||||
});
|
||||
});
|
||||
@ -7,6 +7,10 @@ export const CompanySettingsAdapter = {
|
||||
id: dto.id,
|
||||
legalName: dto.legal_name,
|
||||
tradeName: dto.trade_name,
|
||||
tin: dto.tin,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
website: dto.website,
|
||||
defaultPaymentTermId: dto.default_payment_term_id,
|
||||
};
|
||||
},
|
||||
|
||||
@ -2,16 +2,14 @@ import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { CompanyResponseDTO } from "../../../../../common";
|
||||
|
||||
export interface GetCompanySettingsParams {
|
||||
companyId: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export const getCompanySettings = (
|
||||
dataSource: IDataSource,
|
||||
params: GetCompanySettingsParams
|
||||
params?: { signal?: AbortSignal }
|
||||
): Promise<CompanyResponseDTO> => {
|
||||
return dataSource.getOne<CompanyResponseDTO>("companies", params.companyId, {
|
||||
signal: params.signal,
|
||||
return dataSource.custom<Record<string, never>, CompanyResponseDTO>({
|
||||
path: "companies/current",
|
||||
method: "get",
|
||||
signal: params?.signal,
|
||||
data: {},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
import type { IDataSource } from "@erp/core/client";
|
||||
|
||||
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../../common";
|
||||
|
||||
export interface UpdateCompanySettingsParams {
|
||||
companyId: string;
|
||||
data: UpdateCompanyRequestDTO;
|
||||
}
|
||||
import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../../common";
|
||||
|
||||
export const updateCompanySettings = (
|
||||
dataSource: IDataSource,
|
||||
params: UpdateCompanySettingsParams
|
||||
params: { data: UpdateCurrentCompanyRequestDTO }
|
||||
): Promise<CompanyResponseDTO> => {
|
||||
return dataSource.updateOne<UpdateCompanyRequestDTO, CompanyResponseDTO>(
|
||||
"companies",
|
||||
params.companyId,
|
||||
params.data
|
||||
);
|
||||
return dataSource.custom<UpdateCurrentCompanyRequestDTO, CompanyResponseDTO>({
|
||||
path: "companies/current",
|
||||
method: "patch",
|
||||
data: params.data,
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,5 +2,9 @@ export interface CompanySettings {
|
||||
id: string;
|
||||
legalName: string;
|
||||
tradeName: string | null;
|
||||
tin: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
defaultPaymentTermId: string | null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
export const COMPANY_SETTINGS_QUERY_KEY = (companyId?: string) =>
|
||||
["companies", "settings", "detail", { companyId }] as const;
|
||||
export const COMPANY_SETTINGS_QUERY_KEY = () => ["companies", "settings", "current"] as const;
|
||||
|
||||
export const UPDATE_COMPANY_SETTINGS_MUTATION_KEY = [
|
||||
"companies",
|
||||
|
||||
@ -7,19 +7,14 @@ import type { CompanySettings } from "../entities";
|
||||
|
||||
import { COMPANY_SETTINGS_QUERY_KEY } from "./keys";
|
||||
|
||||
export const useCompanySettingsQuery = (
|
||||
companyId?: string
|
||||
): UseQueryResult<CompanySettings, DefaultError> => {
|
||||
export const useCompanySettingsQuery = (): UseQueryResult<CompanySettings, DefaultError> => {
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useQuery<CompanySettings, DefaultError>({
|
||||
queryKey: COMPANY_SETTINGS_QUERY_KEY(companyId),
|
||||
enabled: Boolean(companyId),
|
||||
queryKey: COMPANY_SETTINGS_QUERY_KEY(),
|
||||
enabled: true,
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getCompanySettings(dataSource, {
|
||||
companyId: companyId as string,
|
||||
signal,
|
||||
});
|
||||
const dto = await getCompanySettings(dataSource, { signal });
|
||||
|
||||
return CompanySettingsAdapter.fromDto(dto);
|
||||
},
|
||||
|
||||
@ -2,9 +2,11 @@ import { useDataSource } from "@erp/core/hooks";
|
||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { UpdateCompanyRequestSchema } from "../../../../../common";
|
||||
import {
|
||||
UpdateCurrentCompanyRequestSchema,
|
||||
type UpdateCurrentCompanyRequestDTO,
|
||||
} from "../../../../../common";
|
||||
import { CompanySettingsAdapter } from "../adapters";
|
||||
import type { UpdateCompanySettingsParams } from "../api";
|
||||
import { updateCompanySettings } from "../api";
|
||||
import type { CompanySettings } from "../entities";
|
||||
|
||||
@ -20,10 +22,10 @@ export const useCompanySettingsUpdateMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useMutation<CompanySettings, DefaultError, UpdateCompanySettingsParams>({
|
||||
return useMutation<CompanySettings, DefaultError, { data: UpdateCurrentCompanyRequestDTO }>({
|
||||
mutationKey: UPDATE_COMPANY_SETTINGS_MUTATION_KEY,
|
||||
mutationFn: async (params) => {
|
||||
const result = UpdateCompanyRequestSchema.safeParse(params.data);
|
||||
const result = UpdateCurrentCompanyRequestSchema.safeParse(params.data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error.issues));
|
||||
@ -33,7 +35,7 @@ export const useCompanySettingsUpdateMutation = () => {
|
||||
return CompanySettingsAdapter.fromDto(dto);
|
||||
},
|
||||
onSuccess: async (company) => {
|
||||
queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(company.id), company);
|
||||
queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(), company);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -0,0 +1,288 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mock, test } from "node:test";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
let capturedSubmitProps: Record<string, unknown> | undefined;
|
||||
let capturedResetProps: Record<string, unknown> | undefined;
|
||||
let capturedPageErrorProps: Record<string, unknown> | undefined;
|
||||
const capturedTextFields: Array<Record<string, unknown>> = [];
|
||||
const capturedSelectFields: Array<Record<string, unknown>> = [];
|
||||
|
||||
const buildControllerState = () => ({
|
||||
form: {
|
||||
formState: {
|
||||
isDirty: true,
|
||||
isSubmitting: false,
|
||||
},
|
||||
},
|
||||
formId: "company-details-form",
|
||||
inlineLoadError: null,
|
||||
companyQuery: {
|
||||
isError: false,
|
||||
},
|
||||
isInitialLoading: false,
|
||||
isLoading: false,
|
||||
isRetrying: false,
|
||||
isSaving: false,
|
||||
onFormKeyDown: () => undefined,
|
||||
pageError: null,
|
||||
onSubmit: () => undefined,
|
||||
resetForm: () => undefined,
|
||||
retry: () => undefined,
|
||||
paymentTermOptions: [
|
||||
{
|
||||
value: "22222222-2222-4222-8222-222222222222",
|
||||
label: "30 dias",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let controllerState = buildControllerState();
|
||||
|
||||
const resetCapturedProps = () => {
|
||||
capturedSubmitProps = undefined;
|
||||
capturedResetProps = undefined;
|
||||
capturedPageErrorProps = undefined;
|
||||
capturedTextFields.length = 0;
|
||||
capturedSelectFields.length = 0;
|
||||
};
|
||||
|
||||
mock.module("@erp/core/components", {
|
||||
namedExports: {
|
||||
ErrorAlert: ({ title, message }: { title: string; message?: string }) => (
|
||||
<div data-inline-error-title={title}>{message}</div>
|
||||
),
|
||||
PageErrorState: (props: Record<string, unknown>) => {
|
||||
capturedPageErrorProps = props;
|
||||
return <div data-page-error={String(props.variant)}>{String(props.title)}</div>;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("@erp/core/hooks", {
|
||||
namedExports: {
|
||||
UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("@repo/rdx-ui/components", {
|
||||
namedExports: {
|
||||
AppContent: ({ children }: { children?: ReactNode }) => children ?? null,
|
||||
CancelActionButton: (props: Record<string, unknown>) => {
|
||||
capturedResetProps = props;
|
||||
return null;
|
||||
},
|
||||
RdxEditorSection: ({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
}) => <section data-title={title}>{children}</section>,
|
||||
RdxEditorStickyFooter: () => null,
|
||||
RdxFormGrid: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
RhfSubmitActionButton: (props: Record<string, unknown>) => {
|
||||
capturedSubmitProps = props;
|
||||
return null;
|
||||
},
|
||||
SelectField: (props: Record<string, unknown>) => {
|
||||
capturedSelectFields.push(props);
|
||||
return <div data-field={String(props.name)}>{String(props.label)}</div>;
|
||||
},
|
||||
TextField: (props: Record<string, unknown>) => {
|
||||
capturedTextFields.push(props);
|
||||
return <div data-field={String(props.name)}>{String(props.label)}</div>;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("react-hook-form", {
|
||||
namedExports: {
|
||||
FormProvider: ({ children }: { children: ReactNode }) => children,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../../i18n", {
|
||||
namedExports: {
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
(
|
||||
{
|
||||
"settings.companyDetails.title": "Datos de empresa",
|
||||
"settings.companyDetails.loading": "Cargando datos de la empresa activa...",
|
||||
"settings.companyDetails.description":
|
||||
"Configura los datos generales, de contacto y el vencimiento por defecto de la empresa activa.",
|
||||
"settings.companyDetails.sections.general.title": "Datos generales",
|
||||
"settings.companyDetails.sections.general.description":
|
||||
"Actualiza la identificacion principal de la empresa activa.",
|
||||
"settings.companyDetails.sections.general.fields.legalName.label": "Razon social",
|
||||
"settings.companyDetails.sections.general.fields.legalName.description":
|
||||
"Es el nombre legal principal de la empresa.",
|
||||
"settings.companyDetails.sections.general.fields.legalName.placeholder":
|
||||
"Introduce la razon social",
|
||||
"settings.companyDetails.sections.general.fields.tradeName.label": "Nombre comercial",
|
||||
"settings.companyDetails.sections.general.fields.tradeName.description":
|
||||
"Se usa cuando la empresa opera con una denominacion distinta de la razon social.",
|
||||
"settings.companyDetails.sections.general.fields.tradeName.placeholder":
|
||||
"Introduce el nombre comercial",
|
||||
"settings.companyDetails.sections.general.fields.tin.label": "NIF/CIF/TIN",
|
||||
"settings.companyDetails.sections.general.fields.tin.description":
|
||||
"Identificador fiscal de la empresa activa.",
|
||||
"settings.companyDetails.sections.general.fields.tin.placeholder":
|
||||
"Introduce el NIF/CIF/TIN",
|
||||
"settings.companyDetails.sections.contact.title": "Datos de contacto",
|
||||
"settings.companyDetails.sections.contact.description":
|
||||
"Manten actualizados los canales de contacto visibles de la empresa activa.",
|
||||
"settings.companyDetails.sections.contact.fields.email.label": "Email",
|
||||
"settings.companyDetails.sections.contact.fields.email.description":
|
||||
"Correo principal de contacto de la empresa.",
|
||||
"settings.companyDetails.sections.contact.fields.email.placeholder":
|
||||
"nombre@empresa.com",
|
||||
"settings.companyDetails.sections.contact.fields.phone.label": "Telefono",
|
||||
"settings.companyDetails.sections.contact.fields.phone.description":
|
||||
"Telefono principal de contacto de la empresa.",
|
||||
"settings.companyDetails.sections.contact.fields.phone.placeholder":
|
||||
"Introduce el telefono",
|
||||
"settings.companyDetails.sections.contact.fields.website.label": "Web",
|
||||
"settings.companyDetails.sections.contact.fields.website.description":
|
||||
"Sitio web publico de la empresa.",
|
||||
"settings.companyDetails.sections.contact.fields.website.placeholder":
|
||||
"https://empresa.com",
|
||||
"settings.companyDetails.sections.paymentTerm.title": "Condiciones comerciales",
|
||||
"settings.companyDetails.sections.paymentTerm.description":
|
||||
"Este vencimiento se usara como valor inicial en futuras configuraciones de la empresa activa.",
|
||||
"settings.companyDetails.sections.paymentTerm.field.label": "Vencimiento por defecto",
|
||||
"settings.companyDetails.sections.paymentTerm.field.description":
|
||||
"Debe pertenecer a la empresa activa y estar activo para nuevas asignaciones.",
|
||||
"settings.companyDetails.sections.paymentTerm.field.placeholder":
|
||||
"Selecciona un vencimiento",
|
||||
"settings.companyDetails.sections.paymentTerm.field.none": "Sin vencimiento por defecto",
|
||||
"settings.companyDetails.actions.save": "Guardar cambios",
|
||||
"settings.companyDetails.actions.saving": "Guardando...",
|
||||
"settings.companyDetails.actions.reset": "Restablecer",
|
||||
"settings.companyDetails.feedback.idle": "Sin cambios pendientes.",
|
||||
"settings.companyDetails.feedback.saving": "Guardando configuracion de empresa.",
|
||||
"settings.companyDetails.pageError.actions.retry": "Reintentar",
|
||||
"settings.companyDetails.pageError.actions.retrying": "Reintentando...",
|
||||
} as Record<string, string>
|
||||
)[key] ?? key,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../controllers", {
|
||||
namedExports: {
|
||||
useCompanyDetailsSettingsController: () => controllerState,
|
||||
},
|
||||
});
|
||||
|
||||
test("renderiza las tres secciones, los siete campos y el footer con el estado esperado", async () => {
|
||||
controllerState = buildControllerState();
|
||||
resetCapturedProps();
|
||||
|
||||
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
|
||||
const html = renderToStaticMarkup(<CompanyDetailsSettingsPage />);
|
||||
|
||||
assert.match(html, /Datos generales/);
|
||||
assert.match(html, /Datos de contacto/);
|
||||
assert.match(html, /Condiciones comerciales/);
|
||||
|
||||
assert.deepEqual(
|
||||
capturedTextFields.map((field) => field.name),
|
||||
["legalName", "tradeName", "tin", "email", "phone", "website"]
|
||||
);
|
||||
assert.equal(capturedTextFields[0]?.required, true);
|
||||
assert.equal(capturedTextFields[3]?.typePreset, "email");
|
||||
assert.equal(capturedTextFields[4]?.typePreset, "phone");
|
||||
assert.equal(capturedTextFields[5]?.typePreset, "url");
|
||||
|
||||
assert.equal(capturedSelectFields.length, 1);
|
||||
assert.equal(capturedSelectFields[0]?.name, "defaultPaymentTermId");
|
||||
assert.equal(capturedSelectFields[0]?.clearLabel, "Sin vencimiento por defecto");
|
||||
|
||||
assert.equal(capturedSubmitProps?.disabled, false);
|
||||
assert.equal(capturedResetProps?.disabled, false);
|
||||
});
|
||||
|
||||
test("muestra un estado de carga accesible mientras carga por primera vez", async () => {
|
||||
controllerState = {
|
||||
...buildControllerState(),
|
||||
isInitialLoading: true,
|
||||
isLoading: true,
|
||||
};
|
||||
resetCapturedProps();
|
||||
|
||||
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
|
||||
const html = renderToStaticMarkup(<CompanyDetailsSettingsPage />);
|
||||
|
||||
assert.match(html, /Cargando datos de la empresa activa/);
|
||||
assert.equal(capturedTextFields.length, 0);
|
||||
assert.equal(capturedPageErrorProps, undefined);
|
||||
});
|
||||
|
||||
test("renderiza PageErrorState con retry cuando existe error bloqueante recuperable", async () => {
|
||||
controllerState = {
|
||||
...buildControllerState(),
|
||||
isRetrying: true,
|
||||
pageError: {
|
||||
variant: "network",
|
||||
title: "No se pudo conectar con el servidor",
|
||||
description: "Comprueba tu conexion e intentalo de nuevo.",
|
||||
canRetry: true,
|
||||
reference: "corr-123",
|
||||
},
|
||||
};
|
||||
resetCapturedProps();
|
||||
|
||||
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
|
||||
const html = renderToStaticMarkup(<CompanyDetailsSettingsPage />);
|
||||
|
||||
assert.match(html, /No se pudo conectar con el servidor/);
|
||||
assert.equal(capturedPageErrorProps?.variant, "network");
|
||||
assert.equal(
|
||||
(capturedPageErrorProps?.primaryAction as { label?: string } | undefined)?.label,
|
||||
"Reintentando..."
|
||||
);
|
||||
assert.equal(capturedPageErrorProps?.reference, "corr-123");
|
||||
});
|
||||
|
||||
test("no muestra retry cuando el error bloqueante no permite reintento", async () => {
|
||||
controllerState = {
|
||||
...buildControllerState(),
|
||||
pageError: {
|
||||
variant: "forbidden",
|
||||
title: "No tienes acceso a la empresa activa",
|
||||
description: "No dispones de permisos para consultar esta configuracion.",
|
||||
canRetry: false,
|
||||
},
|
||||
};
|
||||
resetCapturedProps();
|
||||
|
||||
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
|
||||
renderToStaticMarkup(<CompanyDetailsSettingsPage />);
|
||||
|
||||
assert.equal(capturedPageErrorProps?.variant, "forbidden");
|
||||
assert.equal(capturedPageErrorProps?.primaryAction, undefined);
|
||||
});
|
||||
|
||||
test("deshabilita las acciones durante el guardado", async () => {
|
||||
controllerState = {
|
||||
...buildControllerState(),
|
||||
isSaving: true,
|
||||
form: {
|
||||
formState: {
|
||||
isDirty: true,
|
||||
isSubmitting: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
resetCapturedProps();
|
||||
|
||||
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
|
||||
renderToStaticMarkup(<CompanyDetailsSettingsPage />);
|
||||
|
||||
assert.equal(capturedSubmitProps?.disabled, true);
|
||||
assert.equal(capturedSubmitProps?.isBusy, true);
|
||||
assert.equal(capturedResetProps?.disabled, true);
|
||||
});
|
||||
@ -8,36 +8,55 @@ import {
|
||||
RdxFormGrid,
|
||||
RhfSubmitActionButton,
|
||||
SelectField,
|
||||
TextField,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { LandmarkIcon } from "lucide-react";
|
||||
import { ContactIcon, LandmarkIcon, ScrollTextIcon } from "lucide-react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import { useCompanyDetailsSettingsController } from "../../controllers";
|
||||
import type { CompanySettingsForm } from "../../entities";
|
||||
|
||||
export const CompanyDetailsSettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const ctrl = useCompanyDetailsSettingsController();
|
||||
|
||||
if (ctrl.isLoading) {
|
||||
return <AppContent className="py-2" />;
|
||||
if (ctrl.isInitialLoading) {
|
||||
return (
|
||||
<AppContent className="py-2">
|
||||
<div
|
||||
aria-busy="true"
|
||||
className="flex min-h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
{t("settings.companyDetails.loading")}
|
||||
</div>
|
||||
</AppContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (ctrl.pageError) {
|
||||
return (
|
||||
<AppContent className="py-2">
|
||||
<PageErrorState
|
||||
description={t("settings.companyDetails.pageError.description")}
|
||||
primaryAction={{
|
||||
label: ctrl.isRetrying
|
||||
? t("settings.companyDetails.pageError.retrying")
|
||||
: t("settings.companyDetails.pageError.retry"),
|
||||
onClick: ctrl.retry,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}}
|
||||
title={t("settings.companyDetails.pageError.title")}
|
||||
variant="server"
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PageErrorState
|
||||
description={ctrl.pageError.description}
|
||||
primaryAction={
|
||||
ctrl.pageError.canRetry
|
||||
? {
|
||||
label: ctrl.isRetrying
|
||||
? t("settings.companyDetails.pageError.actions.retrying")
|
||||
: t("settings.companyDetails.pageError.actions.retry"),
|
||||
onClick: ctrl.retry,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
reference={ctrl.pageError.reference}
|
||||
title={ctrl.pageError.title}
|
||||
variant={ctrl.pageError.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppContent>
|
||||
);
|
||||
}
|
||||
@ -55,14 +74,110 @@ export const CompanyDetailsSettingsPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{ctrl.companyQuery.isError ? (
|
||||
{ctrl.inlineLoadError ? (
|
||||
<ErrorAlert
|
||||
message={(ctrl.companyQuery.error as Error)?.message ?? undefined}
|
||||
title={t("settings.companyDetails.feedback.error.title")}
|
||||
message={ctrl.inlineLoadError.description}
|
||||
title={ctrl.inlineLoadError.title}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form className="flex flex-col gap-6" id={ctrl.formId} noValidate onSubmit={ctrl.onSubmit}>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={ctrl.formId}
|
||||
noValidate
|
||||
onKeyDown={ctrl.onFormKeyDown}
|
||||
onSubmit={ctrl.onSubmit}
|
||||
>
|
||||
<RdxEditorSection
|
||||
description={t("settings.companyDetails.sections.general.description")}
|
||||
icon={<ScrollTextIcon className="size-5" />}
|
||||
title={t("settings.companyDetails.sections.general.title")}
|
||||
variant="panel"
|
||||
>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<TextField<CompanySettingsForm>
|
||||
className="col-span-full"
|
||||
description={t(
|
||||
"settings.companyDetails.sections.general.fields.legalName.description"
|
||||
)}
|
||||
disabled={ctrl.isSaving}
|
||||
inputClassName="bg-background"
|
||||
label={t("settings.companyDetails.sections.general.fields.legalName.label")}
|
||||
name="legalName"
|
||||
placeholder={t(
|
||||
"settings.companyDetails.sections.general.fields.legalName.placeholder"
|
||||
)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField<CompanySettingsForm>
|
||||
description={t(
|
||||
"settings.companyDetails.sections.general.fields.tradeName.description"
|
||||
)}
|
||||
disabled={ctrl.isSaving}
|
||||
inputClassName="bg-background"
|
||||
label={t("settings.companyDetails.sections.general.fields.tradeName.label")}
|
||||
name="tradeName"
|
||||
placeholder={t(
|
||||
"settings.companyDetails.sections.general.fields.tradeName.placeholder"
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField<CompanySettingsForm>
|
||||
description={t("settings.companyDetails.sections.general.fields.tin.description")}
|
||||
disabled={ctrl.isSaving}
|
||||
inputClassName="bg-background"
|
||||
label={t("settings.companyDetails.sections.general.fields.tin.label")}
|
||||
name="tin"
|
||||
placeholder={t("settings.companyDetails.sections.general.fields.tin.placeholder")}
|
||||
/>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
|
||||
<RdxEditorSection
|
||||
description={t("settings.companyDetails.sections.contact.description")}
|
||||
icon={<ContactIcon className="size-5" />}
|
||||
title={t("settings.companyDetails.sections.contact.title")}
|
||||
variant="panel"
|
||||
>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<TextField<CompanySettingsForm>
|
||||
description={t("settings.companyDetails.sections.contact.fields.email.description")}
|
||||
disabled={ctrl.isSaving}
|
||||
inputClassName="bg-background"
|
||||
label={t("settings.companyDetails.sections.contact.fields.email.label")}
|
||||
name="email"
|
||||
placeholder={t("settings.companyDetails.sections.contact.fields.email.placeholder")}
|
||||
typePreset="email"
|
||||
/>
|
||||
|
||||
<TextField<CompanySettingsForm>
|
||||
description={t("settings.companyDetails.sections.contact.fields.phone.description")}
|
||||
disabled={ctrl.isSaving}
|
||||
inputClassName="bg-background"
|
||||
label={t("settings.companyDetails.sections.contact.fields.phone.label")}
|
||||
name="phone"
|
||||
placeholder={t("settings.companyDetails.sections.contact.fields.phone.placeholder")}
|
||||
typePreset="phone"
|
||||
/>
|
||||
|
||||
<TextField<CompanySettingsForm>
|
||||
className="col-span-full"
|
||||
description={t(
|
||||
"settings.companyDetails.sections.contact.fields.website.description"
|
||||
)}
|
||||
disabled={ctrl.isSaving}
|
||||
inputClassName="bg-background"
|
||||
label={t("settings.companyDetails.sections.contact.fields.website.label")}
|
||||
name="website"
|
||||
placeholder={t(
|
||||
"settings.companyDetails.sections.contact.fields.website.placeholder"
|
||||
)}
|
||||
typePreset="url"
|
||||
/>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
|
||||
<RdxEditorSection
|
||||
description={t("settings.companyDetails.sections.paymentTerm.description")}
|
||||
icon={<LandmarkIcon className="size-5" />}
|
||||
@ -70,12 +185,14 @@ export const CompanyDetailsSettingsPage = () => {
|
||||
variant="panel"
|
||||
>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<SelectField
|
||||
<SelectField<CompanySettingsForm>
|
||||
clearable
|
||||
clearLabel={t("settings.companyDetails.sections.paymentTerm.field.none")}
|
||||
className="col-span-full"
|
||||
description={t("settings.companyDetails.sections.paymentTerm.field.description")}
|
||||
disabled={ctrl.isSaving}
|
||||
emptyValue=""
|
||||
inputClassName="bg-background"
|
||||
items={ctrl.paymentTermOptions}
|
||||
label={t("settings.companyDetails.sections.paymentTerm.field.label")}
|
||||
name="defaultPaymentTermId"
|
||||
@ -95,7 +212,7 @@ export const CompanyDetailsSettingsPage = () => {
|
||||
primary={
|
||||
<RhfSubmitActionButton
|
||||
busyLabel={t("settings.companyDetails.actions.saving")}
|
||||
disabled={ctrl.isSaving}
|
||||
disabled={ctrl.isSaving || !ctrl.form.formState.isDirty}
|
||||
formId={ctrl.formId}
|
||||
isBusy={ctrl.form.formState.isSubmitting}
|
||||
label={t("settings.companyDetails.actions.save")}
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ApiClientError } from "@erp/core/client";
|
||||
|
||||
import { buildCompanyDetailsSettingsPageErrorViewModel } from "./build-company-details-settings-page-error-view-model";
|
||||
|
||||
const translate = (key: string) =>
|
||||
({
|
||||
"settings.companyDetails.pageError.notFound.title": "No se encontro la empresa activa",
|
||||
"settings.companyDetails.pageError.notFound.description":
|
||||
"La empresa seleccionada ya no existe o no esta disponible.",
|
||||
"settings.companyDetails.pageError.forbidden.title":
|
||||
"No tienes acceso a la empresa activa",
|
||||
"settings.companyDetails.pageError.forbidden.description":
|
||||
"No dispones de permisos para consultar esta configuracion.",
|
||||
"settings.companyDetails.pageError.network.title":
|
||||
"No se pudo conectar con el servidor",
|
||||
"settings.companyDetails.pageError.network.description":
|
||||
"Comprueba tu conexion e intentalo de nuevo.",
|
||||
"settings.companyDetails.pageError.timeout.title": "La carga esta tardando demasiado",
|
||||
"settings.companyDetails.pageError.timeout.description":
|
||||
"El servidor no respondio dentro del tiempo esperado.",
|
||||
"settings.companyDetails.pageError.server.title": "No se pudo cargar la empresa activa",
|
||||
"settings.companyDetails.pageError.server.description":
|
||||
"Se produjo un error interno al recuperar la configuracion de empresa.",
|
||||
"settings.companyDetails.pageError.unavailable.title":
|
||||
"Servicio temporalmente no disponible",
|
||||
"settings.companyDetails.pageError.unavailable.description":
|
||||
"El servicio de empresas no esta disponible en este momento.",
|
||||
"settings.companyDetails.pageError.unknown.title": "No se pudo cargar la empresa activa",
|
||||
"settings.companyDetails.pageError.unknown.description":
|
||||
"Ha ocurrido un error inesperado al cargar la configuracion de empresa.",
|
||||
})[key] ?? key;
|
||||
|
||||
test("mapea notFound real a notFound sin retry", () => {
|
||||
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
|
||||
new ApiClientError({ kind: "notFound", message: "technical", correlationId: "corr-404" }),
|
||||
translate
|
||||
);
|
||||
|
||||
assert.equal(viewModel?.variant, "notFound");
|
||||
assert.equal(viewModel?.canRetry, false);
|
||||
assert.equal(viewModel?.reference, "corr-404");
|
||||
});
|
||||
|
||||
test("mapea forbidden sin retry", () => {
|
||||
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
|
||||
new ApiClientError({ kind: "forbidden", message: "technical" }),
|
||||
translate
|
||||
);
|
||||
|
||||
assert.equal(viewModel?.variant, "forbidden");
|
||||
assert.equal(viewModel?.canRetry, false);
|
||||
});
|
||||
|
||||
test("mapea network, timeout, server, unavailable y unknown con retry", () => {
|
||||
for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) {
|
||||
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
|
||||
new ApiClientError({ kind, message: "technical" }),
|
||||
translate
|
||||
);
|
||||
|
||||
assert.equal(viewModel?.variant, kind);
|
||||
assert.equal(viewModel?.canRetry, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ignora cancelled", () => {
|
||||
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
|
||||
new ApiClientError({ kind: "cancelled", message: "technical" }),
|
||||
translate
|
||||
);
|
||||
|
||||
assert.equal(viewModel, null);
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
import { isApiClientError } from "@erp/core/client";
|
||||
|
||||
import type { CompanyDetailsSettingsPageErrorViewModel } from "../entities";
|
||||
|
||||
interface Translate {
|
||||
(key: string): string;
|
||||
}
|
||||
|
||||
const buildErrorVariant = (
|
||||
error: unknown
|
||||
): CompanyDetailsSettingsPageErrorViewModel["variant"] | "cancelled" => {
|
||||
if (!isApiClientError(error)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
switch (error.kind) {
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "notFound":
|
||||
return "notFound";
|
||||
case "forbidden":
|
||||
return "forbidden";
|
||||
case "network":
|
||||
return "network";
|
||||
case "timeout":
|
||||
return "timeout";
|
||||
case "server":
|
||||
return "server";
|
||||
case "unavailable":
|
||||
return "unavailable";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
export const buildCompanyDetailsSettingsPageErrorViewModel = (
|
||||
error: unknown,
|
||||
translate: Translate
|
||||
): CompanyDetailsSettingsPageErrorViewModel | null => {
|
||||
const variant = buildErrorVariant(error);
|
||||
|
||||
if (variant === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
variant,
|
||||
title: translate(`settings.companyDetails.pageError.${variant}.title`),
|
||||
description: translate(`settings.companyDetails.pageError.${variant}.description`),
|
||||
canRetry: !["forbidden", "notFound"].includes(variant),
|
||||
reference: isApiClientError(error) ? error.correlationId : undefined,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CompanySettings } from "../shared/entities";
|
||||
|
||||
import { buildCompanySettingsForm } from "./build-company-settings-form";
|
||||
|
||||
test('convierte null del Entity a "" en el Form para campos nullable', () => {
|
||||
const company: CompanySettings = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
legalName: "Acme Legal SL",
|
||||
tradeName: null,
|
||||
tin: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
website: null,
|
||||
defaultPaymentTermId: null,
|
||||
};
|
||||
|
||||
assert.deepEqual(buildCompanySettingsForm(company), {
|
||||
legalName: "Acme Legal SL",
|
||||
tradeName: "",
|
||||
tin: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
defaultPaymentTermId: "",
|
||||
});
|
||||
});
|
||||
@ -2,5 +2,11 @@ import type { CompanySettingsForm } from "../entities";
|
||||
import type { CompanySettings } from "../shared/entities";
|
||||
|
||||
export const buildCompanySettingsForm = (company: CompanySettings): CompanySettingsForm => ({
|
||||
defaultPaymentTermId: company.defaultPaymentTermId,
|
||||
legalName: company.legalName,
|
||||
tradeName: company.tradeName ?? "",
|
||||
tin: company.tin ?? "",
|
||||
email: company.email ?? "",
|
||||
phone: company.phone ?? "",
|
||||
website: company.website ?? "",
|
||||
defaultPaymentTermId: company.defaultPaymentTermId ?? "",
|
||||
});
|
||||
|
||||
@ -3,43 +3,187 @@ import test from "node:test";
|
||||
|
||||
import { buildUpdateCompanySettingsParams } from "./build-update-company-settings-params";
|
||||
|
||||
test("normaliza vacio a null", () => {
|
||||
const buildForm = () => ({
|
||||
legalName: "Acme Legal SL",
|
||||
tradeName: "Acme Trade",
|
||||
tin: "B12345678",
|
||||
email: "info@acme.test",
|
||||
phone: "+34 900 000 000",
|
||||
website: "https://acme.test",
|
||||
defaultPaymentTermId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
|
||||
test("devuelve PATCH vacío sin dirty fields", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: buildForm(),
|
||||
dirtyFields: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { data: {} });
|
||||
});
|
||||
|
||||
test("envía solo legal_name cuando legalName es el único campo dirty", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
form: {
|
||||
defaultPaymentTermId: "",
|
||||
...buildForm(),
|
||||
legalName: " New Legal Name ",
|
||||
},
|
||||
dirtyFields: {
|
||||
legalName: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
legal_name: "New Legal Name",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("omite un campo nullable no dirty aunque tenga valor en el form", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: buildForm(),
|
||||
dirtyFields: {
|
||||
legalName: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal("trade_name" in result.data, false);
|
||||
assert.equal("tin" in result.data, false);
|
||||
assert.equal("email" in result.data, false);
|
||||
assert.equal("phone" in result.data, false);
|
||||
assert.equal("website" in result.data, false);
|
||||
assert.equal("default_payment_term_id" in result.data, false);
|
||||
});
|
||||
|
||||
test("convierte tradeName vacío dirty a null", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: {
|
||||
...buildForm(),
|
||||
tradeName: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
tradeName: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
trade_name: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("convierte tin vacío dirty a null", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: {
|
||||
...buildForm(),
|
||||
tin: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
tin: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
tin: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("convierte email vacío dirty a null", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: {
|
||||
...buildForm(),
|
||||
email: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
email: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("convierte phone vacío dirty a null", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: {
|
||||
...buildForm(),
|
||||
phone: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
phone: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
phone: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("convierte website vacío dirty a null", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: {
|
||||
...buildForm(),
|
||||
website: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
website: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
website: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("convierte defaultPaymentTermId vacío dirty a null", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
form: {
|
||||
...buildForm(),
|
||||
defaultPaymentTermId: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
defaultPaymentTermId: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
data: {
|
||||
default_payment_term_id: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("conserva null explicito", () => {
|
||||
test("envía varios campos dirty y omite el resto", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
form: {
|
||||
defaultPaymentTermId: null,
|
||||
...buildForm(),
|
||||
legalName: " Acme New Legal ",
|
||||
phone: " +34 911 111 111 ",
|
||||
website: "",
|
||||
},
|
||||
dirtyFields: {
|
||||
legalName: true,
|
||||
phone: true,
|
||||
website: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.data.default_payment_term_id, null);
|
||||
});
|
||||
|
||||
test("envia la seleccion valida", () => {
|
||||
const result = buildUpdateCompanySettingsParams({
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
form: {
|
||||
defaultPaymentTermId: "22222222-2222-4222-8222-222222222222",
|
||||
assert.deepEqual(result, {
|
||||
data: {
|
||||
legal_name: "Acme New Legal",
|
||||
phone: "+34 911 111 111",
|
||||
website: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.data.default_payment_term_id,
|
||||
"22222222-2222-4222-8222-222222222222"
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,20 +1,54 @@
|
||||
import type { UpdateCompanyRequestDTO } from "../../../../../common";
|
||||
import type { UpdateCurrentCompanyRequestDTO } from "../../../../common";
|
||||
import type { CompanySettingsForm } from "../entities";
|
||||
import type { FieldNamesMarkedBoolean } from "react-hook-form";
|
||||
|
||||
export const buildUpdateCompanySettingsParams = (params: {
|
||||
companyId: string;
|
||||
form: CompanySettingsForm;
|
||||
dirtyFields: FieldNamesMarkedBoolean<CompanySettingsForm>;
|
||||
}): {
|
||||
companyId: string;
|
||||
data: UpdateCompanyRequestDTO;
|
||||
data: UpdateCurrentCompanyRequestDTO;
|
||||
} => {
|
||||
const data: UpdateCurrentCompanyRequestDTO = {};
|
||||
|
||||
if (params.dirtyFields.legalName) {
|
||||
data.legal_name = toRequiredText(params.form.legalName);
|
||||
}
|
||||
|
||||
if (params.dirtyFields.tradeName) {
|
||||
data.trade_name = toNullableText(params.form.tradeName);
|
||||
}
|
||||
|
||||
if (params.dirtyFields.tin) {
|
||||
data.tin = toNullableText(params.form.tin);
|
||||
}
|
||||
|
||||
if (params.dirtyFields.email) {
|
||||
data.email = toNullableText(params.form.email);
|
||||
}
|
||||
|
||||
if (params.dirtyFields.phone) {
|
||||
data.phone = toNullableText(params.form.phone);
|
||||
}
|
||||
|
||||
if (params.dirtyFields.website) {
|
||||
data.website = toNullableText(params.form.website);
|
||||
}
|
||||
|
||||
if (params.dirtyFields.defaultPaymentTermId) {
|
||||
data.default_payment_term_id = toNullableText(params.form.defaultPaymentTermId);
|
||||
}
|
||||
|
||||
return {
|
||||
companyId: params.companyId,
|
||||
data: {
|
||||
default_payment_term_id:
|
||||
params.form.defaultPaymentTermId == null || params.form.defaultPaymentTermId === ""
|
||||
? null
|
||||
: params.form.defaultPaymentTermId,
|
||||
},
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const toRequiredText = (value: string): string => {
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const toNullableText = (value: string): string | null => {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
return trimmedValue === "" ? null : trimmedValue;
|
||||
};
|
||||
|
||||
@ -1,2 +1,5 @@
|
||||
export * from "./build-company-details-settings-page-error-view-model";
|
||||
export * from "./build-company-settings-form";
|
||||
export * from "./build-update-company-settings-params";
|
||||
export * from "./normalize-company-details-submit-error";
|
||||
export * from "./resolve-company-details-settings-feedback-state";
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ApiClientError } from "@erp/core/client";
|
||||
import { ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
|
||||
|
||||
import { normalizeCompanyDetailsSubmitError } from "./normalize-company-details-submit-error";
|
||||
|
||||
test("mantiene ValidationErrorCollection sin cambios", () => {
|
||||
const error = new ValidationErrorCollection("Validation failed", [
|
||||
{
|
||||
path: "legalName",
|
||||
message: "Required",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(normalizeCompanyDetailsSubmitError(error), error);
|
||||
});
|
||||
|
||||
test("convierte ApiClientError validation a ValidationErrorCollection mapeando paths snake_case", () => {
|
||||
const error = new ApiClientError({
|
||||
kind: "validation",
|
||||
message: "technical",
|
||||
issues: [
|
||||
{
|
||||
path: ["legal_name"],
|
||||
message: "Required",
|
||||
},
|
||||
{
|
||||
path: ["default_payment_term_id"],
|
||||
message: "Not assignable",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const normalized = normalizeCompanyDetailsSubmitError(error);
|
||||
|
||||
assert.equal(isValidationErrorCollection(normalized), true);
|
||||
assert.deepEqual(
|
||||
(normalized as ValidationErrorCollection).details.map((detail) => detail.path),
|
||||
["legalName", "defaultPaymentTermId"]
|
||||
);
|
||||
});
|
||||
|
||||
test("mantiene ApiClientError no validation como error general", () => {
|
||||
const error = new ApiClientError({
|
||||
kind: "server",
|
||||
message: "technical",
|
||||
});
|
||||
|
||||
assert.equal(normalizeCompanyDetailsSubmitError(error), error);
|
||||
});
|
||||
|
||||
test("normaliza valores desconocidos a Error", () => {
|
||||
const normalized = normalizeCompanyDetailsSubmitError("boom");
|
||||
|
||||
assert.equal(normalized instanceof Error, true);
|
||||
assert.equal(normalized.message, "Unknown error");
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { isApiClientError } from "@erp/core/client";
|
||||
import {
|
||||
ValidationErrorCollection,
|
||||
type ValidationErrorDetail,
|
||||
isValidationErrorCollection,
|
||||
} from "@repo/rdx-ddd";
|
||||
|
||||
const COMPANY_SETTINGS_FIELD_PATHS: Record<string, string> = {
|
||||
legal_name: "legalName",
|
||||
trade_name: "tradeName",
|
||||
tin: "tin",
|
||||
email: "email",
|
||||
phone: "phone",
|
||||
website: "website",
|
||||
default_payment_term_id: "defaultPaymentTermId",
|
||||
};
|
||||
|
||||
const mapCompanySettingsValidationPath = (path: readonly (string | number)[] | undefined) => {
|
||||
if (!path || path.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedPath = path.map(String).join(".");
|
||||
return COMPANY_SETTINGS_FIELD_PATHS[normalizedPath] ?? normalizedPath;
|
||||
};
|
||||
|
||||
const toValidationErrorCollection = (error: unknown): ValidationErrorCollection | null => {
|
||||
if (!isApiClientError(error) || error.kind !== "validation") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = error.issues.reduce<ValidationErrorDetail[]>((acc, issue) => {
|
||||
const message = issue.message?.trim();
|
||||
|
||||
if (!message) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
path: mapCompanySettingsValidationPath(issue.path),
|
||||
message,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (details.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ValidationErrorCollection("Validation failed", details);
|
||||
};
|
||||
|
||||
export const normalizeCompanyDetailsSubmitError = (
|
||||
error: unknown
|
||||
): Error | ValidationErrorCollection => {
|
||||
if (isValidationErrorCollection(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const validationError = toValidationErrorCollection(error);
|
||||
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return new Error("Unknown error");
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ApiClientError } from "@erp/core/client";
|
||||
|
||||
import { resolveCompanyDetailsSettingsFeedbackState } from "./resolve-company-details-settings-feedback-state";
|
||||
|
||||
test("mantiene loading inicial mientras carga", () => {
|
||||
const state = resolveCompanyDetailsSettingsFeedbackState({
|
||||
isLoading: true,
|
||||
isLoadError: false,
|
||||
hasRequiredData: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
assert.equal(state.isInitialLoading, true);
|
||||
assert.equal(state.hasBlockingError, false);
|
||||
assert.equal(state.hasInlineError, false);
|
||||
});
|
||||
|
||||
test("marca error bloqueante cuando falla sin datos requeridos", () => {
|
||||
const state = resolveCompanyDetailsSettingsFeedbackState({
|
||||
isLoading: false,
|
||||
isLoadError: true,
|
||||
hasRequiredData: false,
|
||||
error: new ApiClientError({ kind: "server", message: "technical" }),
|
||||
});
|
||||
|
||||
assert.equal(state.hasBlockingError, true);
|
||||
assert.equal(state.hasInlineError, false);
|
||||
});
|
||||
|
||||
test("mantiene error inline cuando falla un refetch con datos previos utilizables", () => {
|
||||
const state = resolveCompanyDetailsSettingsFeedbackState({
|
||||
isLoading: false,
|
||||
isLoadError: true,
|
||||
hasRequiredData: true,
|
||||
error: new ApiClientError({ kind: "network", message: "technical" }),
|
||||
});
|
||||
|
||||
assert.equal(state.isInitialLoading, false);
|
||||
assert.equal(state.hasBlockingError, false);
|
||||
assert.equal(state.hasInlineError, true);
|
||||
});
|
||||
|
||||
test("no muestra pantalla bloqueante para cancelled sin datos", () => {
|
||||
const state = resolveCompanyDetailsSettingsFeedbackState({
|
||||
isLoading: false,
|
||||
isLoadError: true,
|
||||
hasRequiredData: false,
|
||||
error: new ApiClientError({ kind: "cancelled", message: "technical" }),
|
||||
});
|
||||
|
||||
assert.equal(state.isInitialLoading, true);
|
||||
assert.equal(state.hasBlockingError, false);
|
||||
assert.equal(state.hasInlineError, false);
|
||||
});
|
||||
@ -0,0 +1,29 @@
|
||||
import { isApiClientError } from "@erp/core/client";
|
||||
|
||||
interface ResolveCompanyDetailsSettingsFeedbackStateParams {
|
||||
isLoading: boolean;
|
||||
isLoadError: boolean;
|
||||
hasRequiredData: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface CompanyDetailsSettingsFeedbackState {
|
||||
isInitialLoading: boolean;
|
||||
hasBlockingError: boolean;
|
||||
hasInlineError: boolean;
|
||||
}
|
||||
|
||||
export const resolveCompanyDetailsSettingsFeedbackState = ({
|
||||
isLoading,
|
||||
isLoadError,
|
||||
hasRequiredData,
|
||||
error,
|
||||
}: ResolveCompanyDetailsSettingsFeedbackStateParams): CompanyDetailsSettingsFeedbackState => {
|
||||
const isCancelled = isApiClientError(error) && error.kind === "cancelled";
|
||||
|
||||
return {
|
||||
isInitialLoading: isLoading || (isLoadError && isCancelled && !hasRequiredData),
|
||||
hasBlockingError: isLoadError && !isCancelled && !hasRequiredData,
|
||||
hasInlineError: isLoadError && !isCancelled && hasRequiredData,
|
||||
};
|
||||
};
|
||||
@ -92,7 +92,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await dataSource.getOne<CompanyResponseDTO>(
|
||||
"companies",
|
||||
activeCompany?.id ?? "",
|
||||
"current",
|
||||
{
|
||||
signal,
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ export const useUpdateProformaCustomerChangeController = (
|
||||
queryKey: buildCompanySettingsQueryKey(companyId),
|
||||
enabled: Boolean(companyId),
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await dataSource.getOne<CompanyResponseDTO>("companies", companyId as string, {
|
||||
const dto = await dataSource.getOne<CompanyResponseDTO>("companies", "current", {
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@ -64,6 +64,10 @@ Las restricciones detalladas sobre claims y contexto tenant se documentan en `te
|
||||
|
||||
Los contratos públicos de `requireIdentityAuthenticated(params)` y `requireIdentityTenant(params)` se documentan en `tenant-context.md`.
|
||||
|
||||
En la V1 actual, `requireIdentityTenant(params)` y `companyAccess` resuelven membership activa y company activa.
|
||||
|
||||
No debe interpretarse como enforcement de permisos granulares o RBAC.
|
||||
|
||||
Código backend nuevo no debe usar:
|
||||
|
||||
- `@erp/auth/api`
|
||||
@ -90,6 +94,9 @@ Responsabilidad:
|
||||
|
||||
- `identity` decide qué `companyId` puede usar una cuenta según memberships activas.
|
||||
- no devuelve la ficha completa de empresa.
|
||||
- no decide todavía permisos granulares por operación.
|
||||
|
||||
Esto representa membership activa y accesibilidad tenant, no permisos por operación.
|
||||
|
||||
## Tenant context
|
||||
|
||||
@ -100,6 +107,14 @@ Responsabilidad:
|
||||
|
||||
La validación esperada, la semántica de errores y el shape mínimo de `req.user` se mantienen en `tenant-context.md`.
|
||||
|
||||
Ejemplo actual relevante:
|
||||
|
||||
```http
|
||||
PATCH /companies/current
|
||||
```
|
||||
|
||||
usa membership activa y contexto tenant válido, no `identity:companies:update`.
|
||||
|
||||
## Reglas de mantenimiento
|
||||
|
||||
- Backend nuevo: usar `@erp/identity/api`.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
|
||||
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
|
||||
|
||||
import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
|
||||
|
||||
export interface IIdentityPublicServices {
|
||||
auth: IIdentityAuthPublicServices;
|
||||
companyAccess: IIdentityCompanyAccessChecker;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type {
|
||||
IIdentityAuthPublicServices,
|
||||
IIdentityCompanyAccessChecker,
|
||||
IIdentityTenantCompanyFinder,
|
||||
IIdentityPublicServices,
|
||||
IIdentityTenantCompanyFinder,
|
||||
} from "../../application";
|
||||
import {
|
||||
authenticateUser,
|
||||
|
||||
@ -70,6 +70,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
||||
const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository(
|
||||
database
|
||||
);
|
||||
const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository);
|
||||
|
||||
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
|
||||
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
|
||||
@ -132,7 +133,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
||||
},
|
||||
},
|
||||
companyAccess: {
|
||||
checker: new IdentityCompanyAccessChecker(companyMembershipAccessRepository),
|
||||
checker: companyAccessChecker,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -38,6 +38,11 @@ export interface IdentityAuthSessionContextValue {
|
||||
refreshAccessToken: () => Promise<string | null>;
|
||||
selectActiveCompany: (companyId: string) => ActiveCompany | null;
|
||||
changeActiveCompany: (companyId: string) => Promise<ActiveCompany | null>;
|
||||
updateActiveCompany: (company: {
|
||||
id: string;
|
||||
legalName: string;
|
||||
tradeName: string | null;
|
||||
}) => void;
|
||||
getAuthenticatedRedirectPath: (params?: {
|
||||
returnTo?: string | null;
|
||||
fallbackPath?: string;
|
||||
@ -243,6 +248,37 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
||||
[selectActiveCompany]
|
||||
);
|
||||
|
||||
const updateActiveCompany = useCallback(
|
||||
(company: { id: string; legalName: string; tradeName: string | null }) => {
|
||||
setCompanySession((currentSession) => {
|
||||
const availableCompanies = currentSession.availableCompanies.map((item) =>
|
||||
item.id === company.id
|
||||
? {
|
||||
...item,
|
||||
legalName: company.legalName,
|
||||
tradeName: company.tradeName,
|
||||
}
|
||||
: item
|
||||
);
|
||||
|
||||
const activeCompany =
|
||||
currentSession.activeCompany?.id === company.id
|
||||
? {
|
||||
...currentSession.activeCompany,
|
||||
legalName: company.legalName,
|
||||
tradeName: company.tradeName,
|
||||
}
|
||||
: currentSession.activeCompany;
|
||||
|
||||
return {
|
||||
availableCompanies,
|
||||
activeCompany,
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getAuthenticatedRedirectPath = useCallback(
|
||||
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
|
||||
return resolveAuthenticatedRedirectPath({
|
||||
@ -289,6 +325,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
||||
refreshAccessToken,
|
||||
selectActiveCompany,
|
||||
changeActiveCompany,
|
||||
updateActiveCompany,
|
||||
getAuthenticatedRedirectPath,
|
||||
clearSession,
|
||||
}),
|
||||
@ -307,6 +344,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
||||
refreshSessionMutation.isPending,
|
||||
restoreSession,
|
||||
selectActiveCompany,
|
||||
updateActiveCompany,
|
||||
session,
|
||||
]
|
||||
);
|
||||
|
||||
@ -15,6 +15,12 @@
|
||||
"^lint"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
"^test"
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
@ -46,4 +52,4 @@
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user