From 3d8256b6949f1d55b0c0a77897a7a0566faaa999 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 16 Oct 2026 19:00:01 +0200 Subject: [PATCH] ERP-ARCH-01-tenant-authorization-cache --- apps/server/src/index.ts | 6 + .../testing/e2e/bootstrap-e2e-data.test.ts | 7 +- .../src/testing/e2e/bootstrap-e2e-data.ts | 3 + .../http/authorization-policy.http.test.ts | 355 ++++++++++++++++++ .../http/companies-current.http.test.ts | 8 +- .../http/company-audit.integration.test.ts | 12 +- .../http/fixtures/company-certificates.json | 1 + .../src/testing/http/http-test-fixtures.ts | 28 ++ .../src/testing/http/http-test-runtime.ts | 17 + .../scoped-persistence.integration.test.ts | 257 +++++++++++++ .../http/tenant-authorization.http.test.ts | 131 +++++++ apps/web/src/app.tsx | 51 +-- .../hooks/use-home-customers-total-query.ts | 6 +- .../hooks/use-home-monthly-billing-query.ts | 12 +- .../use-home-proformas-overview-query.ts | 6 +- .../hooks/use-home-recent-activity-query.ts | 8 +- apps/web/src/layout/app-company-switcher.tsx | 4 +- apps/web/src/layout/app-fullscreen-layout.tsx | 4 +- apps/web/src/layout/app-sidebar-layout.tsx | 4 +- apps/web/src/layout/company-page-outlet.tsx | 14 + .../use-app-company-switcher-controller.ts | 16 +- .../hooks/use-initial-onboarding.ts | 13 +- docs/architecture/backend-architecture.md | 14 + .../architecture/identity-migration-status.md | 50 +-- docs/architecture/tenant-context.md | 103 ++++- docs/frontend/frontend-architecture.md | 34 ++ .../src/web/shared/hooks/use-audit-entry.ts | 6 +- .../src/web/shared/hooks/use-audit-list.ts | 12 +- .../payment-method-repository.interface.ts | 6 +- .../__tests__/payment-method-services.test.ts | 2 +- .../services/payment-method-disabler.ts | 6 +- .../services/payment-method-enabler.ts | 6 +- .../services/payment-method-status-changer.ts | 6 +- .../services/payment-method-updater.ts | 2 +- .../set-default-payment-method.service.ts | 12 +- .../__tests__/tax-definition-test-support.ts | 43 ++- .../tax-definition-repository.interface.ts | 6 +- .../tax-definition-status-changer.service.ts | 12 +- .../tax-definition-updater.service.ts | 2 +- .../tax-regime-repository.interface.ts | 6 +- .../tax-regime-status-changer.service.ts | 6 +- .../services/tax-regime-updater.service.ts | 2 +- .../payment-method-company-scope.test.ts | 68 ++++ .../sequelize-payment-method.repository.ts | 27 +- .../sequelize-tax-definition.repository.ts | 27 +- .../tax-definition-company-scope.test.ts | 68 ++++ .../sequelize-tax-regime.repository.ts | 32 +- .../tax-regime-company-scope.test.ts | 68 ++++ .../use-payment-method-create-mutation.ts | 11 +- .../use-payment-method-delete-mutation.ts | 12 +- .../hooks/use-payment-method-detail-query.ts | 6 +- .../use-payment-method-disable-mutation.ts | 13 +- .../use-payment-method-enable-mutation.ts | 13 +- .../hooks/use-payment-method-list-query.ts | 9 +- ...use-payment-method-set-default-mutation.ts | 17 +- .../use-payment-method-update-mutation.ts | 11 +- .../hooks/use-payment-term-create-mutation.ts | 11 +- .../hooks/use-payment-term-delete-mutation.ts | 12 +- .../hooks/use-payment-term-detail-query.ts | 6 +- .../use-payment-term-disable-mutation.ts | 13 +- .../hooks/use-payment-term-enable-mutation.ts | 13 +- .../hooks/use-payment-term-list-query.ts | 9 +- .../hooks/use-payment-term-update-mutation.ts | 11 +- .../use-tax-definition-create-mutation.ts | 34 +- .../use-tax-definition-delete-mutation.ts | 26 +- .../hooks/use-tax-definition-detail-query.ts | 18 +- .../use-tax-definition-disable-mutation.ts | 26 +- .../use-tax-definition-enable-mutation.ts | 26 +- .../hooks/use-tax-definition-list-query.ts | 23 +- .../use-tax-definition-update-mutation.ts | 32 +- .../hooks/use-tax-regime-create-mutation.ts | 11 +- .../hooks/use-tax-regime-delete-mutation.ts | 12 +- .../hooks/use-tax-regime-detail-query.ts | 6 +- .../hooks/use-tax-regime-disable-mutation.ts | 13 +- .../hooks/use-tax-regime-enable-mutation.ts | 13 +- .../shared/hooks/use-tax-regime-list-query.ts | 9 +- .../hooks/use-tax-regime-update-mutation.ts | 11 +- .../contracts/company-repository.interface.ts | 1 + .../use-cases/create-company.use-case.test.ts | 12 +- .../use-cases/create-company.use-case.ts | 19 +- ...vailable-companies-for-account.use-case.ts | 12 +- .../src/api/infrastructure/di/companies.di.ts | 8 +- .../express/companies.routes.ts | 14 +- .../controllers/create-company.controller.ts | 7 +- .../sequelize-company.repository.ts | 5 +- modules/companies/src/api/module.ts | 3 + modules/companies/src/common/index.ts | 1 + modules/companies/src/common/permissions.ts | 21 ++ .../hooks/use-company-settings-query.ts | 6 +- .../use-company-settings-update-mutation.ts | 6 +- .../company-details-settings-page.test.tsx | 1 + modules/core/package.json | 5 +- .../api/modules/module-permissions.test.ts | 38 ++ .../src/api/modules/module-permissions.ts | 26 ++ .../src/api/modules/module-runtime.test.ts | 40 ++ .../core/src/api/modules/module-runtime.ts | 10 +- .../api/modules/module-server.interface.ts | 8 +- modules/core/src/api/modules/types.ts | 14 + modules/core/src/common/types/permissions.ts | 1 + .../src/web/hooks/company-query-scope.tsx | 22 ++ modules/core/src/web/hooks/index.ts | 1 + .../lib/query/company-query-context.test.ts | 123 ++++++ .../web/lib/query/company-query-context.ts | 21 ++ .../src/web/lib/query/company-query-key.ts | 26 ++ modules/core/src/web/lib/query/index.ts | 2 + .../proforma-repository.interface.ts | 6 +- .../services/proforma-status-changer.ts | 6 +- .../proformas/services/proforma-updater.ts | 2 +- .../proforma-company-scope.test.ts | 73 ++++ .../repositories/proforma-v2.repository.ts | 26 +- .../recurring-proforma-domain.mapper.ts | 2 +- ...rring-proforma-occurrence-domain.mapper.ts | 4 +- .../recurring-occurrence-save-scope.test.ts | 63 ++++ .../recurring-proforma-save-scope.test.ts | 68 ++++ ...ing-proforma-occurrence.repository.test.ts | 15 +- ...ecurring-proforma-occurrence.repository.ts | 26 +- ...lize-recurring-proforma.repository.test.ts | 21 +- ...sequelize-recurring-proforma.repository.ts | 26 +- .../hooks/use-download-invoice-pdf-query.ts | 6 +- .../use-issued-invoice-list.controller.ts | 11 +- .../hooks/use-issued-invoice-get-query.ts | 6 +- .../hooks/use-issued-invoice-list-query.ts | 9 +- .../use-create-proforma-controller.ts | 8 +- ...a-from-proforma-dialog-controller.test.tsx | 1 + .../hooks/use-download-proforma-pdf-query.ts | 6 +- .../shared/hooks/proforma-cache-strategy.ts | 199 +++++++--- .../shared/hooks/use-document-series-query.ts | 6 +- .../use-duplicate-proforma-by-id-mutation.ts | 5 +- .../hooks/use-proforma-archive-mutation.ts | 5 +- .../use-proforma-change-status-mutation.ts | 5 +- .../hooks/use-proforma-create-mutation.ts | 7 +- .../hooks/use-proforma-delete-mutation.ts | 7 +- .../shared/hooks/use-proforma-get-query.ts | 6 +- .../use-proforma-issuance-readiness-query.ts | 6 +- .../hooks/use-proforma-issue-mutation.ts | 5 +- .../hooks/use-proforma-unarchive-mutation.ts | 5 +- .../hooks/use-proforma-update-mutation.ts | 5 +- .../shared/hooks/use-proformas-list-query.ts | 9 +- ...ate-proforma-customer-change-controller.ts | 8 +- .../ui/pages/proforma-update-page.test.tsx | 1 + .../recurring-proforma-create-route.test.tsx | 1 + .../recurring-proforma-create-cta.test.tsx | 6 +- ...-recurring-proforma-from-proforma.test.tsx | 4 +- ...create-recurring-proforma-from-proforma.ts | 8 +- .../hooks/use-create-recurring-proforma.ts | 8 +- .../use-recurring-proforma-action-mutation.ts | 8 +- .../use-recurring-proforma-occurrences.ts | 12 +- .../shared/hooks/use-recurring-proforma.ts | 6 +- .../shared/hooks/use-recurring-proformas.ts | 9 +- .../use-company-verifactu-profile-query.ts | 6 +- ...mpany-verifactu-profile-update-mutation.ts | 9 +- .../customer-repository.interface.ts | 9 +- .../application/services/customer-updater.ts | 2 +- .../customer-company-scope.test.ts | 68 ++++ .../repositories/customer.repository.ts | 34 +- .../use-customer-create-fiscal-controller.ts | 11 +- .../hooks/customer-cache-strategy.test.ts | 73 ++-- .../shared/hooks/customer-cache-strategy.ts | 90 +++-- .../hooks/use-customer-create-mutation.ts | 7 +- .../hooks/use-customer-delete-mutation.ts | 7 +- .../shared/hooks/use-customer-get-query.ts | 6 +- .../hooks/use-customer-update-mutation.ts | 5 +- .../shared/hooks/use-customers-list-query.ts | 9 +- .../document-series-save-scope.test.ts | 61 +++ .../document-series.repository.ts | 25 +- .../hooks/use-document-series-list-query.ts | 9 +- .../hooks/use-document-series-mutations.ts | 29 +- ...ocument-series-settings-controller.test.ts | 5 +- modules/identity/sql/001-system-owner.sql | 6 + .../use-cases/bootstrap-system-account.ts | 42 +++ .../application/accounts/use-cases/index.ts | 1 + .../services/company-access-checker.ts | 12 +- .../initial-company-admin-assigner.test.ts | 10 +- .../src/api/application/permissions/index.ts | 2 + .../services/company-authorization.test.ts | 91 +++++ .../services/company-authorization.ts | 71 ++++ .../services/registered-permission-catalog.ts | 27 ++ .../permissions/services/system-access.ts | 6 + .../company-admin-role-ensurer.test.ts | 86 ++++- .../services/company-admin-role-ensurer.ts | 47 ++- .../services/role-permission-validator.ts | 4 +- ...identity-auth-public-services.interface.ts | 10 +- .../identity-public-services.interface.ts | 4 + .../src/api/domain/roles/role.aggregate.ts | 18 + modules/identity/src/api/index.ts | 6 + .../di/identity-public-services.ts | 6 + .../src/api/infrastructure/di/identity.di.ts | 29 +- .../di/synchronize-company-admin-roles.ts | 28 ++ .../express/middlewares/index.ts | 1 + .../require-company-context.middleware.ts | 4 +- .../require-identity-middlewares.ts | 11 +- .../require-permission.middleware.test.ts | 106 ++++++ .../require-permission.middleware.ts | 77 ++++ .../persistence/sequelize/index.ts | 2 + .../models/sequelize-system-owner.model.ts | 26 ++ .../repositories/sequelize-system-access.ts | 54 +++ modules/identity/src/api/module.test.ts | 7 +- modules/identity/src/api/module.ts | 6 + .../shared/permission-definition.dto.ts | 1 + .../common/permissions/permission-codes.ts | 17 + .../src/web/account/shared/hooks/keys.ts | 3 +- .../shared/hooks/use-current-account-query.ts | 4 +- .../use-update-current-account-mutation.ts | 4 +- .../context/identity-auth-session.context.tsx | 118 ++++-- .../express/onboarding.routes.test.ts | 11 +- .../express/onboarding.routes.ts | 13 +- .../supplier-company-scope.test.ts | 39 ++ .../shared/hooks/supplier-cache-strategy.ts | 66 +++- .../hooks/use-supplier-create-mutation.ts | 7 +- .../shared/hooks/use-supplier-get-query.ts | 6 +- .../hooks/use-supplier-update-mutation.ts | 5 +- .../shared/hooks/use-suppliers-list-query.ts | 9 +- tests/e2e/identity/tenant-cache.spec.ts | 85 +++++ 213 files changed, 4202 insertions(+), 646 deletions(-) create mode 100644 apps/server/src/testing/http/authorization-policy.http.test.ts create mode 100644 apps/server/src/testing/http/fixtures/company-certificates.json create mode 100644 apps/server/src/testing/http/scoped-persistence.integration.test.ts create mode 100644 apps/server/src/testing/http/tenant-authorization.http.test.ts create mode 100644 apps/web/src/layout/company-page-outlet.tsx create mode 100644 modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/payment-method-company-scope.test.ts create mode 100644 modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/tax-definition-company-scope.test.ts create mode 100644 modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/tax-regime-company-scope.test.ts create mode 100644 modules/companies/src/common/permissions.ts create mode 100644 modules/core/src/api/modules/module-permissions.test.ts create mode 100644 modules/core/src/api/modules/module-permissions.ts create mode 100644 modules/core/src/web/hooks/company-query-scope.tsx create mode 100644 modules/core/src/web/lib/query/company-query-context.test.ts create mode 100644 modules/core/src/web/lib/query/company-query-context.ts create mode 100644 modules/core/src/web/lib/query/company-query-key.ts create mode 100644 modules/core/src/web/lib/query/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-company-scope.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-occurrence-save-scope.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-proforma-save-scope.test.ts create mode 100644 modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer-company-scope.test.ts create mode 100644 modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series-save-scope.test.ts create mode 100644 modules/identity/sql/001-system-owner.sql create mode 100644 modules/identity/src/api/application/accounts/use-cases/bootstrap-system-account.ts create mode 100644 modules/identity/src/api/application/permissions/services/company-authorization.test.ts create mode 100644 modules/identity/src/api/application/permissions/services/company-authorization.ts create mode 100644 modules/identity/src/api/application/permissions/services/registered-permission-catalog.ts create mode 100644 modules/identity/src/api/application/permissions/services/system-access.ts create mode 100644 modules/identity/src/api/infrastructure/di/synchronize-company-admin-roles.ts create mode 100644 modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.test.ts create mode 100644 modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-system-owner.model.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-system-access.ts create mode 100644 modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/supplier-company-scope.test.ts create mode 100644 tests/e2e/identity/tenant-cache.spec.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ab31e775..6b21b97a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,6 +1,7 @@ import http from "node:http"; import os from "node:os"; +import type { ICompanyPublicServices } from "@erp/companies/api"; import { AsyncLocalStorageOperationContextStore, AuditChangeModel, @@ -275,6 +276,11 @@ process.on("uncaughtException", async (error: Error) => { }, }); await startModules({ + tenantCompanyFinder: getServiceScoped( + "server-infrastructure", + ["companies"], + "companies:general" + ).finder, config, app, database, diff --git a/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts b/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts index befc4f97..40cecacf 100644 --- a/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts +++ b/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts @@ -46,7 +46,7 @@ test("crea el grafo mínimo y nunca persiste la contraseña en claro", async () }) as never, } ); - assert.equal(queries.length, 6); + assert.equal(queries.length, 7); assert.equal(result.email, "e2e@example.test"); assert.equal(result.companySlug, "e2e-company"); const account = queries[0]; @@ -126,7 +126,8 @@ for (const [name, failureAt] of [ ["company", 2], ["membership", 3], ["role", 4], - ["onboarding", 6], + ["system-owner", 6], + ["onboarding", 7], ] as const) { test(`revierte la transacción si falla ${name}`, async () => { const previous = process.env.NODE_ENV; @@ -174,7 +175,7 @@ function dependenciesWithFailure(failureAt: number, events: string[]) { events.push(`query:${position}`); if (position === failureAt) { throw new Error( - `${["account", "company", "membership", "role", "membership-role", "onboarding"][position - 1]} failed` + `${["account", "company", "membership", "role", "membership-role", "system-owner", "onboarding"][position - 1]} failed` ); } }, diff --git a/apps/server/src/testing/e2e/bootstrap-e2e-data.ts b/apps/server/src/testing/e2e/bootstrap-e2e-data.ts index 5ca6cfd3..6c209efc 100644 --- a/apps/server/src/testing/e2e/bootstrap-e2e-data.ts +++ b/apps/server/src/testing/e2e/bootstrap-e2e-data.ts @@ -91,6 +91,9 @@ async function createBootstrapData( "INSERT INTO identity_company_membership_roles (membership_id, role_id) VALUES (:membershipId, :roleId)", { membershipId, roleId } ); + await query("INSERT INTO identity_system_owner (id, account_id) VALUES (1, :accountId)", { + accountId, + }); await query( "INSERT INTO initial_onboardings (id, account_id, status, company_id, created_at, updated_at, completed_at) VALUES (:id, :accountId, 'completed', :companyId, NOW(), NOW(), NOW())", { id: onboardingId, accountId, companyId } diff --git a/apps/server/src/testing/http/authorization-policy.http.test.ts b/apps/server/src/testing/http/authorization-policy.http.test.ts new file mode 100644 index 00000000..cd601c11 --- /dev/null +++ b/apps/server/src/testing/http/authorization-policy.http.test.ts @@ -0,0 +1,355 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import type { IModuleServer } from "@erp/core/api"; +import { getServiceScoped } from "@erp/core/api"; +import type { IAccountCreateProps, IIdentityPublicServices } from "@erp/identity/api"; +import { + AccountStatus, + BcryptPasswordHasher, + requireIdentityAuthenticated, + requireIdentityTenant, + requirePermission, +} from "@erp/identity/api"; +import { EmailAddress, LanguageCode, Name, UniqueID, UtcDateTime } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; +import type { Request, Response } from "express"; + +import companiesModule from "../../../../../modules/companies/src/api/index"; +import { collectModulePermissions } from "../../../../../modules/core/src/api/modules/module-permissions"; +import { CompanyAdminRoleEnsurer } from "../../../../../modules/identity/src/api/application/roles/services/company-admin-role-ensurer"; +import identityModule from "../../../../../modules/identity/src/api/index"; +import { synchronizeCompanyAdminRoles } from "../../../../../modules/identity/src/api/infrastructure/di/synchronize-company-admin-roles"; +import { RoleModel } from "../../../../../modules/identity/src/api/infrastructure/persistence/sequelize/models"; +import { SequelizeRoleRepository } from "../../../../../modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + grantTestPermissions, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +const futureModule: IModuleServer = { + name: "future", + version: "1", + dependencies: ["identity", "companies"], + start(params) { + const identity = params.getService("identity:general"); + params.app.get( + "/api/v1/__test/future/company", + ...requireIdentityTenant(params), + requirePermission(identity.authorization, "future:stock:read"), + (_req: Request, res: Response) => res.json({ allowed: true }) + ); + params.app.get( + "/api/v1/__test/future/system", + ...requireIdentityAuthenticated(params), + requirePermission(identity.authorization, "future:system:configure", "SYSTEM"), + (_req: Request, res: Response) => res.json({ allowed: true }) + ); + params.app.get( + "/api/v1/__test/future/unknown", + ...requireIdentityAuthenticated(params), + requirePermission(identity.authorization, "future:system:unknown", "SYSTEM"), + (_req: Request, res: Response) => res.json({ allowed: true }) + ); + }, + permissions: [ + { code: "future:stock:read", module: "future", scope: "COMPANY", description: "Read stock" }, + { + code: "future:system:configure", + module: "future", + scope: "SYSTEM", + description: "Configure system", + }, + ], +}; + +test("bootstrap, scopes, onboarding, administradores existentes y nuevos con MariaDB real", async (t) => { + const runtime = await createHttpTestRuntime({ testModules: [futureModule] }); + t.after(() => runtime.stop()); + const identity = getServiceScoped( + "policy-test", + ["identity"], + "identity:general" + ); + const password = "TemporaryTestPassword123!"; + const hash = await new BcryptPasswordHasher({ rounds: 4 }).hash(password); + assert.ok(hash.isSuccess); + const props: IAccountCreateProps = { + email: EmailAddress.create(`owner-${randomUUID()}@example.test`).data, + name: Name.create("System owner").data, + passwordHash: hash.data, + avatarUrl: Maybe.none(), + languageCode: LanguageCode.create("es").data, + status: AccountStatus.createActive(), + createdAt: UtcDateTime.now(), + updatedAt: UtcDateTime.now(), + }; + const bootstrapped = await identity.systemBootstrap.create(props); + assert.ok( + bootstrapped.isSuccess, + bootstrapped.isFailure ? bootstrapped.error.message : undefined + ); + const ownerId = bootstrapped.data.id; + assert.equal( + ( + await identity.authorization.canSystem({ + accountId: ownerId, + permissionCode: "future:system:configure", + }) + ).data, + true + ); + // Nunca se promueve una segunda cuenta ni se reemplaza el titular al repetir. + const second = await identity.systemBootstrap.create({ + ...props, + email: EmailAddress.create(`second-${randomUUID()}@example.test`).data, + }); + assert.ok(second.isFailure); + const login = await loginByApi({ + request: runtime.request, + email: props.email.toPrimitive(), + password, + }); + assert.equal(login.status, 200); + assert.ok(login.body && typeof login.body === "object" && "access_token" in login.body); + const token = String(login.body.access_token); + const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + assert.equal( + (await runtime.request("/api/v1/onboarding/initial", { method: "POST", headers })).status, + 200 + ); + const onboarding = await runtime.request("/api/v1/onboarding/initial/complete", { + method: "POST", + headers, + body: JSON.stringify({ + legal_name: "First company", + tin: "B12345678", + address: { + street: "Street", + city: "City", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, + }), + }); + assert.equal(onboarding.status, 200); + const other = await createTestCompany(runtime.database, { + legalName: "Global target without membership", + }); + assert.equal((await runtime.request("/api/v1/__test/future/system", { headers })).status, 200); + assert.equal((await runtime.request("/api/v1/__test/future/unknown", { headers })).status, 403); + const disabled = await createTestCompany(runtime.database, { + legalName: "Disabled target", + status: "disabled", + }); + assert.equal( + ( + await runtime.request("/api/v1/__test/future/company", { + headers: buildCompanyHeaders({ accessToken: token, companyId: disabled.id }), + }) + ).status, + 403 + ); + for (const companyId of [disabled.id, randomUUID()]) { + assert.equal( + ( + await runtime.request("/api/v1/catalogs/payment-methods", { + headers: buildCompanyHeaders({ accessToken: token, companyId }), + }) + ).status, + 403 + ); + } + const companyHeaders = buildCompanyHeaders({ accessToken: token, companyId: other.id }); + assert.equal( + (await runtime.request("/api/v1/catalogs/payment-methods", { headers: companyHeaders })).status, + 200 + ); + assert.equal( + (await runtime.request("/api/v1/__test/future/company", { headers: companyHeaders })).status, + 200 + ); + assert.equal( + (await runtime.request(`/api/v1/companies/${other.id}`, { headers: companyHeaders })).status, + 200 + ); + assert.equal((await runtime.request(`/api/v1/companies/${other.id}`, { headers })).status, 403); + assert.equal( + ( + await runtime.request(`/api/v1/companies/${other.id}`, { + headers: buildCompanyHeaders({ accessToken: token, companyId: randomUUID() }), + }) + ).status, + 403 + ); + assert.equal( + ( + await identity.authorization.can({ + accountId: ownerId, + companyId: UniqueID.create(other.id).data, + permissionCode: "future:stock:read", + }) + ).data, + true + ); + assert.equal( + ( + await identity.authorization.can({ + accountId: ownerId, + companyId: UniqueID.create(other.id).data, + permissionCode: "future:system:configure", + }) + ).data, + false + ); + const createdId = randomUUID(); + assert.equal( + ( + await runtime.request("/api/v1/companies", { + method: "POST", + headers, + body: JSON.stringify({ + id: createdId, + legal_name: "Created globally", + slug: `global-${randomUUID()}`, + }), + }) + ).status, + 201 + ); + const available = await runtime.request("/api/v1/companies/available", { headers }); + assert.equal(available.status, 200); + assert.ok(JSON.stringify(available.body).includes(other.id)); + + const ordinary = await createTestAccount(runtime.database, { + email: `${randomUUID()}@example.test`, + password, + }); + const membership = await createTestMembership(runtime.database, { + accountId: ordinary.id, + companyId: other.id, + }); + await grantTestPermissions(runtime.database, { + companyId: other.id, + membershipId: membership.id, + permissionCodes: ["identity:roles:read"], + }); + const definitions = collectModulePermissions([identityModule, companiesModule, futureModule]); + const adminRoles = new CompanyAdminRoleEnsurer(new SequelizeRoleRepository(), definitions); + // Un rol admin histórico incompleto y un rol normal ya persistidos antes del mantenimiento. + await runtime.database.getQueryInterface().bulkInsert("identity_roles", [ + { + id: randomUUID(), + company_id: other.id, + code: "admin", + name: "Administrator", + is_system: true, + permission_codes: JSON.stringify(["identity:roles:read", "custom:reports:read"]), + created_at: new Date(), + updated_at: new Date(), + }, + ]); + const normalBefore = await RoleModel.findAll({ + where: { company_id: other.id, is_system: false }, + raw: true, + }); + const warmup = { database: runtime.database, getInternal: () => ({ adminRoles }) }; + await synchronizeCompanyAdminRoles(warmup as never); + await synchronizeCompanyAdminRoles(warmup as never); + assert.deepEqual( + await RoleModel.findAll({ where: { company_id: other.id, is_system: false }, raw: true }), + normalBefore + ); + const existing = await RoleModel.findOne({ where: { company_id: other.id, code: "admin" } }); + assert.ok(existing); + const codes = + typeof existing.permission_codes === "string" + ? JSON.parse(existing.permission_codes) + : existing.permission_codes; + assert.ok(Array.isArray(codes)); + assert.ok(codes.includes("future:stock:read")); + assert.ok(codes.includes("custom:reports:read")); + assert.equal(codes.includes("future:system:configure"), false); + assert.equal(new Set(codes).size, codes.length); + // El superadministrador puede designar administradores usando el servicio público transaccional. + await runtime.database.transaction(async (transaction) => { + const assigned = await identity.initialCompanyAdminAssigner.assign({ + accountId: UniqueID.create(ordinary.id).data, + companyId: UniqueID.create(other.id).data, + transaction, + }); + assert.ok(assigned.isSuccess); + }); + assert.equal( + ( + await identity.authorization.can({ + accountId: UniqueID.create(ordinary.id).data, + companyId: UniqueID.create(other.id).data, + permissionCode: "future:stock:read", + }) + ).data, + true + ); + assert.equal( + ( + await identity.authorization.canSystem({ + accountId: UniqueID.create(ordinary.id).data, + permissionCode: "future:system:configure", + }) + ).data, + false + ); + const ordinaryLogin = await loginByApi({ + request: runtime.request, + email: ordinary.email, + password, + }); + assert.ok( + ordinaryLogin.body && + typeof ordinaryLogin.body === "object" && + "access_token" in ordinaryLogin.body + ); + const ordinaryHeaders = { + authorization: `Bearer ${String(ordinaryLogin.body.access_token)}`, + "content-type": "application/json", + }; + assert.equal( + (await runtime.request("/api/v1/__test/future/system", { headers: ordinaryHeaders })).status, + 403 + ); + assert.equal( + ( + await runtime.request("/api/v1/__test/future/company", { + headers: { ...ordinaryHeaders, "x-company-id": other.id }, + }) + ).status, + 200 + ); + assert.equal( + ( + await runtime.request("/api/v1/companies", { + method: "POST", + headers: ordinaryHeaders, + body: JSON.stringify({ id: randomUUID(), legal_name: "Denied", slug: "denied" }), + }) + ).status, + 403 + ); + assert.equal( + ( + await runtime.request("/api/v1/onboarding/initial", { + method: "POST", + headers: ordinaryHeaders, + }) + ).status, + 403 + ); +}); diff --git a/apps/server/src/testing/http/companies-current.http.test.ts b/apps/server/src/testing/http/companies-current.http.test.ts index fc3ed724..5b1366e9 100644 --- a/apps/server/src/testing/http/companies-current.http.test.ts +++ b/apps/server/src/testing/http/companies-current.http.test.ts @@ -9,6 +9,7 @@ import { createTestMembership, createTestPaymentTerm, findCompanyRow, + grantTestPermissions, loginByApi, } from "./http-test-fixtures.ts"; import { createHttpTestRuntime } from "./http-test-runtime.ts"; @@ -54,10 +55,15 @@ test("GET /api/v1/companies/current devuelve la company activa del miembro auten website: "https://acme.test", }); - await createTestMembership(runtime.database, { + const membership = await createTestMembership(runtime.database, { accountId: account.id, companyId: company.id, }); + await grantTestPermissions(runtime.database, { + membershipId: membership.id, + companyId: company.id, + permissionCodes: ["identity:companies:update"], + }); const loginResponse = await loginByApi({ request: runtime.request, diff --git a/apps/server/src/testing/http/company-audit.integration.test.ts b/apps/server/src/testing/http/company-audit.integration.test.ts index daa4ef1a..e2d59d25 100644 --- a/apps/server/src/testing/http/company-audit.integration.test.ts +++ b/apps/server/src/testing/http/company-audit.integration.test.ts @@ -24,6 +24,7 @@ import { createTestCompany, createTestMembership, createTestPaymentTerm, + grantTestPermissions, loginByApi, } from "./http-test-fixtures"; import { createHttpTestRuntime } from "./http-test-runtime"; @@ -36,6 +37,9 @@ test("companies registra create, updates, no-ops y rollback con MariaDB", async email: `company-audit-${randomUUID()}@example.test`, password: "Password123!", }); + await runtime.database + .getQueryInterface() + .bulkInsert("identity_system_owner", [{ id: 1, account_id: account.id }]); const loginResponse = await loginByApi({ request: runtime.request, email: account.email, @@ -76,10 +80,16 @@ test("companies registra create, updates, no-ops y rollback con MariaDB", async legalName: "Original company", email: "old@example.test", }); - await createTestMembership(runtime.database, { + const membership = await createTestMembership(runtime.database, { accountId: account.id, companyId: company.id, }); + await grantTestPermissions(runtime.database, { + membershipId: membership.id, + companyId: company.id, + permissionCodes: ["identity:companies:update"], + }); + const paymentTerm = await createTestPaymentTerm(runtime.database, { companyId: company.id, name: "30 days", diff --git a/apps/server/src/testing/http/fixtures/company-certificates.json b/apps/server/src/testing/http/fixtures/company-certificates.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/apps/server/src/testing/http/fixtures/company-certificates.json @@ -0,0 +1 @@ +{} diff --git a/apps/server/src/testing/http/http-test-fixtures.ts b/apps/server/src/testing/http/http-test-fixtures.ts index d4d4d143..3e504af8 100644 --- a/apps/server/src/testing/http/http-test-fixtures.ts +++ b/apps/server/src/testing/http/http-test-fixtures.ts @@ -269,3 +269,31 @@ export function buildCompanyHeaders(params: { export function getQueryInterface(database: Sequelize): QueryInterface { return database.getQueryInterface(); } + +/** Concede exclusivamente los permisos indicados al membership de esta fixture. */ +export async function grantTestPermissions( + database: Sequelize, + params: { membershipId: string; companyId: string; permissionCodes: readonly string[] } +) { + const roleId = randomUUID(); + const now = new Date(); + const queryInterface = database.getQueryInterface(); + await queryInterface.bulkInsert("identity_roles", [ + { + id: roleId, + company_id: params.companyId, + code: `test-${roleId}`, + name: "Test permissions", + is_system: false, + permission_codes: JSON.stringify(params.permissionCodes), + created_at: now, + updated_at: now, + }, + ]); + await queryInterface.bulkInsert("identity_company_membership_roles", [ + { + membership_id: params.membershipId, + role_id: roleId, + }, + ]); +} diff --git a/apps/server/src/testing/http/http-test-runtime.ts b/apps/server/src/testing/http/http-test-runtime.ts index ea07e6cc..43352624 100644 --- a/apps/server/src/testing/http/http-test-runtime.ts +++ b/apps/server/src/testing/http/http-test-runtime.ts @@ -3,6 +3,7 @@ import { type Server, createServer } from "node:http"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import type { ICompanyPublicServices } from "@erp/companies/api"; import { AsyncLocalStorageOperationContextStore, type IModuleServer, @@ -127,6 +128,11 @@ export async function createHttpTestRuntime( }, }); await startModules({ + tenantCompanyFinder: getServiceScoped( + "server-infrastructure", + ["companies"], + "companies:general" + ).finder, app, config, database, @@ -214,6 +220,17 @@ function loadTestEnvironment(): void { if (result.error) { throw new Error(`Unable to load test environment file: ${TEST_ENV_FILE}`); } + // Overrides exclusivos de integración: permiten una instancia temporal aislada sin editar .env. + for (const key of ["HOST", "PORT", "USER", "PASS", "NAME"]) { + const override = process.env[`HTTP_TEST_DB_${key}`]; + if (override !== undefined) { + process.env[`DB_${key}`] = override; + } + } + // La suite no necesita certificados reales ni rutas locales del entorno de desarrollo. + process.env.COMPANY_CERTIFICATES_PATH = resolve(currentDir, "fixtures/company-certificates.json"); + // El esquema se inicializa en la BD efímera recién creada; nunca se usa sync force. + process.env.DB_SYNC_MODE = "alter"; } function resetServerRuntimeForTests(): void { diff --git a/apps/server/src/testing/http/scoped-persistence.integration.test.ts b/apps/server/src/testing/http/scoped-persistence.integration.test.ts new file mode 100644 index 00000000..8e4053cf --- /dev/null +++ b/apps/server/src/testing/http/scoped-persistence.integration.test.ts @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { RecurringProforma } from "../../../../../modules/customer-invoices/src/api/domain"; +import { RecurringProformaModel } from "../../../../../modules/customer-invoices/src/api/infrastructure/common"; +import { SequelizeRecurringProformaRepository } from "../../../../../modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository"; +import { + SequelizeCustomerDomainMapper, + SequelizeCustomerSummaryMapper, +} from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/mappers"; +import { CustomerModel } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model"; +import { CustomerRepository } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository"; +import type { DocumentSeriesCreateProps } from "../../../../../modules/document-series/src/api/domain"; +import { + DocumentSeries, + DocumentSeriesCode, + DocumentSeriesNumber, + DocumentType, +} from "../../../../../modules/document-series/src/api/domain"; +import { DocumentSeriesModel } from "../../../../../modules/document-series/src/api/infrastructure/common"; +import { SequelizeDocumentSeriesDomainMapper } from "../../../../../modules/document-series/src/api/infrastructure/persistence/sequelize/mappers"; +import { SequelizeDocumentSeriesRepository } from "../../../../../modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +test("ORM real: Customer update, Series save y Recurring save preservan tenant y transacción", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(() => runtime.stop()); + const a = await createTestCompany(runtime.database, { legalName: "A" }); + const b = await createTestCompany(runtime.database, { legalName: "B" }); + const companyId = UniqueID.create(a.id).data; + const otherCompanyId = UniqueID.create(b.id).data; + const account = await createTestAccount(runtime.database, { + email: `${randomUUID()}@example.test`, + password: "TestPassword123!", + }); + await createTestMembership(runtime.database, { accountId: account.id, companyId: a.id }); + const login = await loginByApi({ + request: runtime.request, + email: account.email, + password: account.password, + }); + assert.ok(login.body && typeof login.body === "object" && "access_token" in login.body); + const id = UniqueID.generateNewID(); + const created = await runtime.request("/api/v1/customers", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken: String(login.body.access_token), companyId: a.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ + id: id.toPrimitive(), + is_company: true, + name: "Original", + address: { country: "ES" }, + uses_retention: false, + language_code: "es", + currency_code: "EUR", + }), + }); + assert.equal(created.status, 201); + const customers = new CustomerRepository( + new SequelizeCustomerDomainMapper(), + new SequelizeCustomerSummaryMapper(), + runtime.database + ); + const loaded = await customers.getByIdInCompany(companyId, id); + assert.ok(loaded.isSuccess); + // El agregado válido de A nunca puede escribirse con contexto explícito B. + assert.ok(loaded.data.update({ name: Name.create("Allowed customer").data }).isSuccess); + const wrong = await runtime.database.transaction((transaction) => + customers.update(otherCompanyId, loaded.data, transaction) + ); + assert.ok(wrong.isFailure); + const originalRow = await CustomerModel.findByPk(id.toPrimitive()); + assert.ok(originalRow); + const foreignAggregate = new SequelizeCustomerDomainMapper().mapToDomain( + CustomerModel.build({ ...originalRow.get(), company_id: b.id }) + ); + assert.ok(foreignAggregate.isSuccess); + const deniedAtSql = await runtime.database.transaction((transaction) => + customers.update(otherCompanyId, foreignAggregate.data, transaction) + ); + assert.ok(deniedAtSql.isFailure); + assert.ok(deniedAtSql.error.name.includes("NotFound")); + assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.name, "Original"); + + const missing = await customers.getByIdInCompany(otherCompanyId, id); + assert.ok(missing.isFailure); + assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.company_id, a.id); + await assert.rejects( + runtime.database.transaction(async (transaction) => { + const updated = await customers.update(companyId, loaded.data, transaction); + assert.ok(updated.isSuccess); + assert.equal( + (await CustomerModel.findByPk(id.toPrimitive(), { transaction }))?.name, + "Allowed customer" + ); + // El mismo update sin cambios debe funcionar también con driver real. + assert.ok((await customers.update(companyId, loaded.data, transaction)).isSuccess); + throw new Error("forced rollback"); + }), + /forced rollback/ + ); + + assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.name, "Original"); + assert.ok( + ( + await runtime.database.transaction((transaction) => + customers.update(companyId, loaded.data, transaction) + ) + ).isSuccess + ); + assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.name, "Allowed customer"); + + const seriesProps: DocumentSeriesCreateProps = { + companyId, + branchId: Maybe.none(), + documentType: DocumentType.proforma(), + code: DocumentSeriesCode.create("PF").data, + name: Name.create("Original series").data, + description: Maybe.none(), + prefix: Maybe.none(), + suffix: Maybe.none(), + nextNumber: DocumentSeriesNumber.create(1).data, + padding: 4, + validFrom: Maybe.none(), + validTo: Maybe.none(), + isDefault: false, + isActive: true, + hasAssignedNumbers: false, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: Maybe.none(), + }; + const series = DocumentSeries.create(seriesProps).data; + const seriesRepo = new SequelizeDocumentSeriesRepository( + new SequelizeDocumentSeriesDomainMapper(), + runtime.database + ); + assert.ok( + (await runtime.database.transaction((transaction) => seriesRepo.save(series, transaction))) + .isSuccess + ); + const foreignSeries = DocumentSeries.create( + { ...seriesProps, companyId: otherCompanyId, name: Name.create("Foreign change").data }, + series.id + ).data; + assert.ok( + ( + await runtime.database.transaction((transaction) => + seriesRepo.save(foreignSeries, transaction) + ) + ).isFailure + ); + assert.equal((await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.company_id, a.id); + assert.equal( + (await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.name, + "Original series" + ); + const changedSeries = DocumentSeries.create( + { ...seriesProps, name: Name.create("Allowed series").data }, + series.id + ).data; + await assert.rejects( + runtime.database.transaction(async (transaction) => { + assert.ok((await seriesRepo.save(changedSeries, transaction)).isSuccess); + assert.equal( + (await DocumentSeriesModel.findByPk(series.id.toPrimitive(), { transaction }))?.name, + "Allowed series" + ); + throw new Error("forced rollback"); + }), + /forced rollback/ + ); + assert.equal( + (await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.name, + "Original series" + ); + assert.ok( + ( + await runtime.database.transaction((transaction) => + seriesRepo.save(changedSeries, transaction) + ) + ).isSuccess + ); + assert.equal( + (await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.name, + "Allowed series" + ); + + const recurring = RecurringProforma.create({ companyId, name: "Original recurring" }).data; + const recurringRepo = new SequelizeRecurringProformaRepository(); + assert.ok( + ( + await runtime.database.transaction((transaction) => + recurringRepo.save(recurring, transaction) + ) + ).isSuccess + ); + const foreignRecurring = RecurringProforma.create( + { companyId: otherCompanyId, name: "Foreign recurring" }, + recurring.id + ).data; + assert.ok( + ( + await runtime.database.transaction((transaction) => + recurringRepo.save(foreignRecurring, transaction) + ) + ).isFailure + ); + assert.equal( + (await RecurringProformaModel.findByPk(recurring.id.toPrimitive()))?.company_id, + a.id + ); + const changedRecurring = RecurringProforma.create( + { companyId, name: "Allowed recurring" }, + recurring.id + ).data; + await assert.rejects( + runtime.database.transaction(async (transaction) => { + assert.ok((await recurringRepo.save(changedRecurring, transaction)).isSuccess); + assert.equal( + (await RecurringProformaModel.findByPk(recurring.id.toPrimitive(), { transaction }))?.name, + "Allowed recurring" + ); + throw new Error("forced rollback"); + }), + /forced rollback/ + ); + assert.equal( + (await RecurringProformaModel.findByPk(recurring.id.toPrimitive()))?.name, + "Original recurring" + ); + assert.ok( + ( + await runtime.database.transaction((transaction) => + recurringRepo.save(changedRecurring, transaction) + ) + ).isSuccess + ); + assert.equal( + (await RecurringProformaModel.findByPk(recurring.id.toPrimitive()))?.name, + "Allowed recurring" + ); +}); diff --git a/apps/server/src/testing/http/tenant-authorization.http.test.ts b/apps/server/src/testing/http/tenant-authorization.http.test.ts new file mode 100644 index 00000000..37462e80 --- /dev/null +++ b/apps/server/src/testing/http/tenant-authorization.http.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { getServiceScoped } from "@erp/core/api"; +import type { IIdentityPublicServices } from "@erp/identity/api"; +import { UniqueID } from "@repo/rdx-ddd"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + findCompanyRow, + grantTestPermissions, + loginByApi, +} from "./http-test-fixtures.ts"; +import { createHttpTestRuntime } from "./http-test-runtime.ts"; + +test("Companies aplica auth, membership, permiso y scope a lectura, actualización y baja", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(() => runtime.stop()); + const account = await createTestAccount(runtime.database, { + email: `${randomUUID()}@example.com`, + password: "TestPassword123!", + }); + const company = await createTestCompany(runtime.database, { legalName: "Company A" }); + const other = await createTestCompany(runtime.database, { legalName: "Company B" }); + const outsider = await createTestCompany(runtime.database, { legalName: "Company C" }); + const membership = await createTestMembership(runtime.database, { + accountId: account.id, + companyId: company.id, + }); + const otherMembership = await createTestMembership(runtime.database, { + accountId: account.id, + companyId: other.id, + }); + // Memberships activas válidas: rol explícito sin el permiso de Companies que se va a probar. + for (const [membershipId, companyId] of [ + [membership.id, company.id], + [otherMembership.id, other.id], + ]) { + await grantTestPermissions(runtime.database, { + membershipId, + companyId, + permissionCodes: ["identity:permissions:read"], + }); + } + const login = await loginByApi({ + request: runtime.request, + email: account.email, + password: account.password, + }); + assert.equal(login.status, 200); + assert.ok(login.body && typeof login.body === "object" && "access_token" in login.body); + assert.equal(typeof login.body.access_token, "string"); + const token = String(login.body.access_token); + const headers = { + ...buildCompanyHeaders({ accessToken: token, companyId: company.id }), + "content-type": "application/json", + }; + const identity = getServiceScoped( + "authorization-test", + ["identity"], + "identity:general" + ); + const direct = await identity.authorization.can({ + accountId: UniqueID.create(account.id).data, + companyId: UniqueID.create(company.id).data, + permissionCode: "companies:companies:read", + }); + assert.equal(direct.isSuccess, true, direct.isFailure ? direct.error.message : undefined); + assert.equal(direct.data, false); + assert.equal((await runtime.request(`/api/v1/companies/${company.id}`)).status, 401); + assert.equal((await runtime.request(`/api/v1/companies/${company.id}`, { headers })).status, 403); + assert.equal( + ( + await runtime.request("/api/v1/companies/current", { + method: "PATCH", + headers, + body: JSON.stringify({ legal_name: "Denied" }), + }) + ).status, + 403 + ); + assert.equal( + (await runtime.request(`/api/v1/companies/${company.id}/disable`, { method: "POST", headers })) + .status, + 403 + ); + assert.equal((await findCompanyRow(runtime.database, company.id))?.legal_name, "Company A"); + await grantTestPermissions(runtime.database, { + membershipId: membership.id, + companyId: company.id, + permissionCodes: ["companies:companies:read", "identity:companies:update"], + }); + assert.equal((await runtime.request(`/api/v1/companies/${company.id}`, { headers })).status, 200); + assert.equal( + ( + await runtime.request("/api/v1/companies/current", { + method: "PATCH", + headers, + body: JSON.stringify({ legal_name: "Allowed" }), + }) + ).status, + 200 + ); + assert.equal( + ( + await runtime.request(`/api/v1/companies/${other.id}`, { + headers: buildCompanyHeaders({ accessToken: token, companyId: other.id }), + }) + ).status, + 403 + ); + assert.equal( + ( + await runtime.request(`/api/v1/companies/${outsider.id}`, { + headers: buildCompanyHeaders({ accessToken: token, companyId: outsider.id }), + }) + ).status, + 403 + ); + assert.equal((await runtime.request(`/api/v1/companies/${other.id}`, { headers })).status, 403); + assert.equal((await findCompanyRow(runtime.database, company.id))?.legal_name, "Allowed"); + assert.equal( + (await runtime.request(`/api/v1/companies/${company.id}/disable`, { method: "POST", headers })) + .status, + 200 + ); +}); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 9e131f32..bfd5df7d 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -12,7 +12,7 @@ import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import DineroFactory from "dinero.js"; -import { Suspense } from "react"; +import { Suspense, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { I18nextProvider } from "react-i18next"; import { RouterProvider } from "react-router-dom"; @@ -23,33 +23,38 @@ import { getAppRouter } from "./routes"; export const App = () => { DineroFactory.globalLocale = "es-ES"; - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time - }, - }, - }); + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time + }, + }, + }) + ); console.log("import.meta.env.VITE_API_SERVER_URL => ", import.meta.env.VITE_API_SERVER_URL); - const axiosInstance = createAxiosInstance({ - baseURL: import.meta.env.VITE_API_SERVER_URL, - getAccessToken: () => authTokenStorage.getAccessToken(), - getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(), - onRefreshSession: () => getIdentityAuthSessionRuntimeHandlers().refreshAccessToken(), - onAuthError: () => { - getIdentityAuthSessionRuntimeHandlers().clearSession(); + const [axiosInstance] = useState(() => + createAxiosInstance({ + baseURL: import.meta.env.VITE_API_SERVER_URL, + getAccessToken: () => authTokenStorage.getAccessToken(), + getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(), + onRefreshSession: () => getIdentityAuthSessionRuntimeHandlers().refreshAccessToken(), + onAuthError: () => { + getIdentityAuthSessionRuntimeHandlers().clearSession(); - if (window.location.pathname !== "/login") { - window.location.replace("/login"); - } - }, - }); + if (window.location.pathname !== "/login") { + window.location.replace("/login"); + } + }, + }) + ); - const dataSource = createAxiosDataSource(axiosInstance); - const appRouter = getAppRouter(); + const [dataSource] = useState(() => createAxiosDataSource(axiosInstance)); + const [appRouter] = useState(() => getAppRouter()); return ( diff --git a/apps/web/src/home/hooks/use-home-customers-total-query.ts b/apps/web/src/home/hooks/use-home-customers-total-query.ts index b992f3eb..ab5333bd 100644 --- a/apps/web/src/home/hooks/use-home-customers-total-query.ts +++ b/apps/web/src/home/hooks/use-home-customers-total-query.ts @@ -1,13 +1,15 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import { getHomeCustomers } from "../api/home.api"; export const useHomeCustomersTotalQuery = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: ["home", "customers", "total"], + queryKey: companyQueryKey(queryScope, ["home", "customers", "total"]), queryFn: async ({ signal }) => { const response = await getHomeCustomers(dataSource, { signal, diff --git a/apps/web/src/home/hooks/use-home-monthly-billing-query.ts b/apps/web/src/home/hooks/use-home-monthly-billing-query.ts index e8519a6e..3c9dc0fb 100644 --- a/apps/web/src/home/hooks/use-home-monthly-billing-query.ts +++ b/apps/web/src/home/hooks/use-home-monthly-billing-query.ts @@ -1,5 +1,6 @@ import { MoneyDTOHelper } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { MoneyHelper } from "@repo/rdx-utils"; import { useQuery } from "@tanstack/react-query"; import { endOfMonth, format, startOfMonth } from "date-fns"; @@ -10,12 +11,19 @@ import type { HomeMonthlyBilling } from "../entities/home.entity"; const MONTHLY_BILLING_PAGE_SIZE = 100; export const useHomeMonthlyBillingQuery = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd"); const monthEnd = format(endOfMonth(new Date()), "yyyy-MM-dd"); return useQuery({ - queryKey: ["home", "issued-invoices", "monthly-billing", monthStart, monthEnd], + queryKey: companyQueryKey(queryScope, [ + "home", + "issued-invoices", + "monthly-billing", + monthStart, + monthEnd, + ]), queryFn: async ({ signal }) => { const response = await getHomeIssuedInvoices(dataSource, { signal, diff --git a/apps/web/src/home/hooks/use-home-proformas-overview-query.ts b/apps/web/src/home/hooks/use-home-proformas-overview-query.ts index f50813c1..b44bcebb 100644 --- a/apps/web/src/home/hooks/use-home-proformas-overview-query.ts +++ b/apps/web/src/home/hooks/use-home-proformas-overview-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import { getHomeProformas } from "../api/home.api"; @@ -18,10 +19,11 @@ export interface HomeProformasOverview { } export const useHomeProformasOverviewQuery = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: ["home", "proformas", "overview"], + queryKey: companyQueryKey(queryScope, ["home", "proformas", "overview"]), queryFn: async ({ signal }) => { const response = await getHomeProformas(dataSource, { signal, diff --git a/apps/web/src/home/hooks/use-home-recent-activity-query.ts b/apps/web/src/home/hooks/use-home-recent-activity-query.ts index 263fe21f..b362efe7 100644 --- a/apps/web/src/home/hooks/use-home-recent-activity-query.ts +++ b/apps/web/src/home/hooks/use-home-recent-activity-query.ts @@ -1,5 +1,6 @@ import { MoneyDTOHelper } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useQueries } from "@tanstack/react-query"; import { getHomeIssuedInvoices, getHomeProformas } from "../api/home.api"; @@ -8,12 +9,13 @@ import type { HomeRecentActivityItem } from "../entities/home.entity"; const RECENT_ITEMS_LIMIT = 5; export const useHomeRecentActivityQuery = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const [recentProformasQuery, recentIssuedInvoicesQuery] = useQueries({ queries: [ { - queryKey: ["home", "proformas", "recent"], + queryKey: companyQueryKey(queryScope, ["home", "proformas", "recent"]), queryFn: async ({ signal }: { signal: AbortSignal }) => { const response = await getHomeProformas(dataSource, { signal, @@ -42,7 +44,7 @@ export const useHomeRecentActivityQuery = () => { }, }, { - queryKey: ["home", "issued-invoices", "recent"], + queryKey: companyQueryKey(queryScope, ["home", "issued-invoices", "recent"]), queryFn: async ({ signal }: { signal: AbortSignal }) => { const response = await getHomeIssuedInvoices(dataSource, { signal, diff --git a/apps/web/src/layout/app-company-switcher.tsx b/apps/web/src/layout/app-company-switcher.tsx index 41c3f943..723b0530 100644 --- a/apps/web/src/layout/app-company-switcher.tsx +++ b/apps/web/src/layout/app-company-switcher.tsx @@ -37,6 +37,7 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => { isChangingCompany && "cursor-wait opacity-80", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" )} + data-testid="company-switcher-trigger" disabled={isDisabled} type="button" > @@ -72,9 +73,10 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => { return ( handleSelectCompany(company.id)} + onClick={() => handleSelectCompany(company.id)} > {company.initials} diff --git a/apps/web/src/layout/app-fullscreen-layout.tsx b/apps/web/src/layout/app-fullscreen-layout.tsx index 301dbfd6..ba0ceb40 100644 --- a/apps/web/src/layout/app-fullscreen-layout.tsx +++ b/apps/web/src/layout/app-fullscreen-layout.tsx @@ -1,7 +1,7 @@ import type React from "react"; -import { Outlet } from "react-router-dom"; import { AppMain } from "./app-main"; +import { CompanyPageOutlet } from "./company-page-outlet"; interface AppFullscreenLayoutFrameProps { children: React.ReactNode; @@ -18,7 +18,7 @@ export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameP export const AppFullscreenLayout = () => { return ( - + ); }; diff --git a/apps/web/src/layout/app-sidebar-layout.tsx b/apps/web/src/layout/app-sidebar-layout.tsx index e815cf66..88e462b8 100644 --- a/apps/web/src/layout/app-sidebar-layout.tsx +++ b/apps/web/src/layout/app-sidebar-layout.tsx @@ -1,10 +1,10 @@ import { SidebarInset, SidebarProvider } from "@repo/shadcn-ui/components"; import type * as React from "react"; -import { Outlet } from "react-router-dom"; import { AppMain } from "./app-main"; import { AppSidebar } from "./app-sidebar"; import { AppTopbar } from "./app-topbar"; +import { CompanyPageOutlet } from "./company-page-outlet"; interface AppSidebarLayoutFrameProps { children: React.ReactNode; @@ -43,7 +43,7 @@ export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps) export const AppSidebarLayout = () => { return ( - + ); }; diff --git a/apps/web/src/layout/company-page-outlet.tsx b/apps/web/src/layout/company-page-outlet.tsx new file mode 100644 index 00000000..b70a570a --- /dev/null +++ b/apps/web/src/layout/company-page-outlet.tsx @@ -0,0 +1,14 @@ +import { useCompanyQueryScope } from "@erp/core/hooks"; +import { useIdentityAuthSession } from "@erp/identity/client"; +import { LoadingOverlay } from "@repo/rdx-ui/components"; +import { Outlet } from "react-router-dom"; + +/** Reinicia formularios/selecciones empresariales sin reiniciar navegación, tema ni toasts. */ +export function CompanyPageOutlet() { + const scope = useCompanyQueryScope(); + const { isLoading } = useIdentityAuthSession(); + if (isLoading) { + return ; + } + return ; +} diff --git a/apps/web/src/lib/hooks/use-app-company-switcher-controller.ts b/apps/web/src/lib/hooks/use-app-company-switcher-controller.ts index 2e2b5064..237117f5 100644 --- a/apps/web/src/lib/hooks/use-app-company-switcher-controller.ts +++ b/apps/web/src/lib/hooks/use-app-company-switcher-controller.ts @@ -1,6 +1,5 @@ import { useIdentityAuthSession } from "@erp/identity/client"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; -import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; function resolveCompanyDisplayName(legalName: string, tradeName: string | null) { @@ -37,7 +36,6 @@ function resolveErrorMessage(error: unknown) { } export function useAppCompanySwitcherController() { - const queryClient = useQueryClient(); const { activeCompany, availableCompanies, changeActiveCompany } = useIdentityAuthSession(); const [isChangingCompany, setIsChangingCompany] = useState(false); @@ -67,7 +65,7 @@ export function useAppCompanySwitcherController() { }; }); - const handleSelectCompany = async (companyId: string) => { + const selectCompany = async (companyId: string) => { if (isChangingCompany || companyId === activeCompany?.id) { return; } @@ -82,21 +80,21 @@ export function useAppCompanySwitcherController() { return; } - await queryClient.cancelQueries(); - queryClient.removeQueries({ type: "inactive" }); - await queryClient.resetQueries({ type: "active" }); - showSuccessToast( "Active company updated", `${resolveCompanyDisplayName(nextActiveCompany.legalName, nextActiveCompany.tradeName)} is now active.` ); - } catch (error) { - showErrorToast("Unable to switch company", resolveErrorMessage(error)); } finally { setIsChangingCompany(false); } }; + const handleSelectCompany = (companyId: string): void => { + selectCompany(companyId).catch((error: unknown) => { + showErrorToast("Unable to switch company", resolveErrorMessage(error)); + }); + }; + return { activeCompany: activeCompanyView, companies: companyOptions, diff --git a/apps/web/src/onboarding/hooks/use-initial-onboarding.ts b/apps/web/src/onboarding/hooks/use-initial-onboarding.ts index 6486b1de..e3cccab9 100644 --- a/apps/web/src/onboarding/hooks/use-initial-onboarding.ts +++ b/apps/web/src/onboarding/hooks/use-initial-onboarding.ts @@ -1,4 +1,5 @@ import { useDataSource } from "@erp/core/hooks"; +import { useIdentityAuthSession } from "@erp/identity/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -7,27 +8,31 @@ import { getInitialOnboardingStatus, provisionInitialOnboarding, } from "../api/initial-onboarding.api"; -export const initialOnboardingStatusKey = ["onboarding", "initial"] as const; +export const initialOnboardingStatusKey = (sessionId: string | null) => + ["onboarding", sessionId, "initial"] as const; export const useInitialOnboardingStatus = () => { + const { querySessionId } = useIdentityAuthSession(); const ds = useDataSource(); return useQuery({ - queryKey: initialOnboardingStatusKey, + queryKey: initialOnboardingStatusKey(querySessionId), queryFn: ({ signal }) => getInitialOnboardingStatus(ds, signal), }); }; export const useProvisionInitialOnboarding = () => { + const { querySessionId } = useIdentityAuthSession(); const ds = useDataSource(); const qc = useQueryClient(); return useMutation({ mutationFn: () => provisionInitialOnboarding(ds), - onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v), + onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey(querySessionId), v), }); }; export const useCompleteInitialOnboarding = () => { + const { querySessionId } = useIdentityAuthSession(); const ds = useDataSource(); const qc = useQueryClient(); return useMutation({ mutationFn: (v: InitialCompanyInput) => completeInitialOnboarding(ds, v), - onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v), + onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey(querySessionId), v), }); }; diff --git a/docs/architecture/backend-architecture.md b/docs/architecture/backend-architecture.md index d53b3182..3d4135a9 100644 --- a/docs/architecture/backend-architecture.md +++ b/docs/architecture/backend-architecture.md @@ -38,3 +38,17 @@ Los builders de salida materializan contratos definidos por schemas Zod. Son det - [DTOs y schemas](./dto-guidelines.md) - [Límites entre módulos](./module-boundaries.md) - [Pruebas backend](./testing-guidelines.md) + +## Alcance empresarial de persistencia + +Toda mutación de entidad empresarial debe expresar el alcance en el puerto y en la condición +efectiva de escritura, no solo en una lectura previa. El patrón actualizado de Customers/catálogos/ +proformas es `update(companyId, aggregate, transaction)` con `WHERE id AND company_id`. +No incluir `company_id` entre campos reasignables. Los puertos `save(aggregate, transaction)` +de series/recurrencias llevan el alcance inmutable en el agregado: tampoco pueden hacer un upsert +por PK que traslade la raíz a otra empresa. La lectura interna de colisión no autoriza la escritura. + +Cero filas exige distinguir ausencia de no-op usando únicamente el mismo alcance y transacción; +no revelar existencia en otra empresa. Comprobar la raíz antes de sustituir sus hijos. Conservar +resolvers de pertenencia, invariantes y auditoría. Companies es la propia raíz empresarial: su PK +es la empresa y no necesita una columna `company_id` artificial. diff --git a/docs/architecture/identity-migration-status.md b/docs/architecture/identity-migration-status.md index 0ad12afa..045eff98 100644 --- a/docs/architecture/identity-migration-status.md +++ b/docs/architecture/identity-migration-status.md @@ -24,46 +24,16 @@ Estado: - sin helpers locales de auth - usan el middleware tenant canónico documentado en `tenant-context.md` -### Backend autenticado sin tenant +### Companies y autorización por operación -`modules/companies` usa el middleware autenticado sin tenant documentado en `tenant-context.md`. +Las operaciones globales no requieren tenant: `POST /companies` exige autorización SYSTEM y +`GET /companies/available` exige autenticación; el Super-administrador ve todas las empresas activas. +Las consultas/configuración de empresa y endpoints por ID son tenant-scoped. -Esto aplica a: - -```http -POST /companies -GET /companies/available -GET /companies/:company_id -PATCH /companies/:company_id -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`. +La matriz vigente de permisos, sus excepciones y el registro extensible están en +[tenant-context.md](./tenant-context.md). La política V1 de edición solo por membership ha sido +sustituida en las operaciones Companies allí enumeradas. Los demás módulos conservan sus políticas +hasta una adopción explícita del guard; no se afirma cobertura granular universal. ## Frontend actual @@ -77,9 +47,9 @@ Los detalles del flujo implementado, headers y refresh automático se mantienen Sigue pendiente revisar o retirar consumidores legacy residuales antes de eliminarlo por completo. -### `modules/supplier` +### `modules/suppliers` -Queda fuera de esta migración por ahora. +Ya usa el contexto tenant canónico; no tiene aún permisos granulares por operación. ## Búsquedas útiles de control diff --git a/docs/architecture/tenant-context.md b/docs/architecture/tenant-context.md index 4236ec90..8c6dcbf0 100644 --- a/docs/architecture/tenant-context.md +++ b/docs/architecture/tenant-context.md @@ -47,18 +47,19 @@ Actualmente ya valida: 2. account autenticable 3. `X-Company-Id` presente 4. `X-Company-Id` con formato UUID válido -5. membership `active` en `identity_company_memberships` +5. membership `active` en `identity_company_memberships` o titularidad global de Super-administrador 6. company `active` en `companies` 7. solo entonces rellena `req.user.companyId` Esto significa que hoy el contexto tenant garantiza: ```txt -membership activa +(membership activa o Super-administrador activo) + company activa ``` -pero no enforcement de permisos granulares por operación. +La autorización granular se aplica después, mediante `requirePermission(authorization, code)`. +El tenant por sí solo no concede permisos de operación. ## Semántica esperada de errores @@ -70,8 +71,8 @@ sin X-Company-Id -> 403 X-Company-Id inválido -> 400 company inexistente -> 403 company disabled -> 403 -membership inexistente -> 403 -membership disabled/invited -> 403 +membership inexistente en una cuenta ordinaria -> 403 +membership disabled/invited en una cuenta ordinaria -> 403 membership active + company active -> entra al controller ``` @@ -103,7 +104,7 @@ Actualmente usan `requireIdentityTenant(params)`: - `modules/customer-invoices` - `modules/factuges` -`modules/supplier` queda fuera de esta migración por ahora. +`modules/suppliers` también usa el contexto tenant canónico. ## Qué no debe ir en el token @@ -127,16 +128,22 @@ GET /companies/current PATCH /companies/current ``` -Política V1: +Política actual de lectura `GET /companies/current`: autenticación, membership y empresa activas. +No se añade un permiso a esta consulta usada por selección/configuración actual. -```txt -usuario autenticado -+ X-Company-Id válido -+ membership activa en esa company -+ company activa -``` +La edición `PATCH /companies/current` y `PATCH /companies/:company_id`, así como +`POST /companies/:company_id/enable` y `/disable`, requieren además +`identity:companies:update`. Antes bastaba el contexto empresarial. -No se aplica todavía un permiso específico como `identity:companies:update`. +`GET /companies/:company_id` requiere `companies:companies:read`. Antes bastaba el tenant. +El parámetro sigue comparándose con la empresa autenticada; el permiso de A no autoriza B. + +Las operaciones globales de cuenta y consulta de empresas disponibles siguen autenticadas. +`POST /companies` exige `companies:companies:create` (SYSTEM); su transacción crea la empresa +y asigna al actor su administrador inicial con todos los COMPANY actuales. +Los POST de onboarding inicial exigen `identity:system:initial-onboarding` (SYSTEM). +La lectura de estado del onboarding sigue autenticada. No se introduce autorización granular +en todas las operaciones de todos los módulos de una vez. ## Document context @@ -145,3 +152,71 @@ Si un flujo documental necesita `companySlug`, debe resolverlo desde `companies` ```txt companyId -> companies:general.finder.findById(...) -> slug ``` + +## Autorización y registro de permisos + +La raíz de cada módulo declara `permissions: PermissionDefinition[]` en su manifest backend. +El runtime compone y valida códigos, namespace y duplicados **antes de cualquier setup**. +Cada definición declara `scope: "SYSTEM" | "COMPANY"`; un scope ausente o inválido impide el setup. +Registrar un COMPANY amplía el rol reservado Administradores durante el mantenimiento de arranque +y en nuevas altas. Los demás roles conservan sus permisos explícitos. Identity no importa los módulos contribuyentes. + +La raíz del consumidor resuelve `identity:general` y suministra `authorization` a sus rutas. +La API pública neutral es `authorization.can({ accountId, companyId, permissionCode, transaction? })`, +que devuelve `Result`. Código desconocido o no asignado deniega; fallos técnicos no conceden. +El guard `requirePermission(authorization, code)` se instala después de auth/tenant y solo toma +cuenta/empresa de `req.user`. Falta de autenticación devuelve 401; tenant/permiso ausente 403; +fallo técnico 500 con mensaje seguro. No depende de permisos frontend. + +`CompanyAdminRoleEnsurer` obtiene todos los COMPANY del catálogo completo suministrado por el runtime. +Identifica el rol reservado por `code = admin` e `isSystem = true`, con nombre de alta `Administradores`. +Añade los códigos ausentes, conserva adicionales y fechas cuando no cambia nada, y nunca incorpora SYSTEM. +El nombre histórico `Administrator` puede conservarse en filas existentes: no identifica privilegios. +Los roles normales no se modifican. Poseer un código SYSTEM dentro de un JSON de rol empresarial tampoco +concede autorización global: `canSystem` solo consulta la capacidad global persistida. + +El warmup obligatorio de Identity enumera únicamente roles `admin` reservados, los relee con bloqueo +y aplica la unión en una transacción por empresa. Se ejecuta tras preparar el esquema y antes de servir HTTP; +si falla o expira, impide arrancar, aunque los warmups opcionales no sean estrictos. Se puede repetir. +No hay escrituras al resolver permisos ni sincronización durante requests. + +### Super-administrador y bootstrap + +Identity persiste una titularidad global en `identity_system_owner`, fila `id = 1`, vinculada a una cuenta activa. +No se deriva de email, nombre, fecha de alta ni rol empresarial. `systemBootstrap.create(props)` es la +entrada confiable de composición que crea la primera cuenta y reserva la titularidad en la misma transacción: +bloquea el singleton y rechaza un almacén que ya contiene cuentas. No existe un endpoint público de promoción. +El bootstrap recibe Value Objects, incluido el hash generado por el hasher de Identity; no recibe contraseñas por HTTP. +El usuario creado puede autenticarse y completar el onboarding inicial existente. + +La cuenta global dispone de todos los permisos registrados según su scope, también los futuros. +`authorization.canSystem({ accountId, permissionCode, transaction? })` exige SYSTEM y no requiere tenant. +`authorization.can({ accountId, companyId, permissionCode, transaction? })` exige COMPANY y se invoca después +de validar el tenant; el permiso global no permite omitir `X-Company-Id` ni usar una empresa inexistente/inactiva. +El selector de empresas disponibles incluye todas las activas para el Super-administrador. +Las cuentas ordinarias reciben únicamente sus empresas con membership activa. + +Los catálogos que usan `requireIdentityTenantMembership` también comprueban empresa activa: el host suministra +`tenantCompanyFinder` durante `startModules`, una vez construidos los módulos. Usa el finder público de Companies, +sin imports de su infraestructura ni dependencia circular de setup Catalogs → Companies → Catalogs. +Un host que no suministre ese puerto no puede iniciar esas rutas. El helper conserva su nombre por compatibilidad. + +### Bases existentes + +Con `DB_SYNC_MODE=none`, aplicar expresamente `modules/identity/sql/001-system-owner.sql` antes de iniciar +esta versión. La DDL es idempotente y no promueve cuentas. En una base existente debe identificarse y +acreditarse la cuenta inicial antes de establecer expresamente su titularidad mediante un procedimiento +administrativo revisado. No se ha ejecutado esa asignación ni ninguna DDL sobre datos reales. +No se elige automáticamente al primer admin ni a la cuenta más antigua. +Los roles Administradores existentes sí reciben los COMPANY en el arranque, conservando sus permisos adicionales. + +Patrón de composición (referencia real: `companies.di.ts` y `companies.routes.ts`): + +```ts +// Composition root: resolver una sola vez y suministrar el contrato público al router. +const identity = params.getService("identity:general"); +// Route, después de autenticación y tenant: +router.patch("/current", tenant, requirePermission(identity.authorization, permissionCode), handler); +``` + +No reconstruir resolver, roles o repositories de Identity desde otro módulo. diff --git a/docs/frontend/frontend-architecture.md b/docs/frontend/frontend-architecture.md index fa52a552..eba3ca65 100644 --- a/docs/frontend/frontend-architecture.md +++ b/docs/frontend/frontend-architecture.md @@ -153,3 +153,37 @@ El estado remoto permanece en TanStack Query. No se duplica en estado local salv - No introducir acceso a red en UI ni reglas de negocio en Pages. - Mantener routing, sesión, tenant y sistema visual como responsabilidades separadas. - Cambios de API visual pública deben sincronizar implementación, [UI system](./ui-system.md), UI Kit y tests. + +## Caché empresarial y cambios de sesión + +Los fragmentos de claves de cada recurso se componen en consultas, invalidaciones y escrituras: + +```ts +import { companyQueryKey } from "@erp/core/query"; +import { useCompanyQueryScope } from "@erp/core/hooks"; + +const scope = useCompanyQueryScope(); +const key = companyQueryKey(scope, LIST_CUSTOMERS_QUERY_KEY(criteria)); +``` + +La clave efectiva contiene empresa y sesión local, además del fragmento del recurso. +Los fragmentos/prefijos de las factories no son claves empresariales completas por sí solos. +La paginación usa `keepPreviousCompanyData`: nunca conserva placeholder de otra empresa/sesión. +Las estrategias de caché reciben el scope capturado por el hook; un callback tardío nunca debe +reconstruirlo consultando la empresa activa nueva. Las claves enumeradas por la caché ya están +compuestas y no deben envolverse de nuevo. Audit conserva sus filtros empresariales explícitos +y añade el mismo aislamiento de sesión. Cuenta y onboarding usan identidad de sesión sin empresa; +son operaciones globales legítimas, no consultas empresariales. + +`IdentityAuthSessionProvider` centraliza selección/cambio, cancelación antes de cambiar header, +limpieza y activación. `selectActiveCompany` y `changeActiveCompany` son asíncronas y usan el mismo +recorrido. Cierre/reemplazo de cuenta destruye caché e incrementa la generación local de sesión; +restore/refresh tardíos no pueden restablecer una sesión cerrada. No se modifican claims JWT. + +`CompanyPageOutlet` remonta solo el contenido empresarial al cambiar empresa/sesión: elimina +formularios, IDs y selección anterior sin reiniciar sidebar, tema ni toasts. El selector superior +no mantiene una segunda estrategia de limpieza. QueryClient, axios/datasource y router son estables +durante el montaje de App mediante inicialización diferida en useState. + +No basta limpiar un listado ni esconder permisos en React. Backend valida auth/tenant/permiso y +persistencia scoped; frontend evita reutilizar resultados de otra empresa/cuenta. diff --git a/modules/audit/src/web/shared/hooks/use-audit-entry.ts b/modules/audit/src/web/shared/hooks/use-audit-entry.ts index 416f1c4f..69a1a770 100644 --- a/modules/audit/src/web/shared/hooks/use-audit-entry.ts +++ b/modules/audit/src/web/shared/hooks/use-audit-entry.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useIdentityAuthSession } from "@erp/identity/client"; import { type DefaultError, useQuery } from "@tanstack/react-query"; @@ -8,12 +9,13 @@ import type { AuditEntryDetail } from "../entities/audit-entry-detail.entity"; import { auditQueryKeys } from "./audit-query-keys"; export const useAuditEntry = (id: string | null) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const { activeCompany } = useIdentityAuthSession(); const companyId = activeCompany?.id ?? "none"; return useQuery({ - queryKey: auditQueryKeys.detail(companyId, id ?? "none"), + queryKey: companyQueryKey(queryScope, auditQueryKeys.detail(companyId, id ?? "none")), queryFn: ({ signal }) => { if (!id) { throw new Error("auditEntryId is required"); diff --git a/modules/audit/src/web/shared/hooks/use-audit-list.ts b/modules/audit/src/web/shared/hooks/use-audit-list.ts index c3e53889..e471e80e 100644 --- a/modules/audit/src/web/shared/hooks/use-audit-list.ts +++ b/modules/audit/src/web/shared/hooks/use-audit-list.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { useIdentityAuthSession } from "@erp/identity/client"; import { type DefaultError, useQuery } from "@tanstack/react-query"; @@ -17,18 +18,23 @@ export const useAuditList = ( params: AuditSearchParams | null, options: UseAuditListOptions = {} ) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const { activeCompany } = useIdentityAuthSession(); const companyId = activeCompany?.id ?? "none"; return useQuery({ - queryKey: auditQueryKeys.list(companyId, params ?? { pageNumber: 0, pageSize: 50 }), + queryKey: companyQueryKey( + queryScope, + auditQueryKeys.list(companyId, params ?? { pageNumber: 0, pageSize: 50 }) + ), queryFn: async ({ signal }) => { const search = options.getSearchParams?.() ?? params ?? { pageNumber: 0, pageSize: 50 }; return mapAuditList(await searchAudit(dataSource, { search, signal })); }, enabled: activeCompany !== null && params !== null, - placeholderData: (previousData) => previousData, + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), refetchInterval: options.refetchInterval, }); }; diff --git a/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts b/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts index b6940e05..13de60cb 100644 --- a/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts +++ b/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts @@ -7,7 +7,11 @@ import type { PaymentMethodSummary } from "../models"; export interface IPaymentMethodRepository { create(paymentMethod: PaymentMethod, transaction?: unknown): Promise>; - update(paymentMethod: PaymentMethod, transaction?: unknown): Promise>; + update( + companyId: UniqueID, + paymentMethod: PaymentMethod, + transaction?: unknown + ): Promise>; existsByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts b/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts index cbf13dd2..004d2243 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts @@ -42,7 +42,7 @@ class InMemoryPaymentMethodRepository implements IPaymentMethodRepository { return Result.ok(); } - async update(paymentMethod: PaymentMethod): Promise> { + async update(_companyId: UniqueID, paymentMethod: PaymentMethod): Promise> { this.items.set(paymentMethod.id.toString(), paymentMethod); return Result.ok(); } diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-disabler.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-disabler.ts index abd014e2..7403f708 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-disabler.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-disabler.ts @@ -29,7 +29,11 @@ export class PaymentMethodDisabler implements IPaymentMethodDisabler { return Result.ok(paymentMethod); } - const persistenceResult = await this.repository.update(paymentMethod, transaction); + const persistenceResult = await this.repository.update( + paymentMethod.companyId, + paymentMethod, + transaction + ); if (persistenceResult.isFailure) { return Result.fail(persistenceResult.error); diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-enabler.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-enabler.ts index 90085132..d984be5e 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-enabler.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-enabler.ts @@ -29,7 +29,11 @@ export class PaymentMethodEnabler implements IPaymentMethodEnabler { return Result.ok(paymentMethod); } - const persistenceResult = await this.repository.update(paymentMethod, transaction); + const persistenceResult = await this.repository.update( + paymentMethod.companyId, + paymentMethod, + transaction + ); if (persistenceResult.isFailure) { return Result.fail(persistenceResult.error); diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts index eec5baf6..e053774d 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts @@ -43,7 +43,11 @@ export class PaymentMethodStatusChanger implements IPaymentMethodStatusChanger { return Result.ok(paymentMethod); } - const persistenceResult = await this.repository.update(paymentMethod, transaction); + const persistenceResult = await this.repository.update( + paymentMethod.companyId, + paymentMethod, + transaction + ); if (persistenceResult.isFailure) { return Result.fail(persistenceResult.error); diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts index 37e3ad3c..817dcfff 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts @@ -58,7 +58,7 @@ export class PaymentMethodUpdater implements IPaymentMethodUpdater { } // Persistir cambios - const saveResult = await this.repository.update(paymentMethod, transaction); + const saveResult = await this.repository.update(companyId, paymentMethod, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); diff --git a/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts b/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts index 08e8d3bf..b2b5fb84 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts @@ -72,7 +72,11 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS } if (unmarkResult.data) { - const saveCurrentResult = await this.repository.update(currentDefault, transaction); + const saveCurrentResult = await this.repository.update( + companyId, + currentDefault, + transaction + ); if (saveCurrentResult.isFailure) { return Result.fail(saveCurrentResult.error); } @@ -95,7 +99,11 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS } if (markResult.data) { - const savePaymentMethodResult = await this.repository.update(paymentMethod, transaction); + const savePaymentMethodResult = await this.repository.update( + companyId, + paymentMethod, + transaction + ); if (savePaymentMethodResult.isFailure) { return Result.fail(savePaymentMethodResult.error); } diff --git a/modules/catalogs/src/api/application/tax-definitions/__tests__/tax-definition-test-support.ts b/modules/catalogs/src/api/application/tax-definitions/__tests__/tax-definition-test-support.ts index 0d6abdd4..5f09330c 100644 --- a/modules/catalogs/src/api/application/tax-definitions/__tests__/tax-definition-test-support.ts +++ b/modules/catalogs/src/api/application/tax-definitions/__tests__/tax-definition-test-support.ts @@ -15,8 +15,10 @@ import { import type { TaxDefinitionSummary } from "../models"; import type { ITaxDefinitionRepository } from "../repositories"; -const valueOf = (result: Result): T => { - if (result.isFailure) throw result.error; +const unwrapResult = (result: Result): T => { + if (result.isFailure) { + throw result.error; + } return result.data; }; @@ -30,32 +32,32 @@ export const buildTaxDefinition = (params?: { const companyId = params?.companyId ?? UniqueID.generateNewID(); const allowedSurchargeCodes = params?.allowedSurchargeCodes; - return valueOf( + return unwrapResult( TaxDefinition.create( { companyId, - code: valueOf(TaxDefinitionCode.create("iva_21")), - name: valueOf(TaxDefinitionName.create("IVA general")), - description: Maybe.some(valueOf(TextValue.create("IVA aplicable"))), - rate: valueOf(TaxRate.create({ value: 2100, scale: 2 })), - taxFamily: valueOf(TaxFamily.create("iva")), - calculationBehavior: valueOf(TaxCalculationBehavior.create("additive")), - jurisdictionCountryCode: valueOf(CountryCode.create("ES")), - jurisdictionRegionCode: Maybe.some(valueOf(CountryRegionCode.create("ES-M"))), - taxScope: valueOf(TaxScope.create("domestic")), - invoiceNote: Maybe.some(valueOf(TextValue.create("Factura con IVA"))), + code: unwrapResult(TaxDefinitionCode.create("iva_21")), + name: unwrapResult(TaxDefinitionName.create("IVA general")), + description: Maybe.some(unwrapResult(TextValue.create("IVA aplicable"))), + rate: unwrapResult(TaxRate.create({ value: 2100, scale: 2 })), + taxFamily: unwrapResult(TaxFamily.create("iva")), + calculationBehavior: unwrapResult(TaxCalculationBehavior.create("additive")), + jurisdictionCountryCode: unwrapResult(CountryCode.create("ES")), + jurisdictionRegionCode: Maybe.some(unwrapResult(CountryRegionCode.create("ES-M"))), + taxScope: unwrapResult(TaxScope.create("domestic")), + invoiceNote: Maybe.some(unwrapResult(TextValue.create("Factura con IVA"))), allowedSurchargeCodes: allowedSurchargeCodes === null ? Maybe.none() : Maybe.some( (allowedSurchargeCodes ?? ["rec_5_2", "rec_1_4"]).map((code) => - valueOf(TaxDefinitionCode.create(code)) + unwrapResult(TaxDefinitionCode.create(code)) ) ), isSystem: params?.isSystem ?? false, isActive: params?.isActive ?? true, - validFrom: Maybe.some(valueOf(UtcDate.createFromISO("2026-01-01"))), - validTo: Maybe.some(valueOf(UtcDate.createFromISO("2026-12-31"))), + validFrom: Maybe.some(unwrapResult(UtcDate.createFromISO("2026-01-01"))), + validTo: Maybe.some(unwrapResult(UtcDate.createFromISO("2026-12-31"))), }, params?.id ?? UniqueID.generateNewID() ) @@ -73,8 +75,11 @@ export class InMemoryTaxDefinitionRepository implements ITaxDefinitionRepository return Result.ok(); } - async update(taxDefinition: TaxDefinition): Promise> { + async update(companyId: UniqueID, taxDefinition: TaxDefinition): Promise> { this.updateCalls += 1; + if (!taxDefinition.companyId.equals(companyId)) { + return Result.fail(new Error("Not found")); + } this.items.set(taxDefinition.id.toString(), taxDefinition); return Result.ok(); } @@ -82,7 +87,9 @@ export class InMemoryTaxDefinitionRepository implements ITaxDefinitionRepository async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { this.deleteCalls += 1; const found = await this.getByIdInCompany(companyId, id); - if (found.isFailure) return Result.fail(found.error); + if (found.isFailure) { + return Result.fail(found.error); + } this.deleted.add(id.toString()); return Result.ok(true); } diff --git a/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts b/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts index 029bf5ba..e5751955 100644 --- a/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts +++ b/modules/catalogs/src/api/application/tax-definitions/repositories/tax-definition-repository.interface.ts @@ -7,7 +7,11 @@ import type { TaxDefinitionSummary } from "../../tax-definitions/models"; export interface ITaxDefinitionRepository { create(taxDefinition: TaxDefinition, transaction?: unknown): Promise>; - update(taxDefinition: TaxDefinition, transaction?: unknown): Promise>; + update( + companyId: UniqueID, + taxDefinition: TaxDefinition, + transaction?: unknown + ): Promise>; deleteByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-status-changer.service.ts b/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-status-changer.service.ts index 03d64c34..c3c7b348 100644 --- a/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-status-changer.service.ts +++ b/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-status-changer.service.ts @@ -1,11 +1,15 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import type { ITaxDefinitionRepository } from "../repositories"; import type { TaxDefinition } from "../../../domain"; +import type { ITaxDefinitionRepository } from "../repositories"; export interface ITaxDefinitionStatusChanger { - enable(companyId: UniqueID, id: UniqueID, transaction?: unknown): Promise>; + enable( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; disable( companyId: UniqueID, id: UniqueID, @@ -29,7 +33,7 @@ export class TaxDefinitionStatusChanger implements ITaxDefinitionStatusChanger { } if (changeResult.data) { - const saveResult = await this.repository.update(taxDefinition, transaction); + const saveResult = await this.repository.update(companyId, taxDefinition, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); } @@ -51,7 +55,7 @@ export class TaxDefinitionStatusChanger implements ITaxDefinitionStatusChanger { } if (changeResult.data) { - const saveResult = await this.repository.update(taxDefinition, transaction); + const saveResult = await this.repository.update(companyId, taxDefinition, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); } diff --git a/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-updater.service.ts b/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-updater.service.ts index 5d13b523..aa62ae92 100644 --- a/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-updater.service.ts +++ b/modules/catalogs/src/api/application/tax-definitions/services/tax-definition-updater.service.ts @@ -34,7 +34,7 @@ export class TaxDefinitionUpdater implements ITaxDefinitionUpdater { } if (updateResult.data) { - const saveResult = await this.repository.update(taxDefinition, transaction); + const saveResult = await this.repository.update(companyId, taxDefinition, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); } diff --git a/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts b/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts index 0eaf3509..aa18345b 100644 --- a/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts +++ b/modules/catalogs/src/api/application/tax-regimes/repositories/tax-regime-repository.interface.ts @@ -7,7 +7,11 @@ import type { TaxRegimeSummary } from "../models"; export interface ITaxRegimeRepository { create(taxRegime: TaxRegime, transaction?: unknown): Promise>; - update(taxRegime: TaxRegime, transaction?: unknown): Promise>; + update( + companyId: UniqueID, + taxRegime: TaxRegime, + transaction?: unknown + ): Promise>; deleteByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-status-changer.service.ts b/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-status-changer.service.ts index 440597bc..8f4da6f6 100644 --- a/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-status-changer.service.ts +++ b/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-status-changer.service.ts @@ -32,7 +32,11 @@ export class TaxRegimeStatusChanger implements ITaxRegimeStatusChanger { return Result.ok(taxRegime); } - const persistenceResult = await this.repository.update(taxRegime, transaction); + const persistenceResult = await this.repository.update( + taxRegime.companyId, + taxRegime, + transaction + ); if (persistenceResult.isFailure) { return Result.fail(persistenceResult.error); } diff --git a/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-updater.service.ts b/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-updater.service.ts index 1cee4079..5e242e60 100644 --- a/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-updater.service.ts +++ b/modules/catalogs/src/api/application/tax-regimes/services/tax-regime-updater.service.ts @@ -42,7 +42,7 @@ export class TaxRegimeUpdater implements ITaxRegimeUpdater { } // Persistir cambios - const saveResult = await this.repository.update(taxRegime, transaction); + const saveResult = await this.repository.update(companyId, taxRegime, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); } diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/payment-method-company-scope.test.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/payment-method-company-scope.test.ts new file mode 100644 index 00000000..7c86cf4a --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/payment-method-company-scope.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { PaymentMethodModel } from "../models"; + +import { SequelizePaymentMethodRepository } from "./sequelize-payment-method.repository"; + +const companyId = UniqueID.generateNewID(); +const otherCompanyId = UniqueID.generateNewID(); +const id = UniqueID.generateNewID().toPrimitive(); + +for (const outcome of ["allowed", "foreign", "unchanged"] as const) { + const allowed = outcome !== "foreign"; + test(`escritura empresarial: resultado=${outcome}`, async (t) => { + const requestedCompany = allowed ? companyId : otherCompanyId; + const transaction = {}; + let modified = false; + const repository = new SequelizePaymentMethodRepository( + { + mapToPersistence: () => + Result.ok({ + id, + company_id: requestedCompany.toPrimitive(), + name: "Updated", + items: [], + taxes: [], + }), + } as never, + {} as never, + {} as never + ); + t.mock.method( + PaymentMethodModel, + "update", + async ( + _payload: unknown, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + if (options.where.company_id !== companyId.toPrimitive()) { + return [0]; + } + modified = true; + return [outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + PaymentMethodModel, + "findOne", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + + const result = await repository.update(requestedCompany, {} as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(modified, allowed); + if (!allowed) { + assert.equal(result.error.name.includes("NotFound"), true); + } + }); +} diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts index 8158ece9..3ab097cd 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts @@ -1,9 +1,4 @@ -import { - EntityNotFoundError, - InfrastructureRepositoryError, - SequelizeRepository, - translateSequelizeError, -} from "@erp/core/api"; +import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; import { type Collection, Maybe, Result } from "@repo/rdx-utils"; @@ -61,6 +56,7 @@ export class SequelizePaymentMethodRepository * @returns Result */ async update( + companyId: UniqueID, paymentMethod: PaymentMethod, transaction?: Transaction ): Promise> { @@ -70,17 +66,26 @@ export class SequelizePaymentMethodRepository return Result.fail(dtoResult.error); } - const { id, ...payload } = dtoResult.data; + const { id, company_id, ...payload } = dtoResult.data; + if (company_id !== companyId.toPrimitive()) { + return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id)); + } const [affected] = await PaymentMethodModel.update(payload, { - where: { id }, + where: { id, company_id: companyId.toPrimitive() }, transaction, individualHooks: true, }); if (affected === 0) { - return Result.fail( - new InfrastructureRepositoryError("Concurrency conflict or payment method not found") - ); + const exists = await PaymentMethodModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id: companyId.toPrimitive() }, + transaction, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id)); + } } return Result.ok(); diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts index 75b23884..390ca60d 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts @@ -1,9 +1,4 @@ -import { - EntityNotFoundError, - InfrastructureRepositoryError, - SequelizeRepository, - translateSequelizeError, -} from "@erp/core/api"; +import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import type { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Collection, Maybe, Result } from "@repo/rdx-utils"; @@ -47,6 +42,7 @@ export class SequelizeTaxDefinitionRepository } async update( + companyId: UniqueID, taxDefinition: TaxDefinition, transaction?: Transaction ): Promise> { @@ -56,17 +52,26 @@ export class SequelizeTaxDefinitionRepository return Result.fail(dtoResult.error); } - const { id, ...payload } = dtoResult.data as any; + const { id, company_id, ...payload } = dtoResult.data; + if (company_id !== companyId.toPrimitive()) { + return Result.fail(new EntityNotFoundError("TaxDefinition", "id", id)); + } const [affected] = await TaxDefinitionModel.update(payload, { - where: { id }, + where: { id, company_id: companyId.toPrimitive() }, transaction, individualHooks: true, }); if (affected === 0) { - return Result.fail( - new InfrastructureRepositoryError("Concurrency conflict or tax definition not found") - ); + const exists = await TaxDefinitionModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id: companyId.toPrimitive() }, + transaction, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("TaxDefinition", "id", id)); + } } return Result.ok(); diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/tax-definition-company-scope.test.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/tax-definition-company-scope.test.ts new file mode 100644 index 00000000..e6c69c25 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/tax-definition-company-scope.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { TaxDefinitionModel } from "../models"; + +import { SequelizeTaxDefinitionRepository } from "./sequelize-tax-definition.repository"; + +const companyId = UniqueID.generateNewID(); +const otherCompanyId = UniqueID.generateNewID(); +const id = UniqueID.generateNewID().toPrimitive(); + +for (const outcome of ["allowed", "foreign", "unchanged"] as const) { + const allowed = outcome !== "foreign"; + test(`escritura empresarial: resultado=${outcome}`, async (t) => { + const requestedCompany = allowed ? companyId : otherCompanyId; + const transaction = {}; + let modified = false; + const repository = new SequelizeTaxDefinitionRepository( + { + mapToPersistence: () => + Result.ok({ + id, + company_id: requestedCompany.toPrimitive(), + name: "Updated", + items: [], + taxes: [], + }), + } as never, + {} as never, + {} as never + ); + t.mock.method( + TaxDefinitionModel, + "update", + async ( + _payload: unknown, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + if (options.where.company_id !== companyId.toPrimitive()) { + return [0]; + } + modified = true; + return [outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + TaxDefinitionModel, + "findOne", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + + const result = await repository.update(requestedCompany, {} as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(modified, allowed); + if (!allowed) { + assert.equal(result.error.name.includes("NotFound"), true); + } + }); +} diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts index 42b52848..40b46148 100644 --- a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts @@ -1,9 +1,4 @@ -import { - EntityNotFoundError, - InfrastructureRepositoryError, - SequelizeRepository, - translateSequelizeError, -} from "@erp/core/api"; +import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; import { Collection, Maybe, Result } from "@repo/rdx-utils"; @@ -55,24 +50,37 @@ export class SequelizeTaxRegimeRepository * @param transaction - Transacción activa para la operación. * @returns Result */ - async update(taxRegime: TaxRegime, transaction?: Transaction): Promise> { + async update( + companyId: UniqueID, + taxRegime: TaxRegime, + transaction?: Transaction + ): Promise> { try { const dtoResult = this.domainMapper.mapToPersistence(taxRegime); if (dtoResult.isFailure) { return Result.fail(dtoResult.error); } - const { id, ...payload } = dtoResult.data; + const { id, company_id, ...payload } = dtoResult.data; + if (company_id !== companyId.toPrimitive()) { + return Result.fail(new EntityNotFoundError("TaxRegime", "id", id)); + } const [affected] = await TaxRegimeModel.update(payload, { - where: { id }, + where: { id, company_id: companyId.toPrimitive() }, transaction, individualHooks: true, }); if (affected === 0) { - return Result.fail( - new InfrastructureRepositoryError("Concurrency conflict or payment method not found") - ); + const exists = await TaxRegimeModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id: companyId.toPrimitive() }, + transaction, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("TaxRegime", "id", id)); + } } return Result.ok(); diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/tax-regime-company-scope.test.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/tax-regime-company-scope.test.ts new file mode 100644 index 00000000..b53fa25f --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/tax-regime-company-scope.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { TaxRegimeModel } from "../models"; + +import { SequelizeTaxRegimeRepository } from "./sequelize-tax-regime.repository"; + +const companyId = UniqueID.generateNewID(); +const otherCompanyId = UniqueID.generateNewID(); +const id = UniqueID.generateNewID().toPrimitive(); + +for (const outcome of ["allowed", "foreign", "unchanged"] as const) { + const allowed = outcome !== "foreign"; + test(`escritura empresarial: resultado=${outcome}`, async (t) => { + const requestedCompany = allowed ? companyId : otherCompanyId; + const transaction = {}; + let modified = false; + const repository = new SequelizeTaxRegimeRepository( + { + mapToPersistence: () => + Result.ok({ + id, + company_id: requestedCompany.toPrimitive(), + name: "Updated", + items: [], + taxes: [], + }), + } as never, + {} as never, + {} as never + ); + t.mock.method( + TaxRegimeModel, + "update", + async ( + _payload: unknown, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + if (options.where.company_id !== companyId.toPrimitive()) { + return [0]; + } + modified = true; + return [outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + TaxRegimeModel, + "findOne", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + + const result = await repository.update(requestedCompany, {} as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(modified, allowed); + if (!allowed) { + assert.equal(result.error.name.includes("NotFound"), true); + } + }); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts index 84c8f01b..c50d255d 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const usePaymentMethodCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -35,9 +37,12 @@ export const usePaymentMethodCreateMutation = () => { return GetPaymentMethodByIdAdapter.fromDto(dto); }, onSuccess: async (paymentMethod) => { - queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)), + paymentMethod + ); await queryClient.invalidateQueries({ - queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX), }); }, }); diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts index c6361349..d85633ea 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { type DeletePaymentMethodParams, deletePaymentMethod } from "../api"; @@ -10,6 +11,7 @@ import { } from "./keys"; export const usePaymentMethodDeleteMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -20,8 +22,12 @@ export const usePaymentMethodDeleteMutation = () => { }, onSuccess: async () => { await Promise.all([ - queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }), - queryClient.invalidateQueries({ queryKey: PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX), + }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX), + }), ]); }, }); diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts index 92ca7a7d..f0acb7fe 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetPaymentMethodByIdAdapter } from "../adapters"; @@ -11,11 +12,12 @@ export const usePaymentMethodDetailQuery = ( paymentMethodId?: string, options?: { enabled?: boolean } ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId); return useQuery({ - queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethodId), + queryKey: companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethodId)), enabled, queryFn: async ({ signal }) => { const dto = await getPaymentMethodById(dataSource, { diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts index e9ddf281..9fd2406f 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetPaymentMethodByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const usePaymentMethodDisableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,8 +24,13 @@ export const usePaymentMethodDisableMutation = () => { return GetPaymentMethodByIdAdapter.fromDto(dto); }, onSuccess: async (paymentMethod) => { - queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); - await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)), + paymentMethod + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts index 7e26a47b..767f4634 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetPaymentMethodByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const usePaymentMethodEnableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,8 +24,13 @@ export const usePaymentMethodEnableMutation = () => { return GetPaymentMethodByIdAdapter.fromDto(dto); }, onSuccess: async (paymentMethod) => { - queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); - await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)), + paymentMethod + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-list-query.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-list-query.ts index 79d13a30..ae1f18ed 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-list-query.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-list-query.ts @@ -1,5 +1,6 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListPaymentMethodsAdapter } from "../adapters"; @@ -16,17 +17,19 @@ export interface PaymentMethodsListQueryOptions { export const usePaymentMethodsListQuery = ( options?: PaymentMethodsListQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_PAYMENT_METHODS_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY(criteria)), queryFn: async ({ signal }) => { const dto = await getListPaymentMethodsByCriteria(dataSource, { signal, criteria }); return ListPaymentMethodsAdapter.fromDto(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria }); }; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts index d18e7c25..6d1cee5c 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetPaymentMethodByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const usePaymentMethodSetDefaultMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,10 +24,17 @@ export const usePaymentMethodSetDefaultMutation = () => { return GetPaymentMethodByIdAdapter.fromDto(dto); }, onSuccess: async (paymentMethod) => { - queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)), + paymentMethod + ); await Promise.all([ - queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }), - queryClient.invalidateQueries({ queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethod.id) }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX), + }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)), + }), ]); }, }); diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts index 296f378b..cb45bb2d 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const usePaymentMethodUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -35,9 +37,12 @@ export const usePaymentMethodUpdateMutation = () => { return GetPaymentMethodByIdAdapter.fromDto(dto); }, onSuccess: async (paymentMethod) => { - queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)), + paymentMethod + ); await queryClient.invalidateQueries({ - queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX), }); }, }); diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts index c711ff4c..5b443aae 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const usePaymentTermCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -35,9 +37,12 @@ export const usePaymentTermCreateMutation = () => { return GetPaymentTermByIdAdapter.fromDto(dto); }, onSuccess: async (paymentTerm) => { - queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)), + paymentTerm + ); await queryClient.invalidateQueries({ - queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX), }); }, }); diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts index 487f42db..b3406d2d 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { type DeletePaymentTermParams, deletePaymentTerm } from "../api"; @@ -10,6 +11,7 @@ import { } from "./keys"; export const usePaymentTermDeleteMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -20,8 +22,12 @@ export const usePaymentTermDeleteMutation = () => { }, onSuccess: async () => { await Promise.all([ - queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }), - queryClient.invalidateQueries({ queryKey: PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX), + }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX), + }), ]); }, }); diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts index a1d4dce0..f8756f93 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetPaymentTermByIdAdapter } from "../adapters"; @@ -11,11 +12,12 @@ export const usePaymentTermDetailQuery = ( paymentTermId?: string, options?: { enabled?: boolean } ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && Boolean(paymentTermId); return useQuery({ - queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId), + queryKey: companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTermId)), enabled, queryFn: async ({ signal }) => { const dto = await getPaymentTermById(dataSource, { diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts index fadf6261..5cf0c98b 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetPaymentTermByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const usePaymentTermDisableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,8 +24,13 @@ export const usePaymentTermDisableMutation = () => { return GetPaymentTermByIdAdapter.fromDto(dto); }, onSuccess: async (paymentTerm) => { - queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm); - await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)), + paymentTerm + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts index 084f4331..ecf1a330 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetPaymentTermByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const usePaymentTermEnableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,8 +24,13 @@ export const usePaymentTermEnableMutation = () => { return GetPaymentTermByIdAdapter.fromDto(dto); }, onSuccess: async (paymentTerm) => { - queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm); - await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)), + paymentTerm + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts index 79588476..08b4100e 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts @@ -1,5 +1,6 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListPaymentTermsAdapter } from "../adapters"; @@ -16,17 +17,19 @@ export interface PaymentTermsListQueryOptions { export const usePaymentTermListQuery = ( options?: PaymentTermsListQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_PAYMENT_TERMS_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY(criteria)), queryFn: async ({ signal }) => { const dto = await getListPaymentTermsByCriteria(dataSource, { signal, criteria }); return ListPaymentTermsAdapter.fromDto(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria }); }; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts index a9599532..d7feab89 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const usePaymentTermUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -35,9 +37,12 @@ export const usePaymentTermUpdateMutation = () => { return GetPaymentTermByIdAdapter.fromDto(dto); }, onSuccess: async (paymentTerm) => { - queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm); + queryClient.setQueryData( + companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)), + paymentTerm + ); await queryClient.invalidateQueries({ - queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX), }); }, }); diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-create-mutation.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-create-mutation.ts index 0ce90260..cc8526ea 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-create-mutation.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-create-mutation.ts @@ -1,27 +1,47 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + import { CreateTaxDefinitionRequestSchema } from "../../../../common"; import { GetTaxDefinitionByIdAdapter } from "../adapters"; -import { createTaxDefinition, type CreateTaxDefinitionParams } from "../api"; +import { type CreateTaxDefinitionParams, createTaxDefinition } from "../api"; import type { TaxDefinition } from "../entities"; -import { CREATE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys"; + +import { + CREATE_TAX_DEFINITION_MUTATION_KEY, + LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, + TAX_DEFINITION_QUERY_KEY, +} from "./keys"; import { toValidationErrors } from "./to-validation-errors"; export const useTaxDefinitionCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); return useMutation({ mutationKey: CREATE_TAX_DEFINITION_MUTATION_KEY, mutationFn: async (params) => { - if (!params.id) throw new Error("taxDefinitionId is required"); + if (!params.id) { + throw new Error("taxDefinitionId is required"); + } const validation = CreateTaxDefinitionRequestSchema.safeParse(params.data); - if (!validation.success) throw new ValidationErrorCollection("Validation failed", toValidationErrors(validation.error)); + if (!validation.success) { + throw new ValidationErrorCollection( + "Validation failed", + toValidationErrors(validation.error) + ); + } return GetTaxDefinitionByIdAdapter.fromDto(await createTaxDefinition(dataSource, params)); }, onSuccess: async (taxDefinition) => { - queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)), + taxDefinition + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-delete-mutation.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-delete-mutation.ts index df3cd74b..dc73bd40 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-delete-mutation.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-delete-mutation.ts @@ -1,17 +1,31 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; -import { deleteTaxDefinitionById, type DeleteTaxDefinitionByIdParams } from "../api"; -import { DELETE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys"; + +import { type DeleteTaxDefinitionByIdParams, deleteTaxDefinitionById } from "../api"; + +import { + DELETE_TAX_DEFINITION_MUTATION_KEY, + LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, + TAX_DEFINITION_QUERY_KEY, +} from "./keys"; export const useTaxDefinitionDeleteMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); return useMutation({ mutationKey: DELETE_TAX_DEFINITION_MUTATION_KEY, - mutationFn: async (params) => { await deleteTaxDefinitionById(dataSource, params); }, + mutationFn: async (params) => { + await deleteTaxDefinitionById(dataSource, params); + }, onSuccess: async (_result, params) => { - queryClient.removeQueries({ queryKey: TAX_DEFINITION_QUERY_KEY(params.taxDefinitionId) }); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX }); + queryClient.removeQueries({ + queryKey: companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(params.taxDefinitionId)), + }); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-detail-query.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-detail-query.ts index f8f6b8ff..9200f662 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-detail-query.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-detail-query.ts @@ -1,16 +1,26 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + import { GetTaxDefinitionByIdAdapter } from "../adapters"; import { getTaxDefinitionById } from "../api"; import type { TaxDefinition } from "../entities"; + import { TAX_DEFINITION_QUERY_KEY } from "./keys"; -export const useTaxDefinitionDetailQuery = (taxDefinitionId?: string, options?: { enabled?: boolean }): UseQueryResult => { +export const useTaxDefinitionDetailQuery = ( + taxDefinitionId?: string, + options?: { enabled?: boolean } +): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && Boolean(taxDefinitionId); return useQuery({ - queryKey: TAX_DEFINITION_QUERY_KEY(taxDefinitionId), + queryKey: companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinitionId)), enabled, - queryFn: async ({ signal }) => GetTaxDefinitionByIdAdapter.fromDto(await getTaxDefinitionById(dataSource, { taxDefinitionId: taxDefinitionId!, signal })), + queryFn: async ({ signal }) => + GetTaxDefinitionByIdAdapter.fromDto( + await getTaxDefinitionById(dataSource, { taxDefinitionId: taxDefinitionId!, signal }) + ), }); }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-disable-mutation.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-disable-mutation.ts index 570b299b..fc2f651b 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-disable-mutation.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-disable-mutation.ts @@ -1,19 +1,33 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + import { GetTaxDefinitionByIdAdapter } from "../adapters"; -import { disableTaxDefinitionById, type DisableTaxDefinitionByIdParams } from "../api"; +import { type DisableTaxDefinitionByIdParams, disableTaxDefinitionById } from "../api"; import type { TaxDefinition } from "../entities"; -import { DISABLE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys"; + +import { + DISABLE_TAX_DEFINITION_MUTATION_KEY, + LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, + TAX_DEFINITION_QUERY_KEY, +} from "./keys"; export const useTaxDefinitionDisableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); return useMutation({ mutationKey: DISABLE_TAX_DEFINITION_MUTATION_KEY, - mutationFn: async (params) => GetTaxDefinitionByIdAdapter.fromDto(await disableTaxDefinitionById(dataSource, params)), + mutationFn: async (params) => + GetTaxDefinitionByIdAdapter.fromDto(await disableTaxDefinitionById(dataSource, params)), onSuccess: async (taxDefinition) => { - queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)), + taxDefinition + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-enable-mutation.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-enable-mutation.ts index c5448b87..a4d3c0e8 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-enable-mutation.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-enable-mutation.ts @@ -1,19 +1,33 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + import { GetTaxDefinitionByIdAdapter } from "../adapters"; -import { enableTaxDefinitionById, type EnableTaxDefinitionByIdParams } from "../api"; +import { type EnableTaxDefinitionByIdParams, enableTaxDefinitionById } from "../api"; import type { TaxDefinition } from "../entities"; -import { ENABLE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys"; + +import { + ENABLE_TAX_DEFINITION_MUTATION_KEY, + LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, + TAX_DEFINITION_QUERY_KEY, +} from "./keys"; export const useTaxDefinitionEnableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); return useMutation({ mutationKey: ENABLE_TAX_DEFINITION_MUTATION_KEY, - mutationFn: async (params) => GetTaxDefinitionByIdAdapter.fromDto(await enableTaxDefinitionById(dataSource, params)), + mutationFn: async (params) => + GetTaxDefinitionByIdAdapter.fromDto(await enableTaxDefinitionById(dataSource, params)), onSuccess: async (taxDefinition) => { - queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)), + taxDefinition + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-list-query.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-list-query.ts index 3f51c629..ee7d5b30 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-list-query.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-list-query.ts @@ -1,20 +1,31 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + import { ListTaxDefinitionsAdapter } from "../adapters"; import { listTaxDefinitions } from "../api"; import type { TaxDefinitionList } from "../entities"; + import { LIST_TAX_DEFINITIONS_QUERY_KEY } from "./keys"; -export interface TaxDefinitionListQueryOptions { enabled?: boolean; criteria?: Partial; } +export interface TaxDefinitionListQueryOptions { + enabled?: boolean; + criteria?: Partial; +} -export const useTaxDefinitionListQuery = (options?: TaxDefinitionListQueryOptions): UseQueryResult => { +export const useTaxDefinitionListQuery = ( + options?: TaxDefinitionListQueryOptions +): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY(criteria)), enabled: options?.enabled ?? true, - queryFn: async ({ signal }) => ListTaxDefinitionsAdapter.fromDto(await listTaxDefinitions(dataSource, { signal, criteria })), - placeholderData: (previousData) => previousData, + queryFn: async ({ signal }) => + ListTaxDefinitionsAdapter.fromDto(await listTaxDefinitions(dataSource, { signal, criteria })), + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), }); }; diff --git a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-update-mutation.ts b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-update-mutation.ts index a8bcd202..521bbe71 100644 --- a/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-update-mutation.ts +++ b/modules/catalogs/src/web/tax-definitions/shared/hooks/use-tax-definition-update-mutation.ts @@ -1,27 +1,47 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + import { UpdateTaxDefinitionByIdRequestSchema } from "../../../../common"; import { GetTaxDefinitionByIdAdapter } from "../adapters"; import { type UpdateTaxDefinitionByIdParams, updateTaxDefinitionById } from "../api"; import type { TaxDefinition } from "../entities"; -import { LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY, UPDATE_TAX_DEFINITION_MUTATION_KEY } from "./keys"; + +import { + LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, + TAX_DEFINITION_QUERY_KEY, + UPDATE_TAX_DEFINITION_MUTATION_KEY, +} from "./keys"; import { toValidationErrors } from "./to-validation-errors"; export const useTaxDefinitionUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); return useMutation({ mutationKey: UPDATE_TAX_DEFINITION_MUTATION_KEY, mutationFn: async (params) => { - if (!params.taxDefinitionId) throw new Error("taxDefinitionId is required"); + if (!params.taxDefinitionId) { + throw new Error("taxDefinitionId is required"); + } const validation = UpdateTaxDefinitionByIdRequestSchema.safeParse(params.data); - if (!validation.success) throw new ValidationErrorCollection("Validation failed", toValidationErrors(validation.error)); + if (!validation.success) { + throw new ValidationErrorCollection( + "Validation failed", + toValidationErrors(validation.error) + ); + } return GetTaxDefinitionByIdAdapter.fromDto(await updateTaxDefinitionById(dataSource, params)); }, onSuccess: async (taxDefinition) => { - queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)), + taxDefinition + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-create-mutation.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-create-mutation.ts index 6f8e281b..554f087a 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-create-mutation.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-create-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const useTaxRegimeCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -35,9 +37,12 @@ export const useTaxRegimeCreateMutation = () => { return GetTaxRegimeByIdAdapter.fromDto(dto); }, onSuccess: async (taxRegime) => { - queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)), + taxRegime + ); await queryClient.invalidateQueries({ - queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX), }); }, }); diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-delete-mutation.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-delete-mutation.ts index 2acca6cc..9f9a24e2 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-delete-mutation.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-delete-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { type DeleteTaxRegimeParams, deleteTaxRegime } from "../api"; @@ -10,6 +11,7 @@ import { } from "./keys"; export const useTaxRegimeDeleteMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -20,8 +22,12 @@ export const useTaxRegimeDeleteMutation = () => { }, onSuccess: async () => { await Promise.all([ - queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX }), - queryClient.invalidateQueries({ queryKey: TAX_REGIMES_DETAIL_QUERY_KEY_PREFIX }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX), + }), + queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, TAX_REGIMES_DETAIL_QUERY_KEY_PREFIX), + }), ]); }, }); diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-detail-query.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-detail-query.ts index a950e3a1..a0000e4c 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-detail-query.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-detail-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetTaxRegimeByIdAdapter } from "../adapters"; @@ -11,11 +12,12 @@ export const useTaxRegimeDetailQuery = ( taxRegimeId?: string, options?: { enabled?: boolean } ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && Boolean(taxRegimeId); return useQuery({ - queryKey: TAX_REGIME_QUERY_KEY(taxRegimeId), + queryKey: companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegimeId)), enabled, queryFn: async ({ signal }) => { const dto = await getTaxRegimeById(dataSource, { diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-disable-mutation.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-disable-mutation.ts index b0051cd8..d6cac6c4 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-disable-mutation.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-disable-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetTaxRegimeByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const useTaxRegimeDisableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,8 +24,13 @@ export const useTaxRegimeDisableMutation = () => { return GetTaxRegimeByIdAdapter.fromDto(dto); }, onSuccess: async (taxRegime) => { - queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)), + taxRegime + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-enable-mutation.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-enable-mutation.ts index 2ebff195..d30ca803 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-enable-mutation.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-enable-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetTaxRegimeByIdAdapter } from "../adapters"; @@ -12,6 +13,7 @@ import { } from "./keys"; export const useTaxRegimeEnableMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,8 +24,13 @@ export const useTaxRegimeEnableMutation = () => { return GetTaxRegimeByIdAdapter.fromDto(dto); }, onSuccess: async (taxRegime) => { - queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime); - await queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX }); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)), + taxRegime + ); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX), + }); }, }); }; diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-list-query.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-list-query.ts index c4ba2d4b..2f36519d 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-list-query.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-list-query.ts @@ -1,5 +1,6 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListTaxRegimesAdapter } from "../adapters"; @@ -16,17 +17,19 @@ export interface TaxRegimesListQueryOptions { export const useTaxRegimesListQuery = ( options?: TaxRegimesListQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_TAX_REGIMES_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY(criteria)), queryFn: async ({ signal }) => { const dto = await getListTaxRegimesByCriteria(dataSource, { signal, criteria }); return ListTaxRegimesAdapter.fromDto(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria }); }; diff --git a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-update-mutation.ts b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-update-mutation.ts index d709f258..a5438e37 100644 --- a/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-update-mutation.ts +++ b/modules/catalogs/src/web/tax-regimes/shared/hooks/use-tax-regime-update-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const useTaxRegimeUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -35,9 +37,12 @@ export const useTaxRegimeUpdateMutation = () => { return GetTaxRegimeByIdAdapter.fromDto(dto); }, onSuccess: async (taxRegime) => { - queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime); + queryClient.setQueryData( + companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)), + taxRegime + ); await queryClient.invalidateQueries({ - queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX), }); }, }); diff --git a/modules/companies/src/api/application/companies/contracts/company-repository.interface.ts b/modules/companies/src/api/application/companies/contracts/company-repository.interface.ts index b89a384c..6cc28a3c 100644 --- a/modules/companies/src/api/application/companies/contracts/company-repository.interface.ts +++ b/modules/companies/src/api/application/companies/contracts/company-repository.interface.ts @@ -13,6 +13,7 @@ export interface ICompanyRepository { transaction?: unknown; }): Promise>; findActiveByIds(params: { + allCompanies?: boolean; companyIds: Collection; transaction?: unknown; }): Promise, Error>>; diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts index 1c2b6bf8..6b798fa6 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts @@ -77,6 +77,7 @@ test("create devuelve la company sin default payment term", async () => { const company = buildCompany(); const useCase = new CreateCompanyUseCase({ + adminAssigner: { assign: async () => Result.ok() }, inputMapper: new CreateCompanyInputMapper(), creator: { create: async () => Result.ok(company), @@ -86,6 +87,7 @@ test("create devuelve la company sin default payment term", async () => { }); const result = await useCase.execute({ + accountId: UniqueID.generateNewID(), dto: { id: company.id.toPrimitive(), legal_name: company.legalName.toPrimitive(), @@ -118,12 +120,14 @@ test("create serializes the company public address", async () => { true ); const useCase = new CreateCompanyUseCase({ + adminAssigner: { assign: async () => Result.ok() }, inputMapper: new CreateCompanyInputMapper(), creator: { create: async () => Result.ok(company) }, responseMapper: new CompanyPublicModelMapper(), transactionManager: buildTransactionManager(), }); const result = await useCase.execute({ + accountId: UniqueID.generateNewID(), dto: { id: company.id.toPrimitive(), legal_name: company.legalName.toPrimitive(), @@ -150,6 +154,7 @@ test("create maps a complete request address to Company props", async () => { const company = buildCompany(); let capturedAddress: typeof company.address | undefined; const useCase = new CreateCompanyUseCase({ + adminAssigner: { assign: async () => Result.ok() }, inputMapper: new CreateCompanyInputMapper(), creator: { create: async ({ props }) => { @@ -161,6 +166,7 @@ test("create maps a complete request address to Company props", async () => { transactionManager: buildTransactionManager(), }); const result = await useCase.execute({ + accountId: UniqueID.generateNewID(), dto: { id: company.id.toPrimitive(), legal_name: company.legalName.toPrimitive(), @@ -191,6 +197,7 @@ test("create returns an input mapper failure without calling the creator", async let creatorCalled = false; const expectedError = new Error("invalid input"); const useCase = new CreateCompanyUseCase({ + adminAssigner: { assign: async () => Result.ok() }, inputMapper: { map: () => Result.fail(expectedError) }, creator: { create: async () => { @@ -201,7 +208,10 @@ test("create returns an input mapper failure without calling the creator", async responseMapper: new CompanyPublicModelMapper(), transactionManager: buildTransactionManager(), }); - const result = await useCase.execute({ dto: {} as never }); + const result = await useCase.execute({ + accountId: UniqueID.generateNewID(), + dto: {} as never, + }); assert.equal(result.error, expectedError); assert.equal(creatorCalled, false); }); diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts index 6a482e1c..7961b94f 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts @@ -1,4 +1,6 @@ import type { ITransactionManager } from "@erp/core/api"; +import type { IInitialCompanyAdminAssigner } from "@erp/identity/api"; +import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common"; @@ -10,6 +12,7 @@ export class CreateCompanyUseCase { public constructor( private readonly deps: { creator: ICompanyCreator; + adminAssigner: IInitialCompanyAdminAssigner; inputMapper: ICreateCompanyInputMapper; responseMapper: CompanyPublicModelMapper; transactionManager: ITransactionManager; @@ -17,6 +20,7 @@ export class CreateCompanyUseCase { ) {} public execute(params: { dto: CreateCompanyRequestDTO; + accountId: UniqueID; }): Promise> { const input = this.deps.inputMapper.map(params.dto); if (input.isFailure) { @@ -29,9 +33,18 @@ export class CreateCompanyUseCase { props: input.data.props, transaction, }); - return result.isFailure - ? Result.fail(result.error) - : Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data))); + if (result.isFailure) { + return Result.fail(result.error); + } + const admin = await this.deps.adminAssigner.assign({ + accountId: params.accountId, + companyId: result.data.id, + transaction, + }); + if (admin.isFailure) { + return Result.fail(admin.error); + } + return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data))); } catch (error: unknown) { return Result.fail(error as Error); } diff --git a/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts b/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts index dc1235fa..c450ef25 100644 --- a/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts @@ -4,13 +4,14 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { AvailableCompaniesResponseDTO, AvailableCompanyDTO } from "../../../../common"; +import { CompaniesPermissionCodes } from "../../../../common"; import type { ICompanyRepository } from "../contracts"; import type { CompanyPublicModelMapper } from "../public"; export class FindAvailableCompaniesForAccountUseCase { public constructor( private readonly deps: { - identityServices: Pick; + identityServices: Pick; repository: ICompanyRepository; responseMapper: CompanyPublicModelMapper; transactionManager: ITransactionManager; @@ -37,7 +38,16 @@ export class FindAvailableCompaniesForAccountUseCase { return Result.fail(accessibleCompanyIdsResult.error); } + const global = await this.deps.identityServices.authorization.canSystem({ + accountId: accountIdResult.data, + permissionCode: CompaniesPermissionCodes.Create, + transaction, + }); + if (global.isFailure) { + return Result.fail(global.error); + } const companiesResult = await this.deps.repository.findActiveByIds({ + ...(global.data ? { allCompanies: true } : {}), companyIds: accessibleCompanyIdsResult.data, transaction, }); diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts index e4609995..6b66aa9e 100644 --- a/modules/companies/src/api/infrastructure/di/companies.di.ts +++ b/modules/companies/src/api/infrastructure/di/companies.di.ts @@ -29,6 +29,7 @@ import { UpdateCurrentCompanyInputMapper } from "../../application/companies/map import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence"; export type CompaniesInternalDeps = { + authorization: IIdentityPublicServices["authorization"]; companyAccess: IIdentityPublicServices["companyAccess"]; publicFinder: CompanyPublicFinder; useCases: { @@ -76,10 +77,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDepen return { internal: { + authorization: identityServices.authorization, companyAccess: identityServices.companyAccess, publicFinder, useCases: { createCompany: new CreateCompanyUseCase({ + adminAssigner: identityServices.initialCompanyAdminAssigner, creator, inputMapper: createCompanyInputMapper, responseMapper, @@ -96,7 +99,10 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDepen transactionManager, }), findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({ - identityServices: { companyAccess: identityServices.companyAccess }, + identityServices: { + companyAccess: identityServices.companyAccess, + authorization: identityServices.authorization, + }, repository, responseMapper, transactionManager, diff --git a/modules/companies/src/api/infrastructure/express/companies.routes.ts b/modules/companies/src/api/infrastructure/express/companies.routes.ts index c2dc52d1..02bbb45e 100644 --- a/modules/companies/src/api/infrastructure/express/companies.routes.ts +++ b/modules/companies/src/api/infrastructure/express/companies.routes.ts @@ -1,10 +1,16 @@ import { type StartParams, validateRequest } from "@erp/core/api"; -import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api"; +import { + requireCompanyContext, + requireIdentityAuthenticated, + requirePermission, +} from "@erp/identity/api"; +import { IdentityPermissionCodes } from "@erp/identity/common"; import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; import { ChangeCompanyStatusRequestSchema, + CompaniesPermissionCodes, CreateCompanyRequestSchema, GetCompanyByIdRequestSchema, UpdateCompanyByIdParamsRequestSchema, @@ -39,6 +45,7 @@ export const companiesRouter = (params: StartParams) => { router.post( "/", + requirePermission(deps.authorization, CompaniesPermissionCodes.Create, "SYSTEM"), validateRequest(CreateCompanyRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { const controller = new CreateCompanyController(deps.useCases.createCompany); @@ -65,6 +72,7 @@ export const companiesRouter = (params: StartParams) => { router.patch( "/current", requireCurrentCompanyContext, + requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate), validateRequest(UpdateCurrentCompanyRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany); @@ -75,6 +83,7 @@ export const companiesRouter = (params: StartParams) => { router.get( "/:company_id", requireCurrentCompanyContext, + requirePermission(deps.authorization, CompaniesPermissionCodes.Read), validateRequest(GetCompanyByIdRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { const controller = new GetCompanyByIdController(deps.useCases.getCompanyById); @@ -85,6 +94,7 @@ export const companiesRouter = (params: StartParams) => { router.patch( "/:company_id", requireCurrentCompanyContext, + requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate), validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"), validateRequest(UpdateCompanyRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { @@ -96,6 +106,7 @@ export const companiesRouter = (params: StartParams) => { router.post( "/:company_id/enable", requireCurrentCompanyContext, + requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate), validateRequest(ChangeCompanyStatusRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { const controller = new EnableCompanyController(deps.useCases.enableCompany); @@ -106,6 +117,7 @@ export const companiesRouter = (params: StartParams) => { router.post( "/:company_id/disable", requireCurrentCompanyContext, + requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate), validateRequest(ChangeCompanyStatusRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { const controller = new DisableCompanyController(deps.useCases.disableCompany); diff --git a/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts index 0b03f83a..e841b4b2 100644 --- a/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts +++ b/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts @@ -1,4 +1,5 @@ import { ExpressController } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; import type { CreateCompanyRequestDTO } from "../../../../common"; import type { CreateCompanyUseCase } from "../../../application"; @@ -12,7 +13,11 @@ export class CreateCompanyController extends ExpressController { protected async executeImpl() { const dto = this.req.body as CreateCompanyRequestDTO; - const result = await this.useCase.execute({ dto }); + const accountId = this.getUser<{ userId?: UniqueID }>()?.userId; + if (!accountId) { + return this.unauthorizedError("Authentication required"); + } + const result = await this.useCase.execute({ dto, accountId }); return result.match( (data) => this.created(data), diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts index f0e8b020..d166b813 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts @@ -133,6 +133,7 @@ export class SequelizeCompanyRepository } public async findActiveByIds(params: { + allCompanies?: boolean; companyIds: Collection; transaction?: unknown; }): Promise, Error>> { @@ -141,13 +142,13 @@ export class SequelizeCompanyRepository ...new Set(params.companyIds.getAll().map((companyId) => companyId.toPrimitive())), ]; - if (ids.length === 0) { + if (ids.length === 0 && !params.allCompanies) { return Result.ok(new Collection([])); } const rows = await CompanyModel.findAll({ where: { - id: { [Op.in]: ids }, + ...(params.allCompanies ? {} : { id: { [Op.in]: ids } }), status: COMPANY_STATUS.ACTIVE, }, transaction: asTransaction(params.transaction), diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 4f1490f8..e752d896 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -1,5 +1,7 @@ import type { IModuleServer } from "@erp/core/api"; +import { companiesPermissionDefinitions } from "../common/permissions"; + import type { ICompanyPublicServices } from "./application"; import { buildCompaniesDependencies } from "./infrastructure/di"; import { models } from "./infrastructure/persistence/sequelize"; @@ -14,6 +16,7 @@ export const companiesModule: IModuleServer = { name: "companies", version: "1.0.0", dependencies: ["identity", "catalogs"], + permissions: companiesPermissionDefinitions, async setup(params) { const { internal, publicServices } = buildCompaniesDependencies(params); const general: ICompanyPublicServices = publicServices; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 0392b1b4..8f46be8f 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -1 +1,2 @@ export * from "./dto"; +export * from "./permissions"; diff --git a/modules/companies/src/common/permissions.ts b/modules/companies/src/common/permissions.ts new file mode 100644 index 00000000..3fe22f69 --- /dev/null +++ b/modules/companies/src/common/permissions.ts @@ -0,0 +1,21 @@ +import type { PermissionDefinition } from "@erp/core"; + +export const CompaniesPermissionCodes = { + Create: "companies:companies:create", + Read: "companies:companies:read", +} as const; + +export const companiesPermissionDefinitions = [ + { + code: CompaniesPermissionCodes.Create, + module: "companies", + scope: "SYSTEM", + description: "Create companies globally.", + }, + { + code: CompaniesPermissionCodes.Read, + scope: "COMPANY", + module: "companies", + description: "Read a company through its explicit ID endpoint.", + }, +] satisfies PermissionDefinition[]; diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts index c3411264..53c2b93e 100644 --- a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts +++ b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { CompanySettingsAdapter } from "../adapters"; @@ -8,10 +9,11 @@ import type { CompanySettings } from "../entities"; import { COMPANY_SETTINGS_QUERY_KEY } from "./keys"; export const useCompanySettingsQuery = (): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: COMPANY_SETTINGS_QUERY_KEY(), + queryKey: companyQueryKey(queryScope, COMPANY_SETTINGS_QUERY_KEY()), enabled: true, queryFn: async ({ signal }) => { const dto = await getCompanySettings(dataSource, { signal }); diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts index 4cf0089e..bd04bd06 100644 --- a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts +++ b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -19,6 +20,7 @@ const toValidationErrors = (issues: { path: PropertyKey[]; message: string }[]) })); export const useCompanySettingsUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -38,7 +40,7 @@ export const useCompanySettingsUpdateMutation = () => { return CompanySettingsAdapter.fromDto(dto); }, onSuccess: async (company) => { - queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(), company); + queryClient.setQueryData(companyQueryKey(queryScope, COMPANY_SETTINGS_QUERY_KEY()), company); }, }); }; diff --git a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx index 7bab670b..cc89602a 100644 --- a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx +++ b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx @@ -63,6 +63,7 @@ mock.module("@erp/core/components", { mock.module("@erp/core/hooks", { namedExports: { + useCompanyQueryScope: () => null, UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children, }, }); diff --git a/modules/core/package.json b/modules/core/package.json index ff1eae77..abfb802b 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -18,7 +18,8 @@ "./client": "./src/web/manifest.ts", "./globals.css": "./src/web/globals.css", "./components": "./src/web/components/index.ts", - "./hooks": "./src/web/hooks/index.ts" + "./hooks": "./src/web/hooks/index.ts", + "./query": "./src/web/lib/query/index.ts" }, "peerDependencies": { "react": "^19.2.5", @@ -59,4 +60,4 @@ "sequelize": "^6.37.8", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/core/src/api/modules/module-permissions.test.ts b/modules/core/src/api/modules/module-permissions.test.ts new file mode 100644 index 00000000..14fc9980 --- /dev/null +++ b/modules/core/src/api/modules/module-permissions.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { collectModulePermissions } from "./module-permissions"; + +const definition = { + code: "companies:companies:read", + module: "companies", + scope: "COMPANY" as const, + description: "Read", +}; +test("compone permisos de módulos sin dependencia de Identity hacia sus consumidores", () => { + const result = collectModulePermissions([ + { name: "identity" }, + { name: "companies", permissions: [definition] }, + ]); + assert.deepEqual(result, [definition]); + assert.equal(Object.isFrozen(result), true); +}); +test("rechaza duplicados, namespace ajeno y códigos inválidos antes del setup", () => { + assert.throws(() => + collectModulePermissions([{ name: "companies", permissions: [definition, definition] }]) + ); + assert.throws(() => collectModulePermissions([{ name: "customers", permissions: [definition] }])); + assert.throws(() => + collectModulePermissions([ + { name: "companies", permissions: [{ ...definition, code: "unknown" }] }, + ]) + ); +}); + +test("scope inválido impide el arranque", () => { + assert.throws(() => + collectModulePermissions([ + { name: "companies", permissions: [{ ...definition, scope: "GLOBAL" as never }] }, + ]) + ); +}); diff --git a/modules/core/src/api/modules/module-permissions.ts b/modules/core/src/api/modules/module-permissions.ts new file mode 100644 index 00000000..3d329888 --- /dev/null +++ b/modules/core/src/api/modules/module-permissions.ts @@ -0,0 +1,26 @@ +import type { PermissionDefinition } from "../../common"; + +/** Compone declaraciones antes del setup; un duplicado o namespace ajeno impide arrancar. */ +export function collectModulePermissions( + modules: readonly { name: string; permissions?: readonly PermissionDefinition[] }[] +): readonly PermissionDefinition[] { + const definitions = new Map(); + for (const module of modules) { + for (const definition of module.permissions ?? []) { + if ( + definition.module !== module.name || + !definition.code.startsWith(`${module.name}:`) || + !/^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$/.test(definition.code) || + !["SYSTEM", "COMPANY"].includes(definition.scope) || + !definition.description.trim() + ) { + throw new Error(`Invalid permission declaration in module "${module.name}"`); + } + if (definitions.has(definition.code)) { + throw new Error(`Duplicate permission declaration: ${definition.code}`); + } + definitions.set(definition.code, Object.freeze({ ...definition })); + } + } + return Object.freeze([...definitions.values()]); +} diff --git a/modules/core/src/api/modules/module-runtime.test.ts b/modules/core/src/api/modules/module-runtime.test.ts index 36fb641e..c1ca443f 100644 --- a/modules/core/src/api/modules/module-runtime.test.ts +++ b/modules/core/src/api/modules/module-runtime.test.ts @@ -83,3 +83,43 @@ test("initModules conserva force antes del warmup", async () => { "warmup", ]); }); + +test("compone permisos de todos los manifests antes de construir Identity", async () => { + const declarations = [ + { + code: "companies:companies:read", + module: "companies", + scope: "COMPANY" as const, + description: "Read", + }, + ]; + let received: unknown; + registerModule({ + name: "identity", + version: "1", + setup: (context) => { + received = context.permissionDefinitions; + return {}; + }, + }); + registerFixture(); + registerModule({ name: "companies", version: "1", permissions: declarations }); + await initializeModuleStructure(params("none")); + assert.deepEqual(received, declarations); +}); + +test("un warmup de seguridad obligatorio impide arrancar aunque strict sea false", async () => { + registerFixture(); + registerModule({ + name: "security", + version: "1", + warmupRequired: true, + warmup: () => { + throw new Error("Permission synchronization failed"); + }, + }); + await assert.rejects( + initModules(Object.assign(params("none"), { warmup: { timeoutMs: 1000, strict: false } })), + /Permission synchronization failed/ + ); +}); diff --git a/modules/core/src/api/modules/module-runtime.ts b/modules/core/src/api/modules/module-runtime.ts index 860edcbf..26a15c3f 100644 --- a/modules/core/src/api/modules/module-runtime.ts +++ b/modules/core/src/api/modules/module-runtime.ts @@ -1,6 +1,7 @@ import type { ILogger } from "@repo/rdx-logger"; import type { Model, ModelStatic, Sequelize } from "sequelize"; +import { collectModulePermissions } from "./module-permissions"; import type { IModuleServer } from "./module-server.interface"; import type { ModulesRuntime } from "./modules-runtime"; import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types"; @@ -54,8 +55,9 @@ export async function initModules( export async function initializeModuleStructure( params: ModuleParams & { logger: ILogger; database: Sequelize } ): Promise { + const permissionDefinitions = collectModulePermissions([...modules.values()]); for (const name of modules.keys()) { - await setupModule(name, params, []); + await setupModule(name, { ...params, permissionDefinitions }, []); } await initializeModels(params.database, params.logger); validateDependencies(); @@ -70,7 +72,9 @@ export async function initializeModuleStructure( throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities'); } - return { customerInvoices: customerInvoices.proformas.recurringProformas }; + return { + customerInvoices: { recurringProformas: customerInvoices.proformas.recurringProformas }, + }; } /** Ejecuta la fase opcional de conexión al runtime, que el server usa para HTTP. */ @@ -199,7 +203,7 @@ async function warmupModules( `${name}.warmup` ); } catch (error) { - if (params.warmup.strict) { + if (params.warmup.strict || module.warmupRequired) { throw error; } params.logger.warn("Module warmup failed but continuing", { diff --git a/modules/core/src/api/modules/module-server.interface.ts b/modules/core/src/api/modules/module-server.interface.ts index deee2f26..c3d2a092 100644 --- a/modules/core/src/api/modules/module-server.interface.ts +++ b/modules/core/src/api/modules/module-server.interface.ts @@ -1,6 +1,6 @@ //Contrato para los Modules backend (Node.js) -import type { ModuleMetadata } from "../../common"; +import type { ModuleMetadata, PermissionDefinition } from "../../common"; import type { ModuleSetupResult, SetupParams, StartParams, WarmupParams } from "./types"; @@ -15,6 +15,9 @@ export interface IModuleServer extends ModuleMetadata { */ dependencies?: string[]; + /** Permisos declarados por el módulo; registrarlos no los concede a ningún rol. */ + permissions?: readonly PermissionDefinition[]; + /** * Fase de construcción del módulo. * Aquí se construye el dominio y se decide qué se expone. @@ -30,5 +33,8 @@ export interface IModuleServer extends ModuleMetadata { /** * Warmup opcional (IO, precargas, validaciones externas). */ + /** Un mantenimiento de seguridad obligatorio debe impedir el arranque si falla. */ + warmupRequired?: boolean; + warmup?: (params: WarmupParams) => Promise | void; } diff --git a/modules/core/src/api/modules/types.ts b/modules/core/src/api/modules/types.ts index f3125e80..b8fff081 100644 --- a/modules/core/src/api/modules/types.ts +++ b/modules/core/src/api/modules/types.ts @@ -1,6 +1,9 @@ import type { IAudit } from "@repo/rdx-audit"; +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; import type { Model, ModelStatic, Sequelize } from "sequelize"; +import type { PermissionDefinition } from "../../common"; import type { IOperationContextStore } from "../infrastructure/context"; import type { ISequelizeTransactionContext } from "../infrastructure/persistence/sequelize"; @@ -10,6 +13,17 @@ export interface SequelizeModel extends Model { } export type ModuleParams = { + /** Puerto suministrado por el host tras setup para validar tenant sin ciclos entre módulos. */ + tenantCompanyFinder?: { + existsActiveById(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise>; + }; + + /** Catálogo compuesto por el runtime antes del setup de cualquier módulo. */ + permissionDefinitions?: readonly PermissionDefinition[]; + /** * Acceso a servicios expuestos por otros módulos. * Todas las dependencias ya están resueltas. diff --git a/modules/core/src/common/types/permissions.ts b/modules/core/src/common/types/permissions.ts index e43b4a28..6db11128 100644 --- a/modules/core/src/common/types/permissions.ts +++ b/modules/core/src/common/types/permissions.ts @@ -2,6 +2,7 @@ export type PermissionCode = string; export type PermissionDefinition = { code: PermissionCode; + scope: "SYSTEM" | "COMPANY"; module: string; description: string; }; diff --git a/modules/core/src/web/hooks/company-query-scope.tsx b/modules/core/src/web/hooks/company-query-scope.tsx new file mode 100644 index 00000000..e5c2f097 --- /dev/null +++ b/modules/core/src/web/hooks/company-query-scope.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; + +import type { CompanyQueryScope } from "../lib/query/company-query-key"; + +const CompanyQueryScopeContext = createContext(null); + +export function CompanyQueryScopeProvider({ + scope, + children, +}: { + scope: CompanyQueryScope | null; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useCompanyQueryScope() { + return useContext(CompanyQueryScopeContext); +} diff --git a/modules/core/src/web/hooks/index.ts b/modules/core/src/web/hooks/index.ts index be3af3f6..99f4e27d 100644 --- a/modules/core/src/web/hooks/index.ts +++ b/modules/core/src/web/hooks/index.ts @@ -1,3 +1,4 @@ +export * from "./company-query-scope"; export * from "./use-datasource"; export * from "./use-debounce"; export * from "./use-hook-form"; diff --git a/modules/core/src/web/lib/query/company-query-context.test.ts b/modules/core/src/web/lib/query/company-query-context.test.ts new file mode 100644 index 00000000..30977f58 --- /dev/null +++ b/modules/core/src/web/lib/query/company-query-context.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { QueryClient, QueryObserver } from "@tanstack/react-query"; + +import { changeCompanyQueryContext, clearSessionQueryContext } from "./company-query-context"; +import { companyQueryKey } from "./company-query-key"; + +const a = { companyId: "A", sessionId: "account-1:0" }; +const b = { companyId: "B", sessionId: "account-1:0" }; +const otherAccount = { companyId: "B", sessionId: "account-2:1" }; +const resource = ["customers:list"]; + +test("las factories de identidad separan empresa, sesión y recurso", () => { + assert.notDeepEqual(companyQueryKey(a, resource), companyQueryKey(b, resource)); + assert.notDeepEqual(companyQueryKey(b, resource), companyQueryKey(otherAccount, resource)); +}); + +test("A→B cancela primero y una respuesta A tardía no se muestra en el observer B", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let resolveA: (value: string) => void = () => assert.fail("A no iniciada"); + const started = Promise.withResolvers(); + const pending = client + .fetchQuery({ + queryKey: companyQueryKey(a, resource), + queryFn: () => { + started.resolve(); + return new Promise((resolve) => { + resolveA = resolve; + }); + }, + }) + .catch(() => "cancelled"); + await started.promise; + let active = a; + await changeCompanyQueryContext({ + queryClient: client, + isCurrentSession: () => true, + activate: () => { + assert.equal(client.getQueryCache().getAll().length, 0); + active = b; + }, + }); + assert.equal(active, b); + const observer = new QueryObserver(client, { + queryKey: companyQueryKey(b, resource), + queryFn: async () => "B data", + }); + const observed: unknown[] = []; + const unsubscribe = observer.subscribe((result) => observed.push(result.data)); + await observer.refetch(); + resolveA("A data"); + await pending; + await Promise.resolve(); + assert.equal(observer.getCurrentResult().data, "B data"); + assert.equal(observed.includes("A data"), false); + assert.equal(client.getQueryData(companyQueryKey(a, resource)), undefined); + unsubscribe(); + client.clear(); +}); + +test("logout/login elimina datos anteriores; callback viejo conserva clave de sesión antigua", () => { + const client = new QueryClient(); + client.setQueryData(companyQueryKey(b, resource), "private account 1"); + clearSessionQueryContext(client); + assert.equal(client.getQueryCache().getAll().length, 0); + // Una mutación HTTP ya enviada no puede abortarse: su callback conserva la sesión de origen. + client.setQueryData(companyQueryKey(b, resource), "late account 1"); + assert.equal(client.getQueryData(companyQueryKey(otherAccount, resource)), undefined); + client.setQueryData(companyQueryKey(otherAccount, resource), "account 2"); + assert.equal(client.getQueryData(companyQueryKey(otherAccount, resource)), "account 2"); + client.clear(); +}); + +test("una transición de una sesión cerrada no activa empresa", async () => { + const client = new QueryClient(); + let activated = false; + assert.equal( + await changeCompanyQueryContext({ + queryClient: client, + isCurrentSession: () => false, + activate: () => { + activated = true; + }, + }), + false + ); + assert.equal(activated, false); + client.clear(); +}); + +test("un observer montado no conserva placeholder de A al recibir la clave de B", async () => { + const { keepPreviousCompanyData } = await import("./company-query-key"); + const client = new QueryClient(); + client.setQueryData(companyQueryKey(a, resource), "A private"); + const pendingB = Promise.withResolvers(); + const observer = new QueryObserver(client, { + queryKey: companyQueryKey(a, resource), + queryFn: async () => "A private", + }); + const unsubscribe = observer.subscribe(() => {}); + observer.setOptions({ + queryKey: companyQueryKey(b, resource), + queryFn: () => pendingB.promise, + placeholderData: (data, previous) => keepPreviousCompanyData(b, data, previous), + }); + assert.equal(observer.getCurrentResult().data, undefined); + pendingB.resolve("B private"); + await observer.refetch(); + assert.equal(observer.getCurrentResult().data, "B private"); + assert.equal( + keepPreviousCompanyData(b, "page 1", { queryKey: companyQueryKey(b, resource) }), + "page 1" + ); + assert.equal( + keepPreviousCompanyData(otherAccount, "old account", { + queryKey: companyQueryKey(b, resource), + }), + undefined + ); + unsubscribe(); + client.clear(); +}); diff --git a/modules/core/src/web/lib/query/company-query-context.ts b/modules/core/src/web/lib/query/company-query-context.ts new file mode 100644 index 00000000..1085f171 --- /dev/null +++ b/modules/core/src/web/lib/query/company-query-context.ts @@ -0,0 +1,21 @@ +import type { QueryClient } from "@tanstack/react-query"; + +/** Cancela antes de activar el nuevo header; no aplica una transición de una sesión ya cerrada. */ +export async function changeCompanyQueryContext(params: { + queryClient: QueryClient; + isCurrentSession: () => boolean; + activate: () => void; +}): Promise { + await params.queryClient.cancelQueries(); + if (!params.isCurrentSession()) { + return false; + } + params.queryClient.clear(); + params.activate(); + return true; +} + +/** Destruye consultas en vuelo y caché de consultas/mutaciones al cerrar o reemplazar cuenta. */ +export function clearSessionQueryContext(queryClient: QueryClient): void { + queryClient.clear(); +} diff --git a/modules/core/src/web/lib/query/company-query-key.ts b/modules/core/src/web/lib/query/company-query-key.ts new file mode 100644 index 00000000..02e76aa4 --- /dev/null +++ b/modules/core/src/web/lib/query/company-query-key.ts @@ -0,0 +1,26 @@ +import type { QueryKey } from "@tanstack/react-query"; + +export type CompanyQueryScope = Readonly<{ + companyId: string; + /** Identifica la sesión local; una respuesta de otra cuenta no puede poblar sus claves. */ + sessionId: string; +}>; + +/** Los fragmentos de cada recurso se componen con empresa y sesión en todas las operaciones de caché. */ +export function companyQueryKey(scope: CompanyQueryScope | null, resourceKey: QueryKey): QueryKey { + return ["company", scope?.companyId ?? null, "session", scope?.sessionId ?? null, ...resourceKey]; +} + +/** Conserva paginación previa dentro del mismo contexto, nunca entre empresas o sesiones. */ +export function keepPreviousCompanyData( + scope: CompanyQueryScope | null, + previousData: T | undefined, + previousQuery: { queryKey: QueryKey } | undefined +): T | undefined { + const prefix = companyQueryKey(scope, []); + return scope && + previousQuery && + prefix.every((value, index) => previousQuery.queryKey[index] === value) + ? previousData + : undefined; +} diff --git a/modules/core/src/web/lib/query/index.ts b/modules/core/src/web/lib/query/index.ts new file mode 100644 index 00000000..fa0ed937 --- /dev/null +++ b/modules/core/src/web/lib/query/index.ts @@ -0,0 +1,2 @@ +export * from "./company-query-context"; +export * from "./company-query-key"; diff --git a/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts b/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts index f33be546..99374a8a 100644 --- a/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts @@ -9,7 +9,11 @@ import type { ProformaListScope } from "../use-cases"; export interface IProformaRepository { create(proforma: Proforma, transaction?: unknown): Promise>; - update(proforma: Proforma, transaction?: unknown): Promise>; + update( + companyId: UniqueID, + proforma: Proforma, + transaction?: unknown + ): Promise>; existsByIdInCompany( companyId: UniqueID, diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts index 8f84e568..106bd250 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts @@ -92,7 +92,11 @@ export class ProformaStatusChanger implements IProformaStatusChanger { return Result.ok(proforma); } - const updateResult = await this.deps.repository.update(proforma, params.transaction); + const updateResult = await this.deps.repository.update( + params.companyId, + proforma, + params.transaction + ); if (updateResult.isFailure) { return Result.fail(updateResult.error); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index 259b3f3b..9f7109e0 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -110,7 +110,7 @@ export class ProformaUpdater implements IProformaUpdater { } // Persistir cambios - const saveResult = await this.deps.repository.update(proforma, transaction); + const saveResult = await this.deps.repository.update(companyId, proforma, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-company-scope.test.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-company-scope.test.ts new file mode 100644 index 00000000..8f13ef61 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-company-scope.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { ProformaItemModel, ProformaModel, ProformaTaxModel } from "../../../../common"; + +import { SequelizeProformaRepositoryV2 } from "./proforma-v2.repository"; + +const companyId = UniqueID.generateNewID(); +const otherCompanyId = UniqueID.generateNewID(); +const id = UniqueID.generateNewID().toPrimitive(); + +for (const outcome of ["allowed", "foreign", "unchanged"] as const) { + const allowed = outcome !== "foreign"; + test(`escritura empresarial: resultado=${outcome}`, async (t) => { + const requestedCompany = allowed ? companyId : otherCompanyId; + const transaction = {}; + let modified = false; + const repository = new SequelizeProformaRepositoryV2( + { + mapToPersistence: () => + Result.ok({ + id, + company_id: requestedCompany.toPrimitive(), + name: "Updated", + items: [], + taxes: [], + }), + } as never, + {} as never, + {} as never + ); + t.mock.method( + ProformaModel, + "update", + async ( + _payload: unknown, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + if (options.where.company_id !== companyId.toPrimitive()) { + return [0]; + } + modified = true; + return [outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + ProformaModel, + "findOne", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + for (const child of [ProformaItemModel, ProformaTaxModel]) { + t.mock.method(child, "destroy", async () => { + assert.equal(allowed, true, "No borrar hijos si la raíz no pertenece a empresa"); + return 0; + }); + } + const result = await repository.update(requestedCompany, {} as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(modified, allowed); + if (!allowed) { + assert.equal(result.error.name.includes("NotFound"), true); + } + }); +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index d9de605b..34b2c446 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -84,18 +84,36 @@ export class SequelizeProformaRepositoryV2 } } - async update(proforma: Proforma, transaction: Transaction): Promise> { + async update( + companyId: UniqueID, + proforma: Proforma, + transaction: Transaction + ): Promise> { try { const dtoResult = this.domainMapper.mapToPersistence(proforma); if (dtoResult.isFailure) { return Result.fail(dtoResult.error); } const dto = dtoResult.data; - const { id, items, taxes, ...updatePayload } = dto; - await ProformaModel.update(updatePayload, { - where: { id }, + const { id, company_id, items, taxes, ...updatePayload } = dto; + if (company_id !== companyId.toPrimitive()) { + return Result.fail(new EntityNotFoundError("Proforma", "id", id)); + } + const [affected] = await ProformaModel.update(updatePayload, { + where: { id, company_id: companyId.toPrimitive() }, transaction, }); + if (affected === 0) { + const exists = await ProformaModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id: companyId.toPrimitive() }, + transaction, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("Proforma", "id", id)); + } + } await ProformaItemModel.destroy({ where: { proforma_id: id }, transaction }); await ProformaTaxModel.destroy({ where: { proforma_id: id }, transaction }); if (Array.isArray(taxes) && taxes.length > 0) { diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts index 4c8b70f3..641818df 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts @@ -102,7 +102,7 @@ export class SequelizeRecurringProformaDomainMapper { return RecurringProforma.rehydrate(props, itemsResult.data, id.data); } - public mapToPersistence(source: RecurringProforma): Result, Error> { + public mapToPersistence(source: RecurringProforma) { const schedule = source.schedule.map((value) => value.getProps()); return Result.ok({ diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts index b860c859..7434fd54 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts @@ -54,9 +54,7 @@ export class SequelizeRecurringProformaOccurrenceDomainMapper { ); } - public mapToPersistence( - source: RecurringProformaOccurrence - ): Result, Error> { + public mapToPersistence(source: RecurringProformaOccurrence) { return Result.ok({ id: source.id.toPrimitive(), company_id: source.companyId.toPrimitive(), diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-occurrence-save-scope.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-occurrence-save-scope.test.ts new file mode 100644 index 00000000..aef58cf2 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-occurrence-save-scope.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { RecurringProformaOccurrenceModel } from "../../../../common"; +import { SequelizeRecurringProformaOccurrenceDomainMapper } from "../mappers"; + +import { SequelizeRecurringProformaOccurrenceRepository } from "./sequelize-recurring-proforma-occurrence.repository"; + +for (const outcome of ["allowed", "foreign", "missing", "unchanged"] as const) { + const allowed = outcome === "allowed" || outcome === "unchanged"; + const sameCompany = outcome !== "foreign"; + test(`save no reasigna una raíz de otra empresa: resultado=${outcome}`, async (t) => { + const id = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID().toPrimitive(); + const payload = { id: id.toPrimitive(), company_id: companyId, name: "Updated" }; + const transaction = {}; + t.mock.method( + SequelizeRecurringProformaOccurrenceDomainMapper.prototype, + "mapToPersistence", + () => Result.ok(payload) + ); + let writes = 0; + t.mock.method( + RecurringProformaOccurrenceModel, + "findOrCreate", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.equal(options.transaction, transaction); + return [{ company_id: sameCompany ? companyId : "other-company" }, false]; + } + ); + t.mock.method( + RecurringProformaOccurrenceModel, + "update", + async ( + changes: Record, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.equal(sameCompany, true); + assert.deepEqual(options.where, { id: payload.id, company_id: companyId }); + assert.equal(options.transaction, transaction); + assert.equal("company_id" in changes, false); + writes += 1; + return [outcome === "missing" || outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + RecurringProformaOccurrenceModel, + "findOne", + async (options: { where: unknown; transaction?: unknown }) => { + assert.deepEqual(options.where, { id: payload.id, company_id: companyId }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const result = await repository.save({ id } as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(writes, sameCompany ? 1 : 0); + }); +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-proforma-save-scope.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-proforma-save-scope.test.ts new file mode 100644 index 00000000..bf98f0db --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/recurring-proforma-save-scope.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { RecurringProformaItemModel, RecurringProformaModel } from "../../../../common"; +import { SequelizeRecurringProformaDomainMapper } from "../mappers"; + +import { SequelizeRecurringProformaRepository } from "./sequelize-recurring-proforma.repository"; + +for (const outcome of ["allowed", "foreign", "missing", "unchanged"] as const) { + const allowed = outcome === "allowed" || outcome === "unchanged"; + const sameCompany = outcome !== "foreign"; + test(`save no reasigna una raíz de otra empresa: resultado=${outcome}`, async (t) => { + const id = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID().toPrimitive(); + const payload = { id: id.toPrimitive(), company_id: companyId, name: "Updated" }; + const transaction = {}; + t.mock.method(SequelizeRecurringProformaDomainMapper.prototype, "mapToPersistence", () => + Result.ok(payload) + ); + t.mock.method(SequelizeRecurringProformaDomainMapper.prototype, "mapItemsToPersistence", () => + Result.ok([]) + ); + t.mock.method(RecurringProformaItemModel, "destroy", async () => { + assert.equal(allowed, true); + return 0; + }); + let writes = 0; + t.mock.method( + RecurringProformaModel, + "findOrCreate", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.equal(options.transaction, transaction); + return [{ company_id: sameCompany ? companyId : "other-company" }, false]; + } + ); + t.mock.method( + RecurringProformaModel, + "update", + async ( + changes: Record, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.equal(sameCompany, true); + assert.deepEqual(options.where, { id: payload.id, company_id: companyId }); + assert.equal(options.transaction, transaction); + assert.equal("company_id" in changes, false); + writes += 1; + return [outcome === "missing" || outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + RecurringProformaModel, + "findOne", + async (options: { where: unknown; transaction?: unknown }) => { + assert.deepEqual(options.where, { id: payload.id, company_id: companyId }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + const repository = new SequelizeRecurringProformaRepository(); + const result = await repository.save({ id } as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(writes, sameCompany ? 1 : 0); + }); +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts index 624de04a..692fd4c5 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts @@ -24,15 +24,16 @@ const createOccurrence = () => ) ); -test("SequelizeRecurringProformaOccurrenceRepository guarda el payload de occurrence", async () => { +test("SequelizeRecurringProformaOccurrenceRepository guarda el payload de occurrence", async (t) => { const repository = new SequelizeRecurringProformaOccurrenceRepository(); const occurrence = createOccurrence(); - const originalUpsert = RecurringProformaOccurrenceModel.upsert; + const originalFindOrCreate = RecurringProformaOccurrenceModel.findOrCreate; let payload: unknown; - RecurringProformaOccurrenceModel.upsert = (async (value: unknown) => { - payload = value; - return [{} as never, true]; - }) as typeof RecurringProformaOccurrenceModel.upsert; + RecurringProformaOccurrenceModel.findOrCreate = (async (options: { defaults?: unknown }) => { + payload = options.defaults; + return [{ company_id: occurrence.companyId.toPrimitive() } as never, true]; + }) as typeof RecurringProformaOccurrenceModel.findOrCreate; + t.mock.method(RecurringProformaOccurrenceModel, "update", async () => [1]); try { const result = await repository.save(occurrence); @@ -54,7 +55,7 @@ test("SequelizeRecurringProformaOccurrenceRepository guarda el payload de occurr updated_at: occurrence.updatedAt, }); } finally { - RecurringProformaOccurrenceModel.upsert = originalUpsert; + RecurringProformaOccurrenceModel.findOrCreate = originalFindOrCreate; } }); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts index c982642e..1307b9d1 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts @@ -27,9 +27,33 @@ export class SequelizeRecurringProformaOccurrenceRepository if (persistence.isFailure) { return Result.fail(persistence.error); } - await RecurringProformaOccurrenceModel.upsert(persistence.data as any, { + const payload = persistence.data; + const [current] = await RecurringProformaOccurrenceModel.findOrCreate({ + where: { id: payload.id }, + defaults: payload, transaction: transaction as Transaction | undefined, }); + if (current.company_id !== payload.company_id) { + return Result.fail( + new EntityNotFoundError("RecurringProformaOccurrence", "id", payload.id) + ); + } + const { id, company_id, ...changes } = payload; + const [affected] = await RecurringProformaOccurrenceModel.update(changes, { + where: { id, company_id }, + transaction: transaction as Transaction | undefined, + }); + if (affected === 0) { + const exists = await RecurringProformaOccurrenceModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id }, + transaction: transaction as Transaction | undefined, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("RecurringProformaOccurrence", "id", id)); + } + } return Result.ok(); } catch (error: unknown) { return Result.fail(translateSequelizeError(error)); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts index d47c5f21..1b2f4ce7 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts @@ -50,17 +50,26 @@ test("SequelizeRecurringProformaRepository reemplaza items en la transacción su name: "Cuota", }) ); - const originalUpsert = RecurringProformaModel.upsert; + const originalFindOrCreate = RecurringProformaModel.findOrCreate; + const originalUpdate = RecurringProformaModel.update; const originalDestroy = RecurringProformaItemModel.destroy; - RecurringProformaModel.upsert = (async () => [ - {} as never, - true, - ]) as typeof RecurringProformaModel.upsert; + RecurringProformaModel.findOrCreate = (async () => [ + { company_id: recurringProforma.companyId.toPrimitive() } as never, + false, + ]) as typeof RecurringProformaModel.findOrCreate; + RecurringProformaModel.update = (async (_changes: unknown, options: { where: unknown }) => { + assert.deepEqual(options.where, { + id: recurringProforma.id.toPrimitive(), + company_id: recurringProforma.companyId.toPrimitive(), + }); + return [1]; + }) as typeof RecurringProformaModel.update; RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy; try { assert.equal((await repository.save(recurringProforma, {})).isSuccess, true); } finally { - RecurringProformaModel.upsert = originalUpsert; + RecurringProformaModel.findOrCreate = originalFindOrCreate; + RecurringProformaModel.update = originalUpdate; RecurringProformaItemModel.destroy = originalDestroy; } }); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts index 90e309f8..daf0cdf0 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts @@ -36,7 +36,31 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR } const sqlTransaction = transaction as Transaction | undefined; - await RecurringProformaModel.upsert(persistence.data as any, { transaction: sqlTransaction }); + const payload = persistence.data; + const [current] = await RecurringProformaModel.findOrCreate({ + where: { id: payload.id }, + defaults: payload, + transaction: sqlTransaction, + }); + if (current.company_id !== payload.company_id) { + return Result.fail(new EntityNotFoundError("RecurringProforma", "id", payload.id)); + } + const { id, company_id, ...changes } = payload; + const [affected] = await RecurringProformaModel.update(changes, { + where: { id, company_id }, + transaction: sqlTransaction, + }); + if (affected === 0) { + const exists = await RecurringProformaModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id }, + transaction: sqlTransaction, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("RecurringProforma", "id", id)); + } + } await RecurringProformaItemModel.destroy({ where: { recurring_proforma_id: recurringProforma.id.toPrimitive() }, transaction: sqlTransaction, diff --git a/modules/customer-invoices/src/web/issued-invoices/download-pdf/hooks/use-download-invoice-pdf-query.ts b/modules/customer-invoices/src/web/issued-invoices/download-pdf/hooks/use-download-invoice-pdf-query.ts index d5b7250a..46f39bde 100644 --- a/modules/customer-invoices/src/web/issued-invoices/download-pdf/hooks/use-download-invoice-pdf-query.ts +++ b/modules/customer-invoices/src/web/issued-invoices/download-pdf/hooks/use-download-invoice-pdf-query.ts @@ -1,3 +1,6 @@ +import { useCompanyQueryScope } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; + // features/invoices/view-pdf/hooks/use-download-invoice-pdf.ts import { useDataSource } from "@erp/core/hooks"; @@ -15,11 +18,12 @@ export function useDownloadInvoicePDFQuery( invoiceId?: string, options?: DownloadInvoicePDFOptions ) { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && !!invoiceId; return useQuery({ - queryKey: ISSUED_INVOICE_QUERY_KEY(invoiceId ?? "unknown"), + queryKey: companyQueryKey(queryScope, ISSUED_INVOICE_QUERY_KEY(invoiceId ?? "unknown")), queryFn: async (context) => { if (!invoiceId) { throw new Error("invoiceId is required"); diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts index 7defceb3..17334e17 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list.controller.ts @@ -1,4 +1,5 @@ -import { useDataSource, useDebounce } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource, useDebounce } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { INITIAL_PAGE_INDEX } from "@repo/rdx-criteria"; import { type DataTableSort, useDataTablePreferences } from "@repo/rdx-ui/components"; import { NumberHelper } from "@repo/rdx-utils"; @@ -51,6 +52,7 @@ const STATUS_COUNT_OPTIONS: ReadonlyArray<{ ]; export const useIssuedInvoiceListController = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(searchParams.get("q") ?? ""); @@ -125,7 +127,12 @@ export const useIssuedInvoiceListController = () => { const query = useIssuedInvoiceListQuery({ criteria }); const statusCountQueries = useQueries({ queries: STATUS_COUNT_OPTIONS.map((statusOption) => ({ - queryKey: ["issued_invoices", "status_count", debouncedSearch || "", statusOption.value], + queryKey: companyQueryKey(queryScope, [ + "issued_invoices", + "status_count", + debouncedSearch || "", + statusOption.value, + ]), queryFn: async ({ signal }: { signal: AbortSignal }) => { const response = await getListIssuedInvoicesByCriteria(dataSource, { signal, diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts index 44b1b37d..1f5085e4 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetIssuedInvoiceByIdAdapter } from "../adapters"; @@ -15,12 +16,13 @@ export interface UseIssuedInvoiceGetQueryOptions { export const useIssuedInvoiceGetQuery = ( options: UseIssuedInvoiceGetQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const id = options.id; const enabled = options.enabled ?? Boolean(id); return useQuery({ - queryKey: ISSUED_INVOICE_DETAIL_QUERY_KEY(id), + queryKey: companyQueryKey(queryScope, ISSUED_INVOICE_DETAIL_QUERY_KEY(id)), queryFn: async ({ signal }) => { if (!id) { throw new Error("issuedInvoiceId is required"); diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts index 3b5300b4..27a91f42 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-list-query.ts @@ -1,6 +1,7 @@ import type { CriteriaDTO } from "@erp/core"; import { isApiClientError } from "@erp/core/client"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, useQuery } from "@tanstack/react-query"; import { ListIssuedInvoicesAdapter } from "../adapters"; @@ -16,18 +17,20 @@ export interface IssuedInvoicesListQueryOptions { // Obtener todas las facturas export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesListQueryOptions) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_ISSUED_INVOICES_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_ISSUED_INVOICES_QUERY_KEY(criteria)), queryFn: async ({ signal }) => { const dto = await getListIssuedInvoicesByCriteria(dataSource, { signal, criteria }); return ListIssuedInvoicesAdapter.fromDto(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria retry: (failureCount, error) => { if (!isApiClientError(error)) { return failureCount < 1; diff --git a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts index 2bc1b973..ed757cdb 100644 --- a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts @@ -1,7 +1,8 @@ import { usePaymentTermListQuery } from "@erp/catalogs/client/payment-terms"; import type { CompanyResponseDTO } from "@erp/companies/common"; import { applyValidationErrorCollection } from "@erp/core"; -import { useDataSource, useHookForm } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource, useHookForm } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import type { CustomerSelectionOption } from "@erp/customers"; import type { GetCustomerByIdResponseDTO } from "@erp/customers/common"; import { useIdentityAuthSession } from "@erp/identity/client"; @@ -64,6 +65,7 @@ const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection }; export const useCreateProformaController = (options?: UseCreateProformaControllerOptions) => { + const queryScope = useCompanyQueryScope(); const { t } = useTranslation(); const dataSource = useDataSource(); const { activeCompany } = useIdentityAuthSession(); @@ -87,7 +89,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle }); const companySettingsQuery = useQuery<{ defaultPaymentTermId: string | null }, DefaultError>({ - queryKey: buildCompanySettingsQueryKey(activeCompany?.id), + queryKey: companyQueryKey(queryScope, buildCompanySettingsQueryKey(activeCompany?.id)), enabled: Boolean(activeCompany?.id), queryFn: async ({ signal }) => { const dto = await dataSource.getOne("companies", "current", { @@ -101,7 +103,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle }); const selectedCustomerQuery = useQuery<{ paymentTermId: string | null }, DefaultError>({ - queryKey: buildCustomerDetailQueryKey(selectedCustomer?.id), + queryKey: companyQueryKey(queryScope, buildCustomerDetailQueryKey(selectedCustomer?.id)), enabled: Boolean(selectedCustomer?.id), queryFn: async ({ signal }) => { const dto = await dataSource.getOne( diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx index fadd2a57..e68fc2d1 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx @@ -31,6 +31,7 @@ test("el controlador evita envios duplicados, limpia la regla residual y mantien }); mock.module("@erp/core/hooks", { namedExports: { + useCompanyQueryScope: () => null, useHookForm: ({ initialValues }: { initialValues: Record }) => { const values = { ...initialValues }; diff --git a/modules/customer-invoices/src/web/proformas/download-pdf/hooks/use-download-proforma-pdf-query.ts b/modules/customer-invoices/src/web/proformas/download-pdf/hooks/use-download-proforma-pdf-query.ts index 762ecc51..e02ff1c3 100644 --- a/modules/customer-invoices/src/web/proformas/download-pdf/hooks/use-download-proforma-pdf-query.ts +++ b/modules/customer-invoices/src/web/proformas/download-pdf/hooks/use-download-proforma-pdf-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import { PROFORMA_PDF_QUERY_KEY } from "../../shared/hooks/keys"; @@ -12,11 +13,12 @@ export function useDownloadProformaPDFQuery( proformaId?: string, options?: DownloadProformaPDFOptions ) { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && !!proformaId; return useQuery({ - queryKey: PROFORMA_PDF_QUERY_KEY(proformaId), + queryKey: companyQueryKey(queryScope, PROFORMA_PDF_QUERY_KEY(proformaId)), queryFn: async (context) => { if (!proformaId) { throw new Error("proformaId is required"); diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts index cd6138b9..b2e84860 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts @@ -1,3 +1,5 @@ +import type { CompanyQueryScope } from "@erp/core/query"; +import { companyQueryKey } from "@erp/core/query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX } from "../../../issued-invoices/shared/hooks/keys"; @@ -27,79 +29,115 @@ const hasListItems = (page: ProformaList | undefined): page is ProformaList => { // Primitivas -export function cancelProformaListQueries(queryClient: QueryClient) { +export function cancelProformaListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.cancelQueries({ - queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PROFORMAS_QUERY_KEY_PREFIX), }); } -export function invalidateProformaListQueries(queryClient: QueryClient) { +export function invalidateProformaListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.invalidateQueries({ - queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PROFORMAS_QUERY_KEY_PREFIX), }); } -export function invalidateIssuedInvoiceListQueries(queryClient: QueryClient) { +export function invalidateIssuedInvoiceListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.invalidateQueries({ - queryKey: LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX), }); } -export function invalidateProformaDetailQuery(queryClient: QueryClient, proformaId: string) { +export function invalidateProformaDetailQuery( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proformaId: string +) { return queryClient.invalidateQueries({ - queryKey: PROFORMA_QUERY_KEY(proformaId), + queryKey: companyQueryKey(queryScope, PROFORMA_QUERY_KEY(proformaId)), exact: true, }); } -export function invalidateProformaPdfQuery(queryClient: QueryClient, proformaId: string) { +export function invalidateProformaPdfQuery( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proformaId: string +) { return queryClient.invalidateQueries({ - queryKey: PROFORMA_PDF_QUERY_KEY(proformaId), + queryKey: companyQueryKey(queryScope, PROFORMA_PDF_QUERY_KEY(proformaId)), exact: true, }); } export function invalidateProformaIssuanceReadinessQuery( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proformaId: string ) { return queryClient.invalidateQueries({ - queryKey: PROFORMA_ISSUANCE_READINESS_QUERY_KEY(proformaId), + queryKey: companyQueryKey(queryScope, PROFORMA_ISSUANCE_READINESS_QUERY_KEY(proformaId)), exact: true, }); } -export function removeProformaPdfQuery(queryClient: QueryClient, proformaId: string) { +export function removeProformaPdfQuery( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proformaId: string +) { return queryClient.removeQueries({ - queryKey: PROFORMA_PDF_QUERY_KEY(proformaId), + queryKey: companyQueryKey(queryScope, PROFORMA_PDF_QUERY_KEY(proformaId)), exact: true, }); } export function setProformaDetailCache( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proformaId: string, proforma: Proforma ) { - queryClient.setQueryData(PROFORMA_QUERY_KEY(proformaId), proforma); + queryClient.setQueryData( + companyQueryKey(queryScope, PROFORMA_QUERY_KEY(proformaId)), + proforma + ); } -export function removeProformaDetailCache(queryClient: QueryClient, proformaId: string) { +export function removeProformaDetailCache( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proformaId: string +) { queryClient.removeQueries({ - queryKey: PROFORMA_QUERY_KEY(proformaId), + queryKey: companyQueryKey(queryScope, PROFORMA_QUERY_KEY(proformaId)), exact: true, }); } -export function invalidateProformaPdfQueries(queryClient: QueryClient) { +export function invalidateProformaPdfQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.invalidateQueries({ - queryKey: PROFORMAS_PDF_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, PROFORMAS_PDF_QUERY_KEY_PREFIX), }); } -export function getAllProformaListQueryKeys(queryClient: QueryClient): QueryKey[] { +export function getAllProformaListQueryKeys( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +): QueryKey[] { const entries = queryClient.getQueriesData({ - queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_PROFORMAS_QUERY_KEY_PREFIX), }); return entries.map(([key]) => key); @@ -108,10 +146,11 @@ export function getAllProformaListQueryKeys(queryClient: QueryClient): QueryKey[ // Upsert de filas en cache export function upsertProformaInListCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proforma: Pick & Partial ) { - const keys = getAllProformaListQueryKeys(queryClient); + const keys = getAllProformaListQueryKeys(queryScope, queryClient); for (const key of keys) { const page = queryClient.getQueryData(key); @@ -140,10 +179,11 @@ export function upsertProformaInListCaches( // Remove optimista de listados export function removeProformaFromListCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proformaId: string ): ProformaListCacheSnapshot[] { - const snapshots = getAllProformaListQueryKeys(queryClient).map((key) => ({ + const snapshots = getAllProformaListQueryKeys(queryScope, queryClient).map((key) => ({ key, page: queryClient.getQueryData(key), })); @@ -176,42 +216,71 @@ export function restoreProformaListCaches( // Estrategias compuestas -export function syncCreatedProformaCaches(queryClient: QueryClient, proforma: Proforma) { - setProformaDetailCache(queryClient, proforma.id, proforma); - return invalidateProformaListQueries(queryClient); +export function syncCreatedProformaCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proforma: Proforma +) { + setProformaDetailCache(queryScope, queryClient, proforma.id, proforma); + return invalidateProformaListQueries(queryScope, queryClient); } -export function syncUpdatedProformaCaches(queryClient: QueryClient, proforma: Proforma) { - setProformaDetailCache(queryClient, proforma.id, proforma); - upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma)); +export function syncUpdatedProformaCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proforma: Proforma +) { + setProformaDetailCache(queryScope, queryClient, proforma.id, proforma); + upsertProformaInListCaches( + queryScope, + queryClient, + ProformaToListRowPatchAdapter.fromProforma(proforma) + ); return Promise.all([ - invalidateProformaListQueries(queryClient), - invalidateProformaIssuanceReadinessQuery(queryClient, proforma.id), - invalidateProformaPdfQuery(queryClient, proforma.id), + invalidateProformaListQueries(queryScope, queryClient), + invalidateProformaIssuanceReadinessQuery(queryScope, queryClient, proforma.id), + invalidateProformaPdfQuery(queryScope, queryClient, proforma.id), ]); } -export function syncArchivedProformaCaches(queryClient: QueryClient, proforma: Proforma) { - setProformaDetailCache(queryClient, proforma.id, proforma); - upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma)); +export function syncArchivedProformaCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proforma: Proforma +) { + setProformaDetailCache(queryScope, queryClient, proforma.id, proforma); + upsertProformaInListCaches( + queryScope, + queryClient, + ProformaToListRowPatchAdapter.fromProforma(proforma) + ); - return invalidateProformaListQueries(queryClient); + return invalidateProformaListQueries(queryScope, queryClient); } -export function syncUnarchivedProformaCaches(queryClient: QueryClient, proforma: Proforma) { - setProformaDetailCache(queryClient, proforma.id, proforma); - upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma)); +export function syncUnarchivedProformaCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proforma: Proforma +) { + setProformaDetailCache(queryScope, queryClient, proforma.id, proforma); + upsertProformaInListCaches( + queryScope, + queryClient, + ProformaToListRowPatchAdapter.fromProforma(proforma) + ); - return invalidateProformaListQueries(queryClient); + return invalidateProformaListQueries(queryScope, queryClient); } export async function prepareDeleteProformaOptimisticUpdate( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proformaId: string ): Promise { - await cancelProformaListQueries(queryClient); - const snapshots = removeProformaFromListCaches(queryClient, proformaId); + await cancelProformaListQueries(queryScope, queryClient); + const snapshots = removeProformaFromListCaches(queryScope, queryClient, proformaId); return { snapshots }; } @@ -226,49 +295,63 @@ export function rollbackDeleteProformaOptimisticUpdate( restoreProformaListCaches(queryClient, context.snapshots); } -export function finalizeDeletedProformaCaches(queryClient: QueryClient, proformaId: string) { - removeProformaDetailCache(queryClient, proformaId); - removeProformaPdfQuery(queryClient, proformaId); - return invalidateProformaListQueries(queryClient); +export function finalizeDeletedProformaCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + proformaId: string +) { + removeProformaDetailCache(queryScope, queryClient, proformaId); + removeProformaPdfQuery(queryScope, queryClient, proformaId); + return invalidateProformaListQueries(queryScope, queryClient); } // Cambio de estado e issue export async function syncChangedProformaStatusCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proformaId: string, proforma?: Proforma ) { if (proforma) { - setProformaDetailCache(queryClient, proforma.id, proforma); - upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma)); + setProformaDetailCache(queryScope, queryClient, proforma.id, proforma); + upsertProformaInListCaches( + queryScope, + queryClient, + ProformaToListRowPatchAdapter.fromProforma(proforma) + ); } else { - await invalidateProformaDetailQuery(queryClient, proformaId); + await invalidateProformaDetailQuery(queryScope, queryClient, proformaId); } await Promise.all([ - invalidateProformaListQueries(queryClient), - invalidateProformaIssuanceReadinessQuery(queryClient, proformaId), - invalidateProformaPdfQuery(queryClient, proformaId), + invalidateProformaListQueries(queryScope, queryClient), + invalidateProformaIssuanceReadinessQuery(queryScope, queryClient, proformaId), + invalidateProformaPdfQuery(queryScope, queryClient, proformaId), ]); } export async function syncIssuedProformaCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, proformaId: string, proforma?: Proforma ) { if (proforma) { - setProformaDetailCache(queryClient, proforma.id, proforma); - upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma)); + setProformaDetailCache(queryScope, queryClient, proforma.id, proforma); + upsertProformaInListCaches( + queryScope, + queryClient, + ProformaToListRowPatchAdapter.fromProforma(proforma) + ); } else { - await invalidateProformaDetailQuery(queryClient, proformaId); + await invalidateProformaDetailQuery(queryScope, queryClient, proformaId); } await Promise.all([ - invalidateProformaListQueries(queryClient), - invalidateProformaIssuanceReadinessQuery(queryClient, proformaId), - invalidateIssuedInvoiceListQueries(queryClient), - invalidateProformaPdfQuery(queryClient, proformaId), + invalidateProformaListQueries(queryScope, queryClient), + invalidateProformaIssuanceReadinessQuery(queryScope, queryClient, proformaId), + invalidateIssuedInvoiceListQueries(queryScope, queryClient), + invalidateProformaPdfQuery(queryScope, queryClient, proformaId), ]); } diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts index e55232b2..5078052c 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListDocumentSeriesAdapter } from "../adapters"; @@ -10,10 +11,11 @@ import { DOCUMENT_SERIES_QUERY_KEY } from "./keys"; export const useDocumentSeriesQuery = ( documentType: "proforma" | "issued_invoice" = "proforma" ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: [...DOCUMENT_SERIES_QUERY_KEY, documentType], + queryKey: companyQueryKey(queryScope, [...DOCUMENT_SERIES_QUERY_KEY, documentType]), queryFn: async ({ signal }) => { const dto = await listDocumentSeries(dataSource, signal, documentType); return ListDocumentSeriesAdapter.fromDto(dto); diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts index 4cac29e3..45005a20 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetProformaByIdAdapter } from "../adapters"; @@ -9,6 +9,7 @@ import { DUPLICATE_PROFORMA_MUTATION_KEY } from "./keys"; import { syncCreatedProformaCaches } from "./proforma-cache-strategy"; export const useDuplicateProformaByIdMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -22,7 +23,7 @@ export const useDuplicateProformaByIdMutation = () => { } satisfies DuplicateProformaResult; }, onSuccess: async (result) => { - await syncCreatedProformaCaches(queryClient, result.proforma); + await syncCreatedProformaCaches(queryScope, queryClient, result.proforma); }, }) as ReturnType< typeof useMutation diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-archive-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-archive-mutation.ts index 12967cb7..2b4e900b 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-archive-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-archive-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetProformaByIdAdapter } from "../adapters"; @@ -9,6 +9,7 @@ import { ARCHIVE_PROFORMA_MUTATION_KEY } from "./keys"; import { syncArchivedProformaCaches } from "./proforma-cache-strategy"; export const useProformaArchiveMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -19,7 +20,7 @@ export const useProformaArchiveMutation = () => { return GetProformaByIdAdapter.fromDto(dto); }, onSuccess: async (proforma) => { - await syncArchivedProformaCaches(queryClient, proforma); + await syncArchivedProformaCaches(queryScope, queryClient, proforma); }, }) as ReturnType>; }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-change-status-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-change-status-mutation.ts index 6e8a97f3..ad3a54a9 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-change-status-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-change-status-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -16,6 +16,7 @@ import { toValidationErrors } from "./to-validation-errors"; type ChangeProformaStatusContext = {}; export const useProformaChangeStatusMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = ChangeStatusProformaByIdRequestSchema; @@ -42,7 +43,7 @@ export const useProformaChangeStatusMutation = () => { return await changeProformaStatusById(dataSource, params); }, onSuccess: async (_result, { id: proformaId }) => { - await syncChangedProformaStatusCaches(queryClient, proformaId); + await syncChangedProformaStatusCaches(queryScope, queryClient, proformaId); }, }); }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-create-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-create-mutation.ts index 36f69fc6..d3b4d7f1 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-create-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-create-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { CreateProformaRequestSchema } from "@erp/customer-invoices/common"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -17,6 +17,7 @@ import { toValidationErrors } from "./to-validation-errors"; type CreateProformaContext = {}; export const useProformaCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = CreateProformaRequestSchema; @@ -39,10 +40,10 @@ export const useProformaCreateMutation = () => { return GetProformaByIdAdapter.fromDto(dto); }, onSuccess: async (proforma) => { - await syncCreatedProformaCaches(queryClient, proforma); + await syncCreatedProformaCaches(queryScope, queryClient, proforma); }, onSettled: async () => { - await invalidateProformaListQueries(queryClient); + await invalidateProformaListQueries(queryScope, queryClient); }, }); }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts index 82f6e654..e41d7c31 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import type { DeleteProformaByIdParams } from "../api"; @@ -15,6 +15,7 @@ import { interface DeleteProformaContext extends DeleteProformaCacheContext {} export const useProformaDeleteMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -34,7 +35,7 @@ export const useProformaDeleteMutation = () => { return { proformaId }; }, onMutate: async ({ proformaId }) => { - return prepareDeleteProformaOptimisticUpdate(queryClient, proformaId); + return prepareDeleteProformaOptimisticUpdate(queryScope, queryClient, proformaId); }, onError: (_error, _params, context) => { @@ -42,7 +43,7 @@ export const useProformaDeleteMutation = () => { }, onSettled: async (_data, _error, { proformaId }) => { - await finalizeDeletedProformaCaches(queryClient, proformaId); + await finalizeDeletedProformaCaches(queryScope, queryClient, proformaId); }, }); }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-get-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-get-query.ts index cea692f6..5bec3170 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-get-query.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-get-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetProformaByIdAdapter } from "../adapters"; @@ -15,12 +16,13 @@ export interface UseProformaGetQueryOptions { export const useProformaGetQuery = ( options: UseProformaGetQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const id = options?.id; const enabled = options?.enabled ?? Boolean(id); return useQuery({ - queryKey: PROFORMA_QUERY_KEY(id), + queryKey: companyQueryKey(queryScope, PROFORMA_QUERY_KEY(id)), queryFn: async ({ signal }) => { if (!id) { throw new Error("proformaId is required"); diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts index 0f7810d6..f1b487bd 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetProformaIssuanceReadinessAdapter } from "../adapters"; @@ -15,12 +16,13 @@ export interface UseProformaIssuanceReadinessQueryOptions { export const useProformaIssuanceReadinessQuery = ( options: UseProformaIssuanceReadinessQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const id = options.id; const enabled = options.enabled ?? Boolean(id); return useQuery({ - queryKey: PROFORMA_ISSUANCE_READINESS_QUERY_KEY(id), + queryKey: companyQueryKey(queryScope, PROFORMA_ISSUANCE_READINESS_QUERY_KEY(id)), queryFn: async ({ signal }) => { if (!id) { throw new Error("proformaId is required"); diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issue-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issue-mutation.ts index 9995a4e2..05f1efb2 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issue-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issue-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -13,6 +13,7 @@ import { syncIssuedProformaCaches } from "./proforma-cache-strategy"; type IssueProformaByIdContext = {}; export const useProformaIssueMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -33,7 +34,7 @@ export const useProformaIssueMutation = () => { return issueProformaById(dataSource, params); }, onSuccess: async (_result, { id: proformaId }) => { - await syncIssuedProformaCaches(queryClient, proformaId); + await syncIssuedProformaCaches(queryScope, queryClient, proformaId); }, }); }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-unarchive-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-unarchive-mutation.ts index e9483997..6ca7d685 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-unarchive-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-unarchive-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { GetProformaByIdAdapter } from "../adapters"; @@ -8,6 +8,7 @@ import { UNARCHIVE_PROFORMA_MUTATION_KEY } from "./keys"; import { syncUnarchivedProformaCaches } from "./proforma-cache-strategy"; export const useProformaUnarchiveMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -18,7 +19,7 @@ export const useProformaUnarchiveMutation = () => { return GetProformaByIdAdapter.fromDto(dto); }, onSuccess: async (proforma) => { - await syncUnarchivedProformaCaches(queryClient, proforma); + await syncUnarchivedProformaCaches(queryScope, queryClient, proforma); }, }) as ReturnType< typeof useMutation diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts index ba94b81a..a083f3c5 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +15,7 @@ import { toValidationErrors } from "./to-validation-errors"; type UpdateProformaContext = {}; export const useProformaUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = UpdateProformaByIdRequestSchema; @@ -38,7 +39,7 @@ export const useProformaUpdateMutation = () => { return GetProformaByIdAdapter.fromDto(dto); }, onSuccess: async (proforma) => { - await syncUpdatedProformaCaches(queryClient, proforma); + await syncUpdatedProformaCaches(queryScope, queryClient, proforma); }, }); }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proformas-list-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proformas-list-query.ts index 21580f7b..a9ae2a6c 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proformas-list-query.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proformas-list-query.ts @@ -1,5 +1,6 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import type { ProformaListScope } from "../../list/types/proforma-list-filters"; @@ -18,18 +19,20 @@ export interface ProformasListQueryOptions { export const useProformasListQuery = ( options?: ProformasListQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; const scope = options?.scope ?? "normal"; return useQuery({ - queryKey: LIST_PROFORMAS_QUERY_KEY(scope, criteria), + queryKey: companyQueryKey(queryScope, LIST_PROFORMAS_QUERY_KEY(scope, criteria)), queryFn: async ({ signal }) => { const dto = await getListProformasByCriteria(dataSource, { signal, criteria, scope }); return ListProformasAdapter.fromDto(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria }); }; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts index c5554841..dab94db3 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts @@ -1,5 +1,6 @@ import type { CompanyResponseDTO } from "@erp/companies/common"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import type { CustomerSelectionOption } from "@erp/customers"; import type { GetCustomerByIdResponseDTO } from "@erp/customers/common"; import { useIdentityAuthSession } from "@erp/identity/client"; @@ -87,6 +88,7 @@ const buildCustomerDetailQueryKey = (customerId?: string) => export const useUpdateProformaCustomerChangeController = ( params: UseUpdateProformaCustomerChangeControllerParams ) => { + const queryScope = useCompanyQueryScope(); const { t } = useTranslation(); const dataSource = useDataSource(); const { activeCompany } = useIdentityAuthSession(); @@ -96,7 +98,7 @@ export const useUpdateProformaCustomerChangeController = ( const companyId = params.proforma?.companyId ?? activeCompany?.id; const companySettingsQuery = useQuery<{ defaultPaymentTermId: string | null }, DefaultError>({ - queryKey: buildCompanySettingsQueryKey(companyId), + queryKey: companyQueryKey(queryScope, buildCompanySettingsQueryKey(companyId)), enabled: Boolean(companyId), queryFn: async ({ signal }) => { const dto = await dataSource.getOne("companies", "current", { @@ -110,7 +112,7 @@ export const useUpdateProformaCustomerChangeController = ( }); const candidateCustomerQuery = useQuery({ - queryKey: buildCustomerDetailQueryKey(candidateCustomer?.id), + queryKey: companyQueryKey(queryScope, buildCustomerDetailQueryKey(candidateCustomer?.id)), enabled: Boolean(candidateCustomer?.id), queryFn: async ({ signal }) => { const dto = await dataSource.getOne( diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx index bdb9a88f..9d073b15 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx @@ -26,6 +26,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb mock.module("@erp/core/hooks", { namedExports: { + useCompanyQueryScope: () => null, UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children, useReturnToNavigation: () => ({ navigateBack: () => undefined, diff --git a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx index 650a2c7f..a442842c 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx @@ -29,6 +29,7 @@ const readMarkup = async (stream: ReadableStream) => { test("/recurring-proformas/create resuelve y renderiza el formulario real", async () => { mock.module("@erp/core/hooks", { namedExports: { + useCompanyQueryScope: () => null, useDataSource: () => ({}), useDebounce: (value: unknown) => value, useHookForm: ({ initialValues }: { initialValues: Record }) => diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-create-cta.test.tsx b/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-create-cta.test.tsx index 5cdacada..5b9ac81f 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-create-cta.test.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-create-cta.test.tsx @@ -1,4 +1,7 @@ /// +import { companyQueryKey } from "@erp/core/query"; + +const queryScope = null; import assert from "node:assert/strict"; import { mock, test } from "node:test"; @@ -16,6 +19,7 @@ test("el listado renderiza el CTA productivo de creacion", async () => { mock.module("@erp/core/hooks", { namedExports: { useDataSource: () => ({}), + useCompanyQueryScope: () => null, useDebounce: (value: unknown) => value, }, }); @@ -26,7 +30,7 @@ test("el listado renderiza el CTA productivo de creacion", async () => { ensureModuleTranslations(); await i18n.changeLanguage("es"); - queryClient.setQueryData(recurringProformaQueryKeys.list(filters), { + queryClient.setQueryData(companyQueryKey(queryScope, recurringProformaQueryKeys.list(filters)), { page: 1, per_page: 20, total_items: 1, diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx index 962cfd68..6ef27a0b 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { mock, test } from "node:test"; +import { companyQueryKey } from "@erp/core/query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; @@ -8,6 +9,7 @@ test("invalida la lista de recurrencias despues de crearla desde una proforma", mock.module("@erp/core/hooks", { namedExports: { useDataSource: () => ({}), + useCompanyQueryScope: () => null, }, }); mock.module("../api/create-recurring-proforma-from-proforma.api", { @@ -58,7 +60,7 @@ test("invalida la lista de recurrencias despues de crearla desde una proforma", }, }); - assert.deepEqual(invalidatedQueryKeys, [recurringProformaQueryKeys.lists]); + assert.deepEqual(invalidatedQueryKeys, [companyQueryKey(null, recurringProformaQueryKeys.lists)]); mock.restoreAll(); }); diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts index 120e74fc..b512944e 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { CreateRecurringProformaFromProformaRequestSchema } from "@erp/customer-invoices/common"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -20,6 +21,7 @@ const toValidationErrors = (error: ZodError) => })); export const useCreateRecurringProformaFromProforma = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); @@ -38,7 +40,9 @@ export const useCreateRecurringProformaFromProforma = () => { return await createRecurringProformaFromProforma(dataSource, params); }, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists }); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, recurringProformaQueryKeys.lists), + }); }, }); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma.ts index e2a01066..3ec1c206 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -9,13 +10,16 @@ import { import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; export const useCreateRecurringProforma = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: CreateRecurringProformaInput) => createRecurringProforma(dataSource, input), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists }); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, recurringProformaQueryKeys.lists), + }); }, }); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts index 834a44e2..3998a0ba 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; @@ -14,6 +15,7 @@ export type RecurringProformaAction = | "delete"; export const useRecurringProformaActionMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); @@ -26,7 +28,9 @@ export const useRecurringProformaActionMutation = () => { data: undefined, }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.all }); + await queryClient.invalidateQueries({ + queryKey: companyQueryKey(queryScope, recurringProformaQueryKeys.all), + }); }, }); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-occurrences.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-occurrences.ts index a9eb7197..2df78123 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-occurrences.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-occurrences.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import { listRecurringProformaOccurrences } from "../api/list-recurring-proforma-occurrences.api"; @@ -10,13 +11,18 @@ export const useRecurringProformaOccurrences = ( recurringProformaId: string | undefined, filters: RecurringProformaOccurrencesFilters ) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: recurringProformaQueryKeys.occurrences(recurringProformaId, filters), + queryKey: companyQueryKey( + queryScope, + recurringProformaQueryKeys.occurrences(recurringProformaId, filters) + ), queryFn: ({ signal }) => listRecurringProformaOccurrences(dataSource, recurringProformaId ?? "", filters, signal), enabled: Boolean(recurringProformaId), - placeholderData: (previousData) => previousData, + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), }); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma.ts index 7eaeeeb7..5652c418 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import { getRecurringProforma } from "../api/get-recurring-proforma.api"; @@ -6,10 +7,11 @@ import { getRecurringProforma } from "../api/get-recurring-proforma.api"; import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; export const useRecurringProforma = (recurringProformaId: string | undefined) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: recurringProformaQueryKeys.detail(recurringProformaId), + queryKey: companyQueryKey(queryScope, recurringProformaQueryKeys.detail(recurringProformaId)), queryFn: ({ signal }) => getRecurringProforma(dataSource, recurringProformaId ?? "", signal), enabled: Boolean(recurringProformaId), }); diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts index 03ab4e69..c54ce26a 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import { ListRecurringProformasAdapter } from "../adapters/list-recurring-proformas.adapter"; @@ -8,14 +9,16 @@ import type { RecurringProformaListFilters } from "../recurring-proforma-list"; import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; export const useRecurringProformas = (filters: RecurringProformaListFilters) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: recurringProformaQueryKeys.list(filters), + queryKey: companyQueryKey(queryScope, recurringProformaQueryKeys.list(filters)), queryFn: async ({ signal }) => ListRecurringProformasAdapter.fromDto( await listRecurringProformas(dataSource, filters, signal) ), - placeholderData: (previousData) => previousData, + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), }); }; diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts index 2bec9ed0..5953099d 100644 --- a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetCompanyVerifactuProfileAdapter } from "../adapters"; @@ -14,10 +15,11 @@ export interface CompanyVerifactuProfileQueryOptions { export const useCompanyVerifactuProfileQuery = ( options?: CompanyVerifactuProfileQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: COMPANY_VERIFACTU_PROFILE_QUERY_KEY, + queryKey: companyQueryKey(queryScope, COMPANY_VERIFACTU_PROFILE_QUERY_KEY), queryFn: async ({ signal }) => { const dto = await getCompanyVerifactuProfile(dataSource, { signal }); return GetCompanyVerifactuProfileAdapter.fromDto(dto); diff --git a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts index d69bb1c5..5dc31813 100644 --- a/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts +++ b/modules/customer-invoices/src/web/settings/verifactu/shared/hooks/use-company-verifactu-profile-update-mutation.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +16,7 @@ import { import { toValidationErrors } from "./to-validation-errors"; export const useCompanyVerifactuProfileUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -32,7 +34,10 @@ export const useCompanyVerifactuProfileUpdateMutation = () => { return UpdateCompanyVerifactuProfileAdapter.fromDto(dto); }, onSuccess: async (profile) => { - queryClient.setQueryData(COMPANY_VERIFACTU_PROFILE_QUERY_KEY, profile); + queryClient.setQueryData( + companyQueryKey(queryScope, COMPANY_VERIFACTU_PROFILE_QUERY_KEY), + profile + ); }, }); }; diff --git a/modules/customers/src/api/application/repositories/customer-repository.interface.ts b/modules/customers/src/api/application/repositories/customer-repository.interface.ts index 9af9cb6a..dd0f0436 100644 --- a/modules/customers/src/api/application/repositories/customer-repository.interface.ts +++ b/modules/customers/src/api/application/repositories/customer-repository.interface.ts @@ -27,13 +27,18 @@ export interface ICustomerRepository { create(customer: Customer, transaction: unknown): Promise>; /** - * Actualiza un cliente existente. + * Actualiza un cliente dentro del alcance confiable de la operación. + * La condición efectiva de persistencia incluye companyId y el ID del agregado. * * @param customer - El cliente a actualizar. * @param transaction - Transacción activa para la operación. * @returns Result */ - update(customer: Customer, transaction: unknown): Promise>; + update( + companyId: UniqueID, + customer: Customer, + transaction: unknown + ): Promise>; /** * Comprueba si existe un Customer con un `id` dentro de una `company`. diff --git a/modules/customers/src/api/application/services/customer-updater.ts b/modules/customers/src/api/application/services/customer-updater.ts index bef75aa5..7f65eea3 100644 --- a/modules/customers/src/api/application/services/customer-updater.ts +++ b/modules/customers/src/api/application/services/customer-updater.ts @@ -73,7 +73,7 @@ export class CustomerUpdater implements ICustomerUpdater { } // Persistir cambios - const saveResult = await this.repository.update(customer, transaction); + const saveResult = await this.repository.update(companyId, customer, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer-company-scope.test.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer-company-scope.test.ts new file mode 100644 index 00000000..187c0ed4 --- /dev/null +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer-company-scope.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { CustomerModel } from "../models/sequelize-customer.model"; + +import { CustomerRepository } from "./customer.repository"; + +const companyId = UniqueID.generateNewID(); +const otherCompanyId = UniqueID.generateNewID(); +const id = UniqueID.generateNewID().toPrimitive(); + +for (const outcome of ["allowed", "foreign", "unchanged"] as const) { + const allowed = outcome !== "foreign"; + test(`escritura empresarial: resultado=${outcome}`, async (t) => { + const requestedCompany = allowed ? companyId : otherCompanyId; + const transaction = {}; + let modified = false; + const repository = new CustomerRepository( + { + mapToPersistence: () => + Result.ok({ + id, + company_id: requestedCompany.toPrimitive(), + name: "Updated", + items: [], + taxes: [], + }), + } as never, + {} as never, + {} as never + ); + t.mock.method( + CustomerModel, + "update", + async ( + _payload: unknown, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + if (options.where.company_id !== companyId.toPrimitive()) { + return [0]; + } + modified = true; + return [outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + CustomerModel, + "findOne", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + + const result = await repository.update(requestedCompany, {} as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(modified, allowed); + if (!allowed) { + assert.equal(result.error.name.includes("NotFound"), true); + } + }); +} diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts index 634c7f78..c2dae3e2 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts @@ -98,24 +98,46 @@ export class CustomerRepository /** * Actualiza un cliente existente. * + * @param companyId - Alcance empresarial obtenido del contexto de aplicación. + * * @param customer - El cliente a actualizar. * @param transaction - Transacción activa para la operación. * @returns Result */ - async update(customer: Customer, transaction?: Transaction): Promise> { + async update( + companyId: UniqueID, + customer: Customer, + transaction?: Transaction + ): Promise> { try { const dtoResult = this.domainMapper.mapToPersistence(customer); - const { id, ...updatePayload } = dtoResult.data; + if (dtoResult.isFailure) { + return Result.fail(dtoResult.error); + } + const { id, company_id, ...updatePayload } = dtoResult.data; + if (company_id !== companyId.toPrimitive()) { + return Result.fail(new EntityNotFoundError("Customer", "id", id)); + } - await CustomerModel.update(updatePayload, { - where: { id /*, version */ }, + const [affected] = await CustomerModel.update(updatePayload, { + where: { id, company_id: companyId.toPrimitive() }, transaction, //individualHooks: true, }); - // CustomerUpdater ya comprobó existencia y scope antes de persistir. - // MariaDB devuelve 0 cuando el estado recibido es idéntico al almacenado. + // MariaDB puede devolver cero ante un no-op; la comprobación sigue acotada a empresa. + if (affected === 0) { + const exists = await CustomerModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id: companyId.toPrimitive() }, + transaction, + }); + if (!exists) { + return Result.fail(new EntityNotFoundError("Customer", "id", id)); + } + } return Result.ok(); } catch (err: unknown) { return Result.fail(translateSequelizeError(err)); diff --git a/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts b/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts index ad374dc6..b9b562de 100644 --- a/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts +++ b/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts @@ -5,17 +5,24 @@ import { import { getPaymentTermOptions, usePaymentTermListQuery } from "@erp/catalogs/client/payment-terms"; import { getTaxRegimeOptions, useTaxRegimesListQuery } from "@erp/catalogs/client/tax-regimes"; import type { CompanyResponseDTO } from "@erp/companies"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { useIdentityAuthSession } from "@erp/identity/client"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; export const useCustomerCreateFiscalController = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const { activeCompany } = useIdentityAuthSession(); const companyQuery = useQuery({ - queryKey: ["customers", "create", "company-default-payment-term", activeCompany?.id], + queryKey: companyQueryKey(queryScope, [ + "customers", + "create", + "company-default-payment-term", + activeCompany?.id, + ]), enabled: Boolean(activeCompany?.id), queryFn: async ({ signal }) => { const dto = await dataSource.getOne( diff --git a/modules/customers/src/web/shared/hooks/customer-cache-strategy.test.ts b/modules/customers/src/web/shared/hooks/customer-cache-strategy.test.ts index 646649ef..8372fe8e 100644 --- a/modules/customers/src/web/shared/hooks/customer-cache-strategy.test.ts +++ b/modules/customers/src/web/shared/hooks/customer-cache-strategy.test.ts @@ -1,3 +1,7 @@ +import { companyQueryKey } from "@erp/core/query"; + +const queryScope = null; + import assert from "node:assert/strict"; import test from "node:test"; @@ -74,26 +78,38 @@ test("sincroniza las listas y el detalle sin tratar el plazo de pago de alta com totalItems: 3, totalPages: 2, }; - client.setQueryData(paymentTermKey, paymentTermId); - client.setQueryData(firstKey, firstPage); - client.setQueryData(secondKey, secondPage); + client.setQueryData(companyQueryKey(queryScope, paymentTermKey), paymentTermId); + client.setQueryData(companyQueryKey(queryScope, firstKey), firstPage); + client.setQueryData(companyQueryKey(queryScope, secondKey), secondPage); - await syncUpdatedCustomerCaches(client, customer); + await syncUpdatedCustomerCaches(queryScope, client, customer); - assert.deepEqual(getAllCustomerListQueryKeys(client), [firstKey, secondKey]); - assert.deepEqual(client.getQueryData(CUSTOMER_QUERY_KEY(customer.id)), customer); + assert.deepEqual(getAllCustomerListQueryKeys(queryScope, client), [ + companyQueryKey(queryScope, firstKey), + companyQueryKey(queryScope, secondKey), + ]); + assert.deepEqual( + client.getQueryData(companyQueryKey(queryScope, CUSTOMER_QUERY_KEY(customer.id))), + customer + ); for (const key of [firstKey, secondKey]) { - const page = client.getQueryData(key); + const page = client.getQueryData(companyQueryKey(queryScope, key)); assert.ok(page); assert.equal(page.items[0]?.name, customer.name); assert.equal(page.totalItems, 3); assert.equal(page.totalPages, 2); - assert.equal(client.getQueryState(key)?.isInvalidated, true); + assert.equal(client.getQueryState(companyQueryKey(queryScope, key))?.isInvalidated, true); } - assert.deepEqual(client.getQueryData(firstKey)?.items[1], otherRow); + assert.deepEqual( + client.getQueryData(companyQueryKey(queryScope, firstKey))?.items[1], + otherRow + ); assert.equal(firstPage.items[0]?.name, "Previous customer"); - assert.equal(client.getQueryData(paymentTermKey), paymentTermId); - assert.equal(client.getQueryState(paymentTermKey)?.isInvalidated, false); + assert.equal(client.getQueryData(companyQueryKey(queryScope, paymentTermKey)), paymentTermId); + assert.equal( + client.getQueryState(companyQueryKey(queryScope, paymentTermKey))?.isInvalidated, + false + ); } finally { client.clear(); } @@ -103,13 +119,18 @@ test("tolera una query de listado pendiente sin datos al sincronizar", async () const client = new QueryClient(); try { const key = LIST_CUSTOMERS_QUERY_KEY(); - client.getQueryCache().build(client, { queryKey: key }); - assert.deepEqual(client.getQueriesData({ queryKey: key }), [[key, undefined]]); + client.getQueryCache().build(client, { queryKey: companyQueryKey(queryScope, key) }); + assert.deepEqual(client.getQueriesData({ queryKey: companyQueryKey(queryScope, key) }), [ + [companyQueryKey(queryScope, key), undefined], + ]); - await syncUpdatedCustomerCaches(client, customer); + await syncUpdatedCustomerCaches(queryScope, client, customer); - assert.equal(client.getQueryData(key), undefined); - assert.deepEqual(client.getQueryData(CUSTOMER_QUERY_KEY(customer.id)), customer); + assert.equal(client.getQueryData(companyQueryKey(queryScope, key)), undefined); + assert.deepEqual( + client.getQueryData(companyQueryKey(queryScope, CUSTOMER_QUERY_KEY(customer.id))), + customer + ); } finally { client.clear(); } @@ -126,15 +147,21 @@ test("el borrado de filas tampoco recorre la query de plazo de pago", () => { totalItems: 1, totalPages: 1, }; - client.setQueryData(paymentTermKey, paymentTermId); - client.setQueryData(key, page); + client.setQueryData(companyQueryKey(queryScope, paymentTermKey), paymentTermId); + client.setQueryData(companyQueryKey(queryScope, key), page); - const snapshots = removeCustomerFromListCaches(client, customer.id); + const snapshots = removeCustomerFromListCaches(queryScope, client, customer.id); - assert.deepEqual(snapshots, [{ key, page }]); - assert.deepEqual(client.getQueryData(key)?.items, []); - assert.equal(client.getQueryData(key)?.totalItems, 0); - assert.equal(client.getQueryData(paymentTermKey), paymentTermId); + assert.deepEqual(snapshots, [{ key: companyQueryKey(queryScope, key), page }]); + assert.deepEqual( + client.getQueryData(companyQueryKey(queryScope, key))?.items, + [] + ); + assert.equal( + client.getQueryData(companyQueryKey(queryScope, key))?.totalItems, + 0 + ); + assert.equal(client.getQueryData(companyQueryKey(queryScope, paymentTermKey)), paymentTermId); } finally { client.clear(); } diff --git a/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts b/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts index f9a37633..3fbca641 100644 --- a/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts +++ b/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts @@ -1,3 +1,5 @@ +import type { CompanyQueryScope } from "@erp/core/query"; +import { companyQueryKey } from "@erp/core/query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { CustomerToListRowPatchAdapter } from "../adapters"; @@ -20,53 +22,75 @@ export interface DeleteCustomerCacheContext { snapshots: CustomerListCacheSnapshot[]; } -export function cancelCustomerListQueries(queryClient: QueryClient) { +export function cancelCustomerListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.cancelQueries({ - queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_CUSTOMERS_QUERY_KEY_PREFIX), }); } -export function invalidateCustomerListQueries(queryClient: QueryClient) { +export function invalidateCustomerListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.invalidateQueries({ - queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_CUSTOMERS_QUERY_KEY_PREFIX), }); } -export function invalidateCustomerDetailQuery(queryClient: QueryClient, customerId: string) { +export function invalidateCustomerDetailQuery( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + customerId: string +) { return queryClient.invalidateQueries({ - queryKey: CUSTOMER_QUERY_KEY(customerId), + queryKey: companyQueryKey(queryScope, CUSTOMER_QUERY_KEY(customerId)), exact: true, }); } export function setCustomerDetailCache( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, customerId: string, customer: Customer ) { - queryClient.setQueryData(CUSTOMER_QUERY_KEY(customerId), customer); + queryClient.setQueryData( + companyQueryKey(queryScope, CUSTOMER_QUERY_KEY(customerId)), + customer + ); } -export function removeCustomerDetailCache(queryClient: QueryClient, customerId: string) { +export function removeCustomerDetailCache( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + customerId: string +) { queryClient.removeQueries({ - queryKey: CUSTOMER_QUERY_KEY(customerId), + queryKey: companyQueryKey(queryScope, CUSTOMER_QUERY_KEY(customerId)), exact: true, }); } -export function getAllCustomerListQueryKeys(queryClient: QueryClient): QueryKey[] { +export function getAllCustomerListQueryKeys( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +): QueryKey[] { const entries = queryClient.getQueriesData({ - queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_CUSTOMERS_QUERY_KEY_PREFIX), }); return entries.map(([key]) => key); } export function upsertCustomerInListCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, customer: Pick & Partial ) { - const keys = getAllCustomerListQueryKeys(queryClient); + const keys = getAllCustomerListQueryKeys(queryScope, queryClient); for (const key of keys) { const page = queryClient.getQueryData(key); @@ -93,10 +117,11 @@ export function upsertCustomerInListCaches( } export function removeCustomerFromListCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, customerId: string ): CustomerListCacheSnapshot[] { - const snapshots = getAllCustomerListQueryKeys(queryClient).map((key) => ({ + const snapshots = getAllCustomerListQueryKeys(queryScope, queryClient).map((key) => ({ key, page: queryClient.getQueryData(key), })); @@ -125,23 +150,36 @@ export function restoreCustomerListCaches( } } -export function syncCreatedCustomerCaches(queryClient: QueryClient, customer: Customer) { - setCustomerDetailCache(queryClient, customer.id, customer); +export function syncCreatedCustomerCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + customer: Customer +) { + setCustomerDetailCache(queryScope, queryClient, customer.id, customer); } -export function syncUpdatedCustomerCaches(queryClient: QueryClient, customer: Customer) { - setCustomerDetailCache(queryClient, customer.id, customer); - upsertCustomerInListCaches(queryClient, CustomerToListRowPatchAdapter.fromCustomer(customer)); +export function syncUpdatedCustomerCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + customer: Customer +) { + setCustomerDetailCache(queryScope, queryClient, customer.id, customer); + upsertCustomerInListCaches( + queryScope, + queryClient, + CustomerToListRowPatchAdapter.fromCustomer(customer) + ); - return invalidateCustomerListQueries(queryClient); + return invalidateCustomerListQueries(queryScope, queryClient); } export async function prepareDeleteCustomerOptimisticUpdate( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, customerId: string ): Promise { - await cancelCustomerListQueries(queryClient); - const snapshots = removeCustomerFromListCaches(queryClient, customerId); + await cancelCustomerListQueries(queryScope, queryClient); + const snapshots = removeCustomerFromListCaches(queryScope, queryClient, customerId); return { snapshots }; } @@ -156,7 +194,11 @@ export function rollbackDeleteCustomerOptimisticUpdate( restoreCustomerListCaches(queryClient, context.snapshots); } -export function finalizeDeletedCustomerCaches(queryClient: QueryClient, customerId: string) { - removeCustomerDetailCache(queryClient, customerId); - return invalidateCustomerListQueries(queryClient); +export function finalizeDeletedCustomerCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + customerId: string +) { + removeCustomerDetailCache(queryScope, queryClient, customerId); + return invalidateCustomerListQueries(queryScope, queryClient); } diff --git a/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts index 78fcda44..3d7d8eb9 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -17,6 +17,7 @@ import { toValidationErrors } from "./to-validation-errors"; type CreateCustomerContext = {}; export const useCustomerCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = CreateCustomerRequestSchema; @@ -51,10 +52,10 @@ export const useCustomerCreateMutation = () => { }, onSuccess: (createdCustomer) => { - syncCreatedCustomerCaches(queryClient, createdCustomer); + return syncCreatedCustomerCaches(queryScope, queryClient, createdCustomer); }, onSettled: async () => { - await invalidateCustomerListQueries(queryClient); + await invalidateCustomerListQueries(queryScope, queryClient); }, }); }; diff --git a/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts index 427de170..5ef15262 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { type DeleteCustomerByIdParams, deleteCustomerById } from "../api"; @@ -14,6 +14,7 @@ import { CUSTOMER_DELETE_KEY } from "./keys"; interface DeleteCustomerContext extends DeleteCustomerCacheContext {} export const useCustomerDeleteMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); @@ -29,7 +30,7 @@ export const useCustomerDeleteMutation = () => { return { id }; }, onMutate: async ({ id }) => { - return prepareDeleteCustomerOptimisticUpdate(queryClient, id); + return prepareDeleteCustomerOptimisticUpdate(queryScope, queryClient, id); }, onError: (_error, _variables, context) => { @@ -37,7 +38,7 @@ export const useCustomerDeleteMutation = () => { }, onSettled: async () => { - await invalidateCustomerListQueries(queryClient); + await invalidateCustomerListQueries(queryScope, queryClient); }, } ); diff --git a/modules/customers/src/web/shared/hooks/use-customer-get-query.ts b/modules/customers/src/web/shared/hooks/use-customer-get-query.ts index f341d924..3e3cc19d 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-get-query.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-get-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetCustomerByIdAdapter } from "../adapters"; @@ -15,12 +16,13 @@ type CustomerGetQueryOptions = { export const useCustomerGetQuery = ( options?: CustomerGetQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const id = options?.id; const enabled = options?.enabled ?? Boolean(id); return useQuery({ - queryKey: CUSTOMER_QUERY_KEY(id), + queryKey: companyQueryKey(queryScope, CUSTOMER_QUERY_KEY(id)), queryFn: async ({ signal }) => { if (!id) { throw new Error("customerId is required"); diff --git a/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts index ac39b296..5ba29b41 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -18,6 +18,7 @@ import { toValidationErrors } from "./to-validation-errors"; type UpdateCustomerContext = {}; export const useCustomerUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = UpdateCustomerByIdRequestSchema; @@ -48,7 +49,7 @@ export const useCustomerUpdateMutation = () => { }, onSuccess: (updatedCustomer) => { - syncUpdatedCustomerCaches(queryClient, updatedCustomer); + return syncUpdatedCustomerCaches(queryScope, queryClient, updatedCustomer); }, }); }; diff --git a/modules/customers/src/web/shared/hooks/use-customers-list-query.ts b/modules/customers/src/web/shared/hooks/use-customers-list-query.ts index 167f54d4..4a143a5c 100644 --- a/modules/customers/src/web/shared/hooks/use-customers-list-query.ts +++ b/modules/customers/src/web/shared/hooks/use-customers-list-query.ts @@ -1,5 +1,6 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListCustomersAdapter } from "../adapters"; @@ -16,12 +17,13 @@ export interface CustomersListQueryOptions { export const useCustomersListQuery = ( options?: CustomersListQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_CUSTOMERS_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_CUSTOMERS_QUERY_KEY(criteria)), queryFn: async ({ signal }) => { const dto: ListCustomersResult = await getListCustomersByCriteria(dataSource, { criteria, @@ -30,6 +32,7 @@ export const useCustomersListQuery = ( return ListCustomersAdapter.fromDTO(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria }); }; diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series-save-scope.test.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series-save-scope.test.ts new file mode 100644 index 00000000..e79f3dd0 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series-save-scope.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { DocumentSeriesModel } from "../../../common"; + +import { SequelizeDocumentSeriesRepository } from "./document-series.repository"; + +for (const outcome of ["allowed", "foreign", "missing", "unchanged"] as const) { + const allowed = outcome === "allowed" || outcome === "unchanged"; + const sameCompany = outcome !== "foreign"; + test(`save no reasigna una raíz de otra empresa: resultado=${outcome}`, async (t) => { + const id = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID().toPrimitive(); + const payload = { id: id.toPrimitive(), company_id: companyId, name: "Updated" }; + const transaction = {}; + + let writes = 0; + t.mock.method( + DocumentSeriesModel, + "findOrCreate", + async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => { + assert.equal(options.transaction, transaction); + return [{ company_id: sameCompany ? companyId : "other-company" }, false]; + } + ); + t.mock.method( + DocumentSeriesModel, + "update", + async ( + changes: Record, + options: { where: { id: string; company_id: string }; transaction?: unknown } + ) => { + assert.equal(sameCompany, true); + assert.deepEqual(options.where, { id: payload.id, company_id: companyId }); + assert.equal(options.transaction, transaction); + assert.equal("company_id" in changes, false); + writes += 1; + return [outcome === "missing" || outcome === "unchanged" ? 0 : 1]; + } + ); + t.mock.method( + DocumentSeriesModel, + "findOne", + async (options: { where: unknown; transaction?: unknown }) => { + assert.deepEqual(options.where, { id: payload.id, company_id: companyId }); + assert.equal(options.transaction, transaction); + return outcome === "unchanged" ? { id: "existing" } : null; + } + ); + const repository = new SequelizeDocumentSeriesRepository( + { mapToPersistence: () => Result.ok(payload) } as never, + {} as never + ); + const result = await repository.save({ id } as never, transaction as never); + assert.equal(result.isSuccess, allowed); + assert.equal(writes, sameCompany ? 1 : 0); + }); +} diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts index 26a96e5e..f2e85a27 100644 --- a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts @@ -43,9 +43,31 @@ export class SequelizeDocumentSeriesRepository return Result.fail(payloadResult.error); } - await DocumentSeriesModel.upsert(payloadResult.data, { + const payload = payloadResult.data; + const [current] = await DocumentSeriesModel.findOrCreate({ + where: { id: payload.id }, + defaults: payload, transaction: transaction as Transaction | undefined, }); + if (current.company_id !== payload.company_id) { + return Result.fail(new DocumentSeriesNotFoundError()); + } + const { id, company_id, ...changes } = payload; + const [affected] = await DocumentSeriesModel.update(changes, { + where: { id, company_id }, + transaction: transaction as Transaction | undefined, + }); + if (affected === 0) { + const exists = await DocumentSeriesModel.findOne({ + attributes: ["id"], + lock: Boolean(transaction), + where: { id, company_id }, + transaction: transaction as Transaction | undefined, + }); + if (!exists) { + return Result.fail(new DocumentSeriesNotFoundError()); + } + } return Result.ok(); } catch (error: unknown) { @@ -246,6 +268,7 @@ export class SequelizeDocumentSeriesRepository { where: { id: persistedResult.data.id, + company_id: params.companyId.toPrimitive(), next_number: row.next_number, }, transaction, diff --git a/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts index ecbd7070..14a68fb1 100644 --- a/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts +++ b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { useQuery } from "@tanstack/react-query"; import type { ListDocumentSeriesRequestDTO } from "../../../../common"; @@ -8,10 +9,12 @@ import { listDocumentSeries } from "../api/document-series.api"; import { documentSeriesKeys } from "./keys"; export const useDocumentSeriesListQuery = (criteria: Partial) => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); return useQuery({ - queryKey: documentSeriesKeys.list(criteria), + queryKey: companyQueryKey(queryScope, documentSeriesKeys.list(criteria)), queryFn: async () => toDocumentSeriesList(await listDocumentSeries(dataSource, criteria)), - placeholderData: (previous) => previous, + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), }); }; diff --git a/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts index 48a097b3..8c486795 100644 --- a/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts +++ b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts @@ -1,4 +1,6 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import type { CompanyQueryScope } from "@erp/core/query"; +import { companyQueryKey } from "@erp/core/query"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { @@ -16,55 +18,64 @@ import { import { documentSeriesKeys } from "./keys"; -const invalidateLists = (queryClient: ReturnType) => - queryClient.invalidateQueries({ queryKey: documentSeriesKeys.all }); +const invalidateLists = ( + queryScope: CompanyQueryScope | null, + queryClient: ReturnType +) => + queryClient.invalidateQueries({ queryKey: companyQueryKey(queryScope, documentSeriesKeys.all) }); export const useCreateDocumentSeriesMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: CreateDocumentSeriesRequestDTO) => createDocumentSeries(dataSource, data), - onSuccess: () => invalidateLists(queryClient), + onSuccess: () => invalidateLists(queryScope, queryClient), }); }; export const useUpdateDocumentSeriesMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, data }: { id: string; data: UpdateDocumentSeriesRequestDTO }) => updateDocumentSeries(dataSource, id, data), - onSuccess: () => invalidateLists(queryClient), + onSuccess: () => invalidateLists(queryScope, queryClient), }); }; export const useAdvanceDocumentSeriesNumberMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, nextNumber }: { id: string; nextNumber: number }) => advanceDocumentSeriesNumber(dataSource, id, nextNumber), - onSuccess: () => invalidateLists(queryClient), + onSuccess: () => invalidateLists(queryScope, queryClient), }); }; export const useSetDefaultDocumentSeriesMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id }: { id: string }) => setDefaultDocumentSeries(dataSource, id), - onSuccess: () => invalidateLists(queryClient), + onSuccess: () => invalidateLists(queryScope, queryClient), }); }; export const useEnableDocumentSeriesMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id }: { id: string }) => enableDocumentSeries(dataSource, id), - onSuccess: () => invalidateLists(queryClient), + onSuccess: () => invalidateLists(queryScope, queryClient), }); }; export const useDisableDocumentSeriesMutation = () => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id }: { id: string }) => disableDocumentSeries(dataSource, id), - onSuccess: () => invalidateLists(queryClient), + onSuccess: () => invalidateLists(queryScope, queryClient), }); }; diff --git a/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts b/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts index 1a316940..1cdf6843 100644 --- a/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts +++ b/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts @@ -68,7 +68,10 @@ mock.module("@erp/core/client", { namedExports: { getUserErrorMessage: () => ({ title: "error", description: "error" }) }, }); mock.module("@erp/core/hooks", { - namedExports: { useHookForm: () => (formInvocation++ === 0 ? form : advanceForm) }, + namedExports: { + useCompanyQueryScope: () => null, + useHookForm: () => (formInvocation++ === 0 ? form : advanceForm), + }, }); mock.module("@repo/rdx-ui/helpers", { namedExports: { diff --git a/modules/identity/sql/001-system-owner.sql b/modules/identity/sql/001-system-owner.sql new file mode 100644 index 00000000..fda9cf85 --- /dev/null +++ b/modules/identity/sql/001-system-owner.sql @@ -0,0 +1,6 @@ +-- Aplicar expresamente en el entorno de destino antes de arrancar con DB_SYNC_MODE=none. +-- No identifica ni promueve cuentas existentes. El bootstrap vacío reserva el singleton. +CREATE TABLE IF NOT EXISTS identity_system_owner ( + id INT NOT NULL PRIMARY KEY, + account_id CHAR(36) NULL +) ENGINE=InnoDB; diff --git a/modules/identity/src/api/application/accounts/use-cases/bootstrap-system-account.ts b/modules/identity/src/api/application/accounts/use-cases/bootstrap-system-account.ts new file mode 100644 index 00000000..1efb4f2e --- /dev/null +++ b/modules/identity/src/api/application/accounts/use-cases/bootstrap-system-account.ts @@ -0,0 +1,42 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IAccountCreateProps } from "../../../domain"; +import { Account } from "../../../domain"; +import type { IAccountRepository } from "../contracts"; + +export interface IEmptySystemReservation { + reserveEmptySystem(accountId: UniqueID, transaction: unknown): Promise>; +} +/** Entrada administrativa de bootstrap, sin endpoint público ni promoción de cuentas existentes. */ +export class BootstrapSystemAccount { + constructor( + private readonly deps: { + accounts: IAccountRepository; + reservation: IEmptySystemReservation; + transactions: ITransactionManager; + } + ) {} + async create(props: IAccountCreateProps): Promise> { + const account = Account.create(props, UniqueID.generateNewID()); + if (account.isFailure) { + return Result.fail(account.error); + } + try { + return await this.deps.transactions.complete(async (transaction) => { + const reserved = await this.deps.reservation.reserveEmptySystem( + account.data.id, + transaction + ); + if (reserved.isFailure) { + return Result.fail(reserved.error); + } + const saved = await this.deps.accounts.save(account.data, transaction); + return saved.isFailure ? Result.fail(saved.error) : account; + }); + } catch (error) { + return Result.fail(error instanceof Error ? error : new Error("System bootstrap failed.")); + } + } +} diff --git a/modules/identity/src/api/application/accounts/use-cases/index.ts b/modules/identity/src/api/application/accounts/use-cases/index.ts index d63afdf7..3bdf9981 100644 --- a/modules/identity/src/api/application/accounts/use-cases/index.ts +++ b/modules/identity/src/api/application/accounts/use-cases/index.ts @@ -1,3 +1,4 @@ +export * from "./bootstrap-system-account"; export * from "./change-current-account-password.use-case"; export * from "./get-current-account.use-case"; export * from "./update-current-account.use-case"; diff --git a/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts b/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts index 85af2be9..5f0f4b01 100644 --- a/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts +++ b/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts @@ -2,6 +2,7 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Collection } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils"; +import type { ISystemAccess } from "../../permissions/services/system-access"; import type { ICompanyMembershipAccessRepository } from "../contracts"; export interface IIdentityCompanyAccessChecker { @@ -17,13 +18,22 @@ export interface IIdentityCompanyAccessChecker { } export class IdentityCompanyAccessChecker implements IIdentityCompanyAccessChecker { - public constructor(private readonly repository: ICompanyMembershipAccessRepository) {} + public constructor( + private readonly repository: ICompanyMembershipAccessRepository, + private readonly systemAccess?: ISystemAccess + ) {} public async canAccessCompany(params: { accountId: UniqueID; companyId: UniqueID; transaction?: unknown; }): Promise> { + if (this.systemAccess) { + const global = await this.systemAccess.isSuperAdmin(params.accountId, params.transaction); + if (global.isFailure || global.data) { + return global; + } + } const result = await this.repository.existsActiveByAccountAndCompany(params); if (result.isFailure) { diff --git a/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts b/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts index 6306658a..8bdc5870 100644 --- a/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts +++ b/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts @@ -5,7 +5,7 @@ import { DuplicateEntityError } from "@erp/core/api"; import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Collection, Maybe, Result } from "@repo/rdx-utils"; -import { IdentityPermissionCodes } from "../../../../common/permissions"; +import { identityPermissionDefinitions } from "../../../../common/permissions"; import { CompanyMembership, CompanyMembershipStatus, Role, RoleCode } from "../../../domain"; import { EffectivePermissionsResolver } from "../../permissions"; import type { IRoleRepository } from "../../roles/contracts"; @@ -27,7 +27,11 @@ function role(id = adminId) { code: RoleCode.create(id.equals(adminId) ? "admin" : "other").data, name: Name.create("Administrator").data, isSystem: id.equals(adminId), - permissionCodes: id.equals(adminId) ? Object.values(IdentityPermissionCodes) : ["extra:read"], + permissionCodes: id.equals(adminId) + ? identityPermissionDefinitions + .filter((item) => item.scope === "COMPANY") + .map((item) => item.code) + : ["extra:read"], createdAt: UtcDate.today(), updatedAt: UtcDate.today(), }, @@ -138,7 +142,7 @@ function setup( return Result.ok(new Collection(values)); }, }; - const ensurer = new CompanyAdminRoleEnsurer(roles); + const ensurer = new CompanyAdminRoleEnsurer(roles, identityPermissionDefinitions); const resolver = new EffectivePermissionsResolver({ memberships, roles }); const service = new InitialCompanyAdminAssigner({ memberships, diff --git a/modules/identity/src/api/application/permissions/index.ts b/modules/identity/src/api/application/permissions/index.ts index b5e6feee..6d4e96da 100644 --- a/modules/identity/src/api/application/permissions/index.ts +++ b/modules/identity/src/api/application/permissions/index.ts @@ -1,2 +1,4 @@ export * from "./contracts"; +export * from "./services/company-authorization"; export * from "./services/effective-permissions-resolver"; +export * from "./services/registered-permission-catalog"; diff --git a/modules/identity/src/api/application/permissions/services/company-authorization.test.ts b/modules/identity/src/api/application/permissions/services/company-authorization.test.ts new file mode 100644 index 00000000..3edf4658 --- /dev/null +++ b/modules/identity/src/api/application/permissions/services/company-authorization.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Result } from "@repo/rdx-utils"; + +import { CompanyMembershipCannotAccessCompanyError } from "../../../domain"; + +import { CompanyAuthorization } from "./company-authorization"; +import type { IEffectivePermissionsResolver } from "./effective-permissions-resolver"; +import { RegisteredPermissionCatalog } from "./registered-permission-catalog"; + +const accountId = UniqueID.generateNewID(); +const companyId = UniqueID.generateNewID(); +const code = "companies:companies:read"; +const catalog = new RegisteredPermissionCatalog([ + { code, module: "companies", scope: "COMPANY" as const, description: "Read" }, +]); +test("el catálogo expone solo declaraciones y no concede permisos", async () => { + const subject = new CompanyAuthorization(catalog, { + resolve: async () => Result.ok(new Collection([])), + }); + assert.equal((await subject.can({ accountId, companyId, permissionCode: code })).data, false); + assert.equal(catalog.findByCode("unknown").data.isNone(), true); + assert.equal(catalog.findAll().data.getAll().length, 1); +}); +test("consulta el resolver con cuenta, empresa y transacción; permite solo código registrado/asignado", async () => { + const transaction = {}; + const calls: Parameters[0][] = []; + const subject = new CompanyAuthorization(catalog, { + resolve: async (params) => { + calls.push(params); + return Result.ok(new Collection([code, "unknown"])); + }, + }); + assert.equal( + (await subject.can({ accountId, companyId, permissionCode: code, transaction })).data, + true + ); + assert.equal(calls[0].accountId, accountId); + assert.equal(calls[0].companyId, companyId); + assert.equal(calls[0].transaction, transaction); + assert.equal( + (await subject.can({ accountId, companyId, permissionCode: "unknown" })).data, + false + ); + assert.equal(calls.length, 1); +}); +test("sin pertenencia rechaza y los errores técnicos permanecen fallos", async () => { + const denied = new CompanyAuthorization(catalog, { + resolve: async () => Result.fail(new CompanyMembershipCannotAccessCompanyError("Denied")), + }); + assert.equal((await denied.can({ accountId, companyId, permissionCode: code })).data, false); + const failed = new CompanyAuthorization(catalog, { + resolve: async () => Result.fail(new Error("Storage")), + }); + assert.equal((await failed.can({ accountId, companyId, permissionCode: code })).isFailure, true); +}); + +test("superadministración usa scopes explícitos y se amplía al registrar permisos", async () => { + const subject = new CompanyAuthorization( + new RegisteredPermissionCatalog([ + { code, module: "companies", description: "Read", scope: "COMPANY" }, + { code: "future:system:configure", module: "future", description: "Global", scope: "SYSTEM" }, + ]), + { resolve: async () => assert.fail("Superadmin no depende de roles empresariales") }, + { + isSuperAdmin: async (id) => Result.ok(id.equals(accountId)), + } + ); + assert.equal((await subject.can({ accountId, companyId, permissionCode: code })).data, true); + assert.equal( + (await subject.canSystem({ accountId, permissionCode: "future:system:configure" })).data, + true + ); + assert.equal((await subject.canSystem({ accountId, permissionCode: code })).data, false); + assert.equal( + (await subject.can({ accountId, companyId, permissionCode: "future:system:configure" })).data, + false + ); + assert.equal( + ( + await subject.canSystem({ + accountId: UniqueID.generateNewID(), + permissionCode: "future:system:configure", + }) + ).data, + false + ); + assert.equal((await subject.canSystem({ accountId, permissionCode: "unknown" })).data, false); +}); diff --git a/modules/identity/src/api/application/permissions/services/company-authorization.ts b/modules/identity/src/api/application/permissions/services/company-authorization.ts new file mode 100644 index 00000000..bc9e7ed7 --- /dev/null +++ b/modules/identity/src/api/application/permissions/services/company-authorization.ts @@ -0,0 +1,71 @@ +import type { PermissionCode } from "@erp/core"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { CompanyMembershipCannotAccessCompanyError } from "../../../domain"; +import type { IPermissionCatalog } from "../contracts"; + +import type { IEffectivePermissionsResolver } from "./effective-permissions-resolver"; +import type { ISystemAccess } from "./system-access"; + +export interface ICompanyAuthorization { + canSystem(params: { + accountId: UniqueID; + permissionCode: PermissionCode; + transaction?: unknown; + }): Promise>; + /** Evalúa un permiso COMPANY después de validar el tenant. Superadmin conserva el contexto empresarial. */ + can(params: { + accountId: UniqueID; + companyId: UniqueID; + permissionCode: PermissionCode; + transaction?: unknown; + }): Promise>; +} + +export class CompanyAuthorization implements ICompanyAuthorization { + constructor( + private readonly catalog: IPermissionCatalog, + private readonly permissions: IEffectivePermissionsResolver, + private readonly systemAccess?: ISystemAccess + ) {} + + async can(params: Parameters[0]): Promise> { + const known = this.catalog.findByCode(params.permissionCode); + if (known.isFailure) { + return Result.fail(known.error); + } + if (known.data.isNone() || known.data.unwrap().scope !== "COMPANY" || !params.companyId) { + return Result.ok(false); + } + // El contrato empresarial se usa después de validar tenant activo en el guard. + if (this.systemAccess) { + const global = await this.systemAccess.isSuperAdmin(params.accountId, params.transaction); + if (global.isFailure || global.data) { + return global; + } + } + const resolved = await this.permissions.resolve(params); + if (resolved.isFailure) { + if (resolved.error instanceof CompanyMembershipCannotAccessCompanyError) { + return Result.ok(false); + } + return Result.fail(resolved.error); + } + return Result.ok(resolved.data.getAll().includes(params.permissionCode)); + } + async canSystem(params: { + accountId: UniqueID; + permissionCode: PermissionCode; + transaction?: unknown; + }): Promise> { + const known = this.catalog.findByCode(params.permissionCode); + if (known.isFailure) { + return Result.fail(known.error); + } + if (known.data.isNone() || known.data.unwrap().scope !== "SYSTEM" || !this.systemAccess) { + return Result.ok(false); + } + return this.systemAccess.isSuperAdmin(params.accountId, params.transaction); + } +} diff --git a/modules/identity/src/api/application/permissions/services/registered-permission-catalog.ts b/modules/identity/src/api/application/permissions/services/registered-permission-catalog.ts new file mode 100644 index 00000000..a3cec0d6 --- /dev/null +++ b/modules/identity/src/api/application/permissions/services/registered-permission-catalog.ts @@ -0,0 +1,27 @@ +import type { PermissionCode, PermissionDefinition } from "@erp/core"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import type { IPermissionCatalog } from "../contracts"; + +export class RegisteredPermissionCatalog implements IPermissionCatalog { + private readonly definitions: ReadonlyMap; + + constructor(definitions: readonly PermissionDefinition[]) { + this.definitions = new Map( + definitions.map((definition) => [definition.code, { ...definition }]) + ); + } + + findAll(): Result, Error> { + return Result.ok(new Collection([...this.definitions.values()].map((item) => ({ ...item })))); + } + + exists(code: PermissionCode): Result { + return Result.ok(this.definitions.has(code)); + } + + findByCode(code: PermissionCode): Result, Error> { + const definition = this.definitions.get(code); + return Result.ok(definition ? Maybe.some({ ...definition }) : Maybe.none()); + } +} diff --git a/modules/identity/src/api/application/permissions/services/system-access.ts b/modules/identity/src/api/application/permissions/services/system-access.ts new file mode 100644 index 00000000..741f107f --- /dev/null +++ b/modules/identity/src/api/application/permissions/services/system-access.ts @@ -0,0 +1,6 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +export interface ISystemAccess { + isSuperAdmin(accountId: UniqueID, transaction?: unknown): Promise>; +} diff --git a/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts index a591d572..909e820b 100644 --- a/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts +++ b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { PermissionDefinition } from "@erp/core"; import { DuplicateEntityError } from "@erp/core/api"; import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Collection, Maybe, Result } from "@repo/rdx-utils"; -import { IdentityPermissionCodes } from "../../../../common/permissions"; +import { identityPermissionDefinitions } from "../../../../common/permissions"; import { Role, RoleCode } from "../../../domain"; import type { IRoleRepository } from "../contracts"; @@ -14,7 +15,9 @@ import { CompanyAdminRoleEnsurer } from "./company-admin-role-ensurer"; const companyId = UniqueID.generateNewID(); function admin( isSystem = true, - permissionCodes: string[] = Object.values(IdentityPermissionCodes) + permissionCodes: string[] = identityPermissionDefinitions + .filter((item) => item.scope === "COMPANY") + .map((item) => item.code) ) { return Role.create( { @@ -31,7 +34,8 @@ function admin( } function setup( reads: Result, Error>[] = [Result.ok(Maybe.none())], - saveResult: Result = Result.ok() + saveResult: Result = Result.ok(), + definitions: readonly PermissionDefinition[] = identityPermissionDefinitions ) { const lookups: Parameters[0][] = []; const writes: { role: Role; transaction?: unknown }[] = []; @@ -46,8 +50,9 @@ function setup( writes.push({ role, transaction }); return saveResult; }, - update: async () => { - assert.fail("No debe modificar roles"); + update: async (role, transaction) => { + writes.push({ role, transaction }); + return Result.ok(); }, delete: async () => { assert.fail("No debe borrar roles"); @@ -56,7 +61,7 @@ function setup( findByIdsInCompany: async () => Result.ok(new Collection([])), findByCompanyId: async () => Result.ok(new Collection([])), }; - return { service: new CompanyAdminRoleEnsurer(repository), writes, lookups }; + return { service: new CompanyAdminRoleEnsurer(repository, definitions), writes, lookups }; } test("crea admin de sistema con SSOT exacta, ID y fechas internos", async () => { const subject = setup(); @@ -64,9 +69,14 @@ test("crea admin de sistema con SSOT exacta, ID y fechas internos", async () => assert.equal(result.isSuccess, true); const role = result.data; assert.equal(role.code.toPrimitive(), "admin"); - assert.equal(role.name.toPrimitive(), "Administrator"); + assert.equal(role.name.toPrimitive(), "Administradores"); assert.equal(role.isSystem, true); - assert.deepEqual(role.permissionCodes, Object.values(IdentityPermissionCodes)); + assert.deepEqual( + role.permissionCodes, + identityPermissionDefinitions + .filter((item) => item.scope === "COMPANY") + .map((item) => item.code) + ); assert.equal(UniqueID.create(role.id.toPrimitive()).isSuccess, true); assert.equal(role.id.equals(companyId), false); assert.equal(role.createdAt.toPrimitive(), UtcDate.today().toPrimitive()); @@ -74,16 +84,19 @@ test("crea admin de sistema con SSOT exacta, ID y fechas internos", async () => assert.equal(subject.writes[0].role, role); }); test("existente válido es idempotente, independientemente del orden de permisos", async () => { - const existing = admin(true, Object.values(IdentityPermissionCodes).reverse()); + const existing = admin( + true, + identityPermissionDefinitions + .filter((item) => item.scope === "COMPANY") + .map((item) => item.code) + .reverse() + ); const subject = setup([Result.ok(Maybe.some(existing)), Result.ok(Maybe.some(existing))]); assert.equal((await subject.service.ensure({ companyId })).data, existing); assert.equal((await subject.service.ensure({ companyId })).data, existing); assert.equal(subject.writes.length, 0); }); -for (const [label, existing] of [ - ["no-system", admin(false)], - ["permisos incorrectos", admin(true, ["identity:roles:read"])], -] as const) { +for (const [label, existing] of [["no-system", admin(false)]] as const) { test(`existente ${label} falla sin mutar`, async () => { const subject = setup([Result.ok(Maybe.some(existing))]); assert.equal((await subject.service.ensure({ companyId })).isFailure, true); @@ -131,3 +144,50 @@ for (const phase of ["lookup", "save", "reread"]) { assert.equal((await subject.service.ensure({ companyId })).error, error); }); } + +test("conserva permisos ajenos existentes sin añadir los recién registrados", async () => { + const codes = [ + ...identityPermissionDefinitions + .filter((item) => item.scope === "COMPANY") + .map((item) => item.code), + "companies:companies:read", + ]; + const existing = admin(true, codes); + const subject = setup([Result.ok(Maybe.some(existing))]); + const result = await subject.service.ensure({ companyId }); + assert.equal(result.isSuccess, true); + assert.deepEqual(result.data.permissionCodes, codes); + assert.equal(subject.writes.length, 0); +}); + +test("existente incompleto incorpora COMPANY, conserva extras y no duplica al repetir", async () => { + const existing = admin(true, ["identity:roles:read", "custom:reports:read"]); + const subject = setup([Result.ok(Maybe.some(existing)), Result.ok(Maybe.some(existing))]); + const transaction = {}; + assert.equal((await subject.service.ensure({ companyId, transaction })).isSuccess, true); + assert.equal((await subject.service.ensure({ companyId, transaction })).isSuccess, true); + assert.equal(subject.writes.length, 1); + assert.equal(subject.writes[0].transaction, transaction); + assert.equal(existing.permissionCodes.includes("custom:reports:read"), true); + assert.equal(existing.permissionCodes.includes("identity:system:initial-onboarding"), false); + assert.equal(new Set(existing.permissionCodes).size, existing.permissionCodes.length); +}); + +for (const existing of [true, false]) { + test(`catálogo ampliado: admin existente=${existing} recibe COMPANY y excluye SYSTEM`, async () => { + const definitions: PermissionDefinition[] = [ + ...identityPermissionDefinitions, + { code: "future:stock:read", module: "future", scope: "COMPANY", description: "Stock" }, + { code: "future:system:configure", module: "future", scope: "SYSTEM", description: "System" }, + ]; + const subject = setup( + [Result.ok(existing ? Maybe.some(admin()) : Maybe.none())], + Result.ok(), + definitions + ); + const result = await subject.service.ensure({ companyId }); + assert.equal(result.isSuccess, true); + assert.equal(result.data.permissionCodes.includes("future:stock:read"), true); + assert.equal(result.data.permissionCodes.includes("future:system:configure"), false); + }); +} diff --git a/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts index 5e04de26..e14daac1 100644 --- a/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts +++ b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts @@ -1,8 +1,8 @@ +import type { PermissionDefinition } from "@erp/core"; import { isDuplicateEntityError } from "@erp/core/api"; import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { IdentityPermissionCodes } from "../../../../common/permissions"; import { Role, RoleCode } from "../../../domain"; import type { IRoleRepository } from "../contracts"; @@ -14,9 +14,12 @@ export interface EnsureCompanyAdminRoleParams { export interface ICompanyAdminRoleEnsurer { ensure(params: EnsureCompanyAdminRoleParams): Promise>; } -/** Asegura el rol administrativo de Identity sin asignarlo ni modificar roles existentes. */ +/** Sincroniza el rol empresarial reservado; nunca concede capacidades SYSTEM. */ export class CompanyAdminRoleEnsurer implements ICompanyAdminRoleEnsurer { - public constructor(private readonly roles: IRoleRepository) {} + public constructor( + private readonly roles: IRoleRepository, + private readonly definitions: readonly PermissionDefinition[] + ) {} public async ensure(params: EnsureCompanyAdminRoleParams): Promise> { const code = RoleCode.create(COMPANY_ADMIN_ROLE_CODE); @@ -24,14 +27,14 @@ export class CompanyAdminRoleEnsurer implements ICompanyAdminRoleEnsurer { return Result.fail(code.error); } const lookup = { ...params, code: code.data }; - const found = await this.roles.findByCodeInCompany(lookup); + const found = await this.roles.findByCodeInCompany({ ...lookup, currentRead: true }); if (found.isFailure) { return Result.fail(found.error); } if (found.data.isSome()) { - return this.validate(found.data.unwrap(), params.companyId); + return this.synchronize(found.data.unwrap(), params); } - const name = Name.create("Administrator"); + const name = Name.create("Administradores"); if (name.isFailure) { return Result.fail(name.error); } @@ -41,7 +44,7 @@ export class CompanyAdminRoleEnsurer implements ICompanyAdminRoleEnsurer { companyId: params.companyId, code: code.data, name: name.data, - permissionCodes: Object.values(IdentityPermissionCodes), + permissionCodes: this.companyCodes(), isSystem: true, createdAt: now, updatedAt: now, @@ -65,24 +68,36 @@ export class CompanyAdminRoleEnsurer implements ICompanyAdminRoleEnsurer { if (winner.data.isNone()) { return Result.fail(saved.error); } - return this.validate(winner.data.unwrap(), params.companyId); + return this.synchronize(winner.data.unwrap(), params); } - private validate(role: Role, companyId: UniqueID): Result { - const expected = new Set(Object.values(IdentityPermissionCodes)); - const actual = role.permissionCodes; + private companyCodes(): string[] { + return this.definitions.filter((item) => item.scope === "COMPANY").map((item) => item.code); + } + + private async synchronize( + role: Role, + params: EnsureCompanyAdminRoleParams + ): Promise> { if ( - !role.companyId.equals(companyId) || + !role.companyId.equals(params.companyId) || role.code.toPrimitive() !== COMPANY_ADMIN_ROLE_CODE || - !role.isSystem || - actual.length !== expected.size || - new Set(actual).size !== expected.size || - actual.some((code) => !expected.has(code)) + !role.isSystem ) { return Result.fail( new Error("Existing company admin role does not match the system definition.") ); } + const changed = role.addSystemPermissions(this.companyCodes(), UtcDate.today()); + if (changed.isFailure) { + return Result.fail(changed.error); + } + if (changed.data) { + const saved = await this.roles.update(role, params.transaction); + if (saved.isFailure) { + return Result.fail(saved.error); + } + } return Result.ok(role); } } diff --git a/modules/identity/src/api/application/roles/services/role-permission-validator.ts b/modules/identity/src/api/application/roles/services/role-permission-validator.ts index a1107c4e..8006cffc 100644 --- a/modules/identity/src/api/application/roles/services/role-permission-validator.ts +++ b/modules/identity/src/api/application/roles/services/role-permission-validator.ts @@ -11,13 +11,13 @@ export class RolePermissionValidator implements IRolePermissionValidator { public validate(permissionCodes: PermissionCode[]): Result { for (const permissionCode of permissionCodes) { - const existsResult = this.permissionCatalog.exists(permissionCode); + const existsResult = this.permissionCatalog.findByCode(permissionCode); if (existsResult.isFailure) { return Result.fail(existsResult.error); } - if (!existsResult.data) { + if (existsResult.data.isNone() || existsResult.data.unwrap().scope !== "COMPANY") { return Result.fail(new UnknownPermissionCodeError(permissionCode)); } } diff --git a/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts b/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts index a824346a..0c32da6e 100644 --- a/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts +++ b/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts @@ -1,5 +1,4 @@ -import type { IOperationContextStore } from "@erp/core/api"; -import type { Result } from "@repo/rdx-utils"; +import type { IOperationContextStore, ModuleParams } from "@erp/core/api"; import type { RequestHandler } from "express"; import type { IIdentityCompanyAccessChecker } from "../company-memberships"; @@ -8,12 +7,7 @@ export type AuthenticatedOnlyMiddlewares = readonly [RequestHandler, RequestHand export type TenantRequiredMiddlewares = readonly [RequestHandler, RequestHandler, RequestHandler]; -export interface IIdentityTenantCompanyFinder { - existsActiveById(params: { - companyId: import("@repo/rdx-ddd").UniqueID; - transaction?: unknown; - }): Promise>; -} +export type IIdentityTenantCompanyFinder = NonNullable; export interface IIdentityAuthPublicServices { authenticatedOnly(params: { diff --git a/modules/identity/src/api/application/services/identity-public-services.interface.ts b/modules/identity/src/api/application/services/identity-public-services.interface.ts index ad2c83dd..0f6a1c40 100644 --- a/modules/identity/src/api/application/services/identity-public-services.interface.ts +++ b/modules/identity/src/api/application/services/identity-public-services.interface.ts @@ -1,12 +1,16 @@ +import type { BootstrapSystemAccount } from "../accounts/use-cases/bootstrap-system-account"; import type { IIdentityCompanyAccessChecker, IInitialCompanyAdminAssigner, } from "../company-memberships"; +import type { ICompanyAuthorization } from "../permissions"; import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface"; export interface IIdentityPublicServices { auth: IIdentityAuthPublicServices; + authorization: ICompanyAuthorization; + systemBootstrap: BootstrapSystemAccount; companyAccess: IIdentityCompanyAccessChecker; initialCompanyAdminAssigner: IInitialCompanyAdminAssigner; } diff --git a/modules/identity/src/api/domain/roles/role.aggregate.ts b/modules/identity/src/api/domain/roles/role.aggregate.ts index 7a09af6b..daf1d718 100644 --- a/modules/identity/src/api/domain/roles/role.aggregate.ts +++ b/modules/identity/src/api/domain/roles/role.aggregate.ts @@ -181,6 +181,24 @@ export class Role extends AggregateRoot { return Result.ok(true); } + /** Solo el mantenimiento explícito de roles reservados puede añadir permisos sin reemplazarlos. */ + public addSystemPermissions(codes: PermissionCode[], updatedAt: UtcDate): Result { + if (!this.isSystem) { + return Result.fail(new RoleCannotBeUpdatedError("Expected system role.")); + } + const next = [...new Set([...this.props.permissionCodes, ...codes])]; + const valid = Role.validatePermissionCodes(next); + if (valid.isFailure) { + return Result.fail(valid.error); + } + if (next.length === this.props.permissionCodes.length) { + return Result.ok(false); + } + this.props.permissionCodes = next; + this.props.updatedAt = updatedAt; + return Result.ok(true); + } + public assertCanBeUpdated(): Result { if (this.isSystem) { return Result.fail(new RoleCannotBeUpdatedError("System roles cannot be updated.")); diff --git a/modules/identity/src/api/index.ts b/modules/identity/src/api/index.ts index 973bbb03..d4fc4c14 100644 --- a/modules/identity/src/api/index.ts +++ b/modules/identity/src/api/index.ts @@ -1,6 +1,9 @@ import type { IModuleServer } from "@erp/core/api"; +import { identityPermissionDefinitions } from "../common/permissions"; + import type { IIdentityPublicServices } from "./application"; +import { synchronizeCompanyAdminRoles } from "./infrastructure/di/synchronize-company-admin-roles"; export * from "./application"; export * from "./domain"; @@ -18,6 +21,9 @@ export const identityAPIModule: IModuleServer = { name: "identity", version: "1.0.0", dependencies: [], + permissions: identityPermissionDefinitions, + warmup: synchronizeCompanyAdminRoles, + warmupRequired: true, async setup(params) { const internal = buildIdentityDependencies(params); const general: IIdentityPublicServices = { diff --git a/modules/identity/src/api/infrastructure/di/identity-public-services.ts b/modules/identity/src/api/infrastructure/di/identity-public-services.ts index 586aa42f..fb63d79c 100644 --- a/modules/identity/src/api/infrastructure/di/identity-public-services.ts +++ b/modules/identity/src/api/infrastructure/di/identity-public-services.ts @@ -2,10 +2,14 @@ import type { IIdentityCompanyAccessChecker, IInitialCompanyAdminAssigner, } from "../../application"; +import type { BootstrapSystemAccount } from "../../application/accounts/use-cases/bootstrap-system-account"; +import type { ICompanyAuthorization } from "../../application/permissions"; import type { IdentityInternalDeps } from "./identity.di"; export interface IIdentityNeutralPublicServices { + authorization: ICompanyAuthorization; + systemBootstrap: BootstrapSystemAccount; companyAccess: IIdentityCompanyAccessChecker; initialCompanyAdminAssigner: IInitialCompanyAdminAssigner; } @@ -14,6 +18,8 @@ export function buildIdentityNeutralPublicServices( deps: IdentityInternalDeps ): IIdentityNeutralPublicServices { return { + authorization: deps.authorization, + systemBootstrap: deps.systemBootstrap, companyAccess: deps.companyAccess.checker, initialCompanyAdminAssigner: deps.initialCompanyAdminAssigner, }; diff --git a/modules/identity/src/api/infrastructure/di/identity.di.ts b/modules/identity/src/api/infrastructure/di/identity.di.ts index 5723cf46..8dd196bb 100644 --- a/modules/identity/src/api/infrastructure/di/identity.di.ts +++ b/modules/identity/src/api/infrastructure/di/identity.di.ts @@ -2,10 +2,13 @@ import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; import { CompanyAdminRoleEnsurer, + CompanyAuthorization, EffectivePermissionsResolver, + type ICompanyAuthorization, type IIdentityCompanyAccessChecker, IdentityCompanyAccessChecker, InitialCompanyAdminAssigner, + RegisteredPermissionCatalog, } from "../../application"; import type { IAccountRepository } from "../../application/accounts"; import { @@ -16,6 +19,7 @@ import { type UpdateCurrentAccountUseCase, UpdateCurrentAccountUseCase as UpdateCurrentAccountUseCaseImpl, } from "../../application/accounts"; +import { BootstrapSystemAccount } from "../../application/accounts/use-cases/bootstrap-system-account"; import { AccountAuthenticator, type GetCurrentSessionUseCase, @@ -39,6 +43,7 @@ import { SequelizeRefreshTokenRepository, SequelizeRoleRepository, } from "../persistence"; +import { SequelizeSystemAccess } from "../persistence/sequelize/repositories/sequelize-system-access"; import { BcryptPasswordHasher, CryptoRefreshTokenGenerator, @@ -48,6 +53,9 @@ import { } from "../security"; export type IdentityInternalDeps = { + authorization: ICompanyAuthorization; + systemBootstrap: BootstrapSystemAccount; + adminRoles: CompanyAdminRoleEnsurer; initialCompanyAdminAssigner: InitialCompanyAdminAssigner; auth: { refreshTokenExpiration: string; @@ -92,12 +100,26 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository( database ); - const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository); + const systemAccess = new SequelizeSystemAccess(); + const systemBootstrap = new BootstrapSystemAccount({ + accounts: accountRepository, + reservation: systemAccess, + transactions: transactionManager, + }); + const companyAccessChecker = new IdentityCompanyAccessChecker( + companyMembershipAccessRepository, + systemAccess + ); const memberships = new SequelizeCompanyMembershipRepository(); const roles = new SequelizeRoleRepository(); - const adminRoles = new CompanyAdminRoleEnsurer(roles); + const adminRoles = new CompanyAdminRoleEnsurer(roles, params.permissionDefinitions ?? []); const permissions = new EffectivePermissionsResolver({ memberships, roles }); + const authorization = new CompanyAuthorization( + new RegisteredPermissionCatalog(params.permissionDefinitions ?? []), + permissions, + systemAccess + ); const initialCompanyAdminAssigner = new InitialCompanyAdminAssigner({ memberships, adminRoles, @@ -127,6 +149,9 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna }); return { + authorization, + systemBootstrap, + adminRoles, initialCompanyAdminAssigner, auth: { refreshTokenExpiration: config.auth.jwt.refreshExpiration, diff --git a/modules/identity/src/api/infrastructure/di/synchronize-company-admin-roles.ts b/modules/identity/src/api/infrastructure/di/synchronize-company-admin-roles.ts new file mode 100644 index 00000000..6e56eace --- /dev/null +++ b/modules/identity/src/api/infrastructure/di/synchronize-company-admin-roles.ts @@ -0,0 +1,28 @@ +import type { WarmupParams } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import type { Transaction } from "sequelize"; + +import { RoleModel } from "../persistence/sequelize/models"; + +import type { IdentityInternalDeps } from "./identity.di"; + +/** Se ejecuta tras preparar el esquema y antes de servir HTTP; no escribe durante autorización. */ +export async function synchronizeCompanyAdminRoles(params: WarmupParams): Promise { + const deps = params.getInternal("identity"); + const rows = await RoleModel.findAll({ + where: { code: "admin", is_system: true }, + attributes: ["company_id"], + }); + for (const row of rows) { + const companyId = UniqueID.create(row.company_id); + if (companyId.isFailure) { + throw companyId.error; + } + await params.database.transaction(async (transaction: Transaction) => { + const result = await deps.adminRoles.ensure({ companyId: companyId.data, transaction }); + if (result.isFailure) { + throw result.error; + } + }); + } +} diff --git a/modules/identity/src/api/infrastructure/express/middlewares/index.ts b/modules/identity/src/api/infrastructure/express/middlewares/index.ts index c6793258..7a8afe1f 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/index.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/index.ts @@ -3,3 +3,4 @@ export * from "./identity-authenticate-request"; export * from "./require-authenticated.middleware"; export * from "./require-company-context.middleware"; export * from "./require-identity-middlewares"; +export * from "./require-permission.middleware"; diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts index cf9b0600..2429597f 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts @@ -63,7 +63,7 @@ export function requireCompanyContext(params: { }); if (canAccessResult.isFailure) { return ExpressController.errorResponse( - new InternalApiError(canAccessResult.error.message), + new InternalApiError("Company context unavailable"), req, res ); @@ -80,7 +80,7 @@ export function requireCompanyContext(params: { const companyExistsResult = await findActiveCompany(params.companyFinder, companyId); if (companyExistsResult.isFailure) { return ExpressController.errorResponse( - new InternalApiError(companyExistsResult.error.message), + new InternalApiError("Company context unavailable"), req, res ); diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts index 4ac384b0..010667d5 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts @@ -1,4 +1,6 @@ import type { StartParams } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; import type { RequestHandler } from "express"; import type { IIdentityPublicServices } from "../../../application"; @@ -8,9 +10,9 @@ import { requireCompanyContext } from "./require-company-context.middleware"; type CompaniesGeneralServices = { finder: { existsActiveById(params: { - companyId: import("@repo/rdx-ddd").UniqueID; + companyId: UniqueID; transaction?: unknown; - }): Promise>; + }): Promise>; }; }; @@ -34,11 +36,16 @@ export function requireIdentityTenant(params: StartParams): RequestHandler[] { } export function requireIdentityTenantMembership(params: StartParams): RequestHandler[] { + const companyFinder = params.tenantCompanyFinder; + if (!companyFinder) { + throw new Error("Host must provide the active tenant company finder before starting routes."); + } const identityServices = params.getService("identity:general"); return [ ...identityServices.auth.authenticatedOnly({ operationContext: params.operationContext }), requireCompanyContext({ + companyFinder, companyAccessChecker: identityServices.companyAccess, operationContext: params.operationContext, }), diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.test.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.test.ts new file mode 100644 index 00000000..c0d93a65 --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ApiError } from "@erp/core/api"; +import { ExpressController } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { requirePermission } from "./require-permission.middleware"; + +for (const allowed of [true, false]) { + test(`guard: membership autenticado con permiso=${allowed}`, async (t) => { + const accountId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + let status = 200; + let nextCalled = false; + t.mock.method(ExpressController, "errorResponse", (error: ApiError) => { + status = error.status; + }); + const guard = requirePermission( + { + canSystem: async () => Result.ok(false), + can: async (params) => { + assert.equal(params.accountId, accountId); + assert.equal(params.companyId, companyId); + assert.equal(params.permissionCode, "companies:companies:read"); + return Result.ok(allowed); + }, + }, + "companies:companies:read" + ); + await guard( + { user: { userId: accountId, companyId }, body: { companyId: "untrusted" } } as never, + {} as never, + () => { + nextCalled = true; + } + ); + assert.equal(status, allowed ? 200 : 403); + assert.equal(nextCalled, allowed); + }); +} + +test("guard rechaza falta de auth/tenant sin consultar autorización", async (t) => { + const statuses: number[] = []; + t.mock.method(ExpressController, "errorResponse", (error: ApiError) => { + statuses.push(error.status); + }); + const guard = requirePermission( + { + canSystem: async () => Result.ok(false), + can: async () => assert.fail("No debe consultar sin contexto"), + }, + "companies:companies:read" + ); + await guard({} as never, {} as never, () => assert.fail("No debe entrar")); + await guard({ user: { userId: UniqueID.generateNewID() } } as never, {} as never, () => + assert.fail("No debe entrar") + ); + assert.deepEqual(statuses, [401, 403]); +}); + +test("guard no publica detalles de un error técnico", async (t) => { + t.mock.method(ExpressController, "errorResponse", (error: ApiError) => { + assert.equal(error.status, 500); + assert.equal(error.detail, "Authorization unavailable"); + }); + const guard = requirePermission( + { + canSystem: async () => Result.ok(false), + can: async () => Result.fail(new Error("private storage detail")), + }, + "companies:companies:read" + ); + await guard( + { user: { userId: UniqueID.generateNewID(), companyId: UniqueID.generateNewID() } } as never, + {} as never, + () => assert.fail("No debe entrar") + ); +}); + +test("guard SYSTEM no exige tenant pero deniega a un administrador empresarial", async (t) => { + let nextCalled = false; + let status = 200; + t.mock.method(ExpressController, "errorResponse", (error: ApiError) => { + status = error.status; + }); + for (const allowed of [false, true]) { + nextCalled = false; + const guard = requirePermission( + { + can: async () => assert.fail("No resolver empresa"), + canSystem: async () => Result.ok(allowed), + }, + "companies:companies:create", + "SYSTEM" + ); + await guard({ user: { userId: UniqueID.generateNewID() } } as never, {} as never, () => { + nextCalled = true; + }); + assert.equal(nextCalled, allowed); + if (!allowed) { + assert.equal(status, 403); + } + } +}); diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.ts new file mode 100644 index 00000000..67c6899b --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-permission.middleware.ts @@ -0,0 +1,77 @@ +import type { PermissionCode } from "@erp/core"; +import type { RequestWithAuth } from "@erp/core/api"; +import { + ExpressController, + ForbiddenApiError, + InternalApiError, + UnauthorizedApiError, +} from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { RequestHandler } from "express"; + +import type { ICompanyAuthorization } from "../../../application/permissions"; + +/** COMPANY se instala después de auth y tenant; SYSTEM después de auth. Solo usa contexto autenticado. */ +export function requirePermission( + authorization: ICompanyAuthorization, + permissionCode: PermissionCode, + scope: "COMPANY" | "SYSTEM" = "COMPANY" +): RequestHandler { + return async (req, res, next) => { + const user = (req as RequestWithAuth).user; + if (!user?.userId) { + return ExpressController.errorResponse( + new UnauthorizedApiError("Authentication required"), + req, + res + ); + } + if (scope === "COMPANY" && !user.companyId) { + return ExpressController.errorResponse( + new ForbiddenApiError("Company context required"), + req, + res + ); + } + try { + const allowed = await checkPermission(authorization, permissionCode, scope, user); + if (allowed.isFailure) { + return ExpressController.errorResponse( + new InternalApiError("Authorization unavailable"), + req, + res + ); + } + if (!allowed.data) { + return ExpressController.errorResponse( + new ForbiddenApiError("Permission required"), + req, + res + ); + } + next(); + } catch { + return ExpressController.errorResponse( + new InternalApiError("Authorization unavailable"), + req, + res + ); + } + }; +} + +function checkPermission( + authorization: ICompanyAuthorization, + permissionCode: PermissionCode, + scope: "COMPANY" | "SYSTEM", + user: { userId: UniqueID; companyId?: UniqueID } +): Promise> { + if (scope === "SYSTEM") { + return authorization.canSystem({ accountId: user.userId, permissionCode }); + } + if (!user.companyId) { + return Promise.resolve(Result.ok(false)); + } + return authorization.can({ accountId: user.userId, companyId: user.companyId, permissionCode }); +} diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts index bd766899..70dba976 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts @@ -3,12 +3,14 @@ import companyMembershipModelInit from "./models/sequelize-company-membership.mo import membershipRoleModelInit from "./models/sequelize-company-membership-role.model"; import refreshTokenModelInit from "./models/sequelize-refresh-token.model"; import roleModelInit from "./models/sequelize-role.model"; +import systemOwnerModelInit from "./models/sequelize-system-owner.model"; export * from "./mappers"; export * from "./models"; export * from "./repositories"; export const models = [ + systemOwnerModelInit, accountModelInit, refreshTokenModelInit, companyMembershipModelInit, diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-system-owner.model.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-system-owner.model.ts new file mode 100644 index 00000000..a521baf9 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-system-owner.model.ts @@ -0,0 +1,26 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model } from "sequelize"; + +/** Singleton persistente; no concede privilegios por nombre, membership ni fecha de alta. */ +export class SystemOwnerModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: number; + declare account_id: string | null; +} +export default (database: Sequelize) => { + SystemOwnerModel.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true }, + account_id: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize: database, + modelName: "SystemOwnerModel", + tableName: "identity_system_owner", + timestamps: false, + } + ); + return SystemOwnerModel; +}; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-system-access.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-system-access.ts new file mode 100644 index 00000000..50b7ec35 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-system-access.ts @@ -0,0 +1,54 @@ +import { translateSequelizeError } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Transaction } from "sequelize"; + +import type { ISystemAccess } from "../../../../application/permissions/services/system-access"; +import { AccountModel } from "../models"; +import { SystemOwnerModel } from "../models/sequelize-system-owner.model"; + +export class SequelizeSystemAccess implements ISystemAccess { + async isSuperAdmin(accountId: UniqueID, transaction?: unknown): Promise> { + try { + const options = { transaction: transaction as Transaction | undefined }; + const owner = await SystemOwnerModel.findByPk(1, options); + if (owner?.account_id !== accountId.toPrimitive()) { + return Result.ok(false); + } + const account = await AccountModel.findOne({ + where: { id: owner.account_id, status: "active" }, + ...options, + }); + return Result.ok(Boolean(account)); + } catch (error) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** Requiere la transacción que guardará la cuenta; serializa bootstrap entre procesos. */ + async reserveEmptySystem( + accountId: UniqueID, + transaction: unknown + ): Promise> { + if (!transaction) { + return Result.fail(new Error("System bootstrap requires an external transaction.")); + } + try { + const options = { transaction: transaction as Transaction }; + await SystemOwnerModel.findOrCreate({ + where: { id: 1 }, + defaults: { id: 1, account_id: null }, + ...options, + }); + const owner = await SystemOwnerModel.findByPk(1, { ...options, lock: true }); + const existing = await AccountModel.findOne({ ...options, lock: true }); + if (!owner || owner.account_id || existing) { + return Result.fail(new Error("System bootstrap requires an empty account store.")); + } + await owner.update({ account_id: accountId.toPrimitive() }, options); + return Result.ok(); + } catch (error) { + return Result.fail(translateSequelizeError(error)); + } + } +} diff --git a/modules/identity/src/api/module.test.ts b/modules/identity/src/api/module.test.ts index a34f6552..38618044 100644 --- a/modules/identity/src/api/module.test.ts +++ b/modules/identity/src/api/module.test.ts @@ -50,7 +50,12 @@ test("Identity publica assigner sin asignar ni abrir transacciones al componer", const general = setup?.services?.general as IIdentityNeutralPublicServices; assert.equal(typeof general.companyAccess.canAccessCompany, "function"); assert.ok(general.initialCompanyAdminAssigner instanceof InitialCompanyAdminAssigner); - assert.deepEqual(Object.keys(general).sort(), ["companyAccess", "initialCompanyAdminAssigner"]); + assert.deepEqual(Object.keys(general).sort(), [ + "authorization", + "companyAccess", + "initialCompanyAdminAssigner", + "systemBootstrap", + ]); assert.equal(assign.mock.callCount(), 0); assert.equal(transactions, 0); }); diff --git a/modules/identity/src/api/module.ts b/modules/identity/src/api/module.ts index 3354c8b2..4e7dd22a 100644 --- a/modules/identity/src/api/module.ts +++ b/modules/identity/src/api/module.ts @@ -1,6 +1,9 @@ import type { IModuleServer } from "@erp/core/api"; +import { identityPermissionDefinitions } from "../common/permissions"; + import { buildIdentityDependencies, buildIdentityNeutralPublicServices } from "./infrastructure/di"; +import { synchronizeCompanyAdminRoles } from "./infrastructure/di/synchronize-company-admin-roles"; import { models } from "./infrastructure/persistence/sequelize"; export type { IIdentityCompanyAccessChecker } from "./application"; @@ -11,6 +14,9 @@ export const identityModule: IModuleServer = { name: "identity", version: "1.0.0", dependencies: [], + permissions: identityPermissionDefinitions, + warmup: synchronizeCompanyAdminRoles, + warmupRequired: true, async setup(params) { const internal = buildIdentityDependencies(params); diff --git a/modules/identity/src/common/dto/permissions/shared/permission-definition.dto.ts b/modules/identity/src/common/dto/permissions/shared/permission-definition.dto.ts index 7bf3ad0c..e843d2d7 100644 --- a/modules/identity/src/common/dto/permissions/shared/permission-definition.dto.ts +++ b/modules/identity/src/common/dto/permissions/shared/permission-definition.dto.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; export const PermissionDefinitionSchema = z.object({ code: z.string(), + scope: z.enum(["SYSTEM", "COMPANY"]), module: z.string(), description: z.string(), }); diff --git a/modules/identity/src/common/permissions/permission-codes.ts b/modules/identity/src/common/permissions/permission-codes.ts index 7712f036..5acac3a7 100644 --- a/modules/identity/src/common/permissions/permission-codes.ts +++ b/modules/identity/src/common/permissions/permission-codes.ts @@ -1,6 +1,7 @@ import type { PermissionDefinition } from "@erp/core"; export const IdentityPermissionCodes = { + InitialOnboarding: "identity:system:initial-onboarding", CompaniesUpdate: "identity:companies:update", RolesRead: "identity:roles:read", RolesCreate: "identity:roles:create", @@ -17,53 +18,69 @@ export type IdentityPermissionCode = (typeof IdentityPermissionCodes)[keyof typeof IdentityPermissionCodes]; export const identityPermissionDefinitions = [ + { + code: IdentityPermissionCodes.InitialOnboarding, + module: "identity", + scope: "SYSTEM", + description: "Complete system initial onboarding.", + }, { code: IdentityPermissionCodes.CompaniesUpdate, + scope: "COMPANY", module: "identity", description: "Update current company settings.", }, { code: IdentityPermissionCodes.RolesRead, + scope: "COMPANY", module: "identity", description: "Read company roles.", }, { code: IdentityPermissionCodes.RolesCreate, + scope: "COMPANY", module: "identity", description: "Create company roles.", }, { code: IdentityPermissionCodes.RolesUpdate, + scope: "COMPANY", module: "identity", description: "Update company roles.", }, { code: IdentityPermissionCodes.RolesDelete, + scope: "COMPANY", module: "identity", description: "Delete company roles.", }, { code: IdentityPermissionCodes.CompanyUsersRead, + scope: "COMPANY", module: "identity", description: "Read company users.", }, { code: IdentityPermissionCodes.CompanyUsersUpdateRoles, + scope: "COMPANY", module: "identity", description: "Replace company user roles.", }, { code: IdentityPermissionCodes.CompanyUsersDisable, + scope: "COMPANY", module: "identity", description: "Disable company users.", }, { code: IdentityPermissionCodes.CompanyUsersEnable, + scope: "COMPANY", module: "identity", description: "Enable company users.", }, { code: IdentityPermissionCodes.PermissionsRead, + scope: "COMPANY", module: "identity", description: "Read registered permission definitions.", }, diff --git a/modules/identity/src/web/account/shared/hooks/keys.ts b/modules/identity/src/web/account/shared/hooks/keys.ts index e0ca8a43..53a5ece6 100644 --- a/modules/identity/src/web/account/shared/hooks/keys.ts +++ b/modules/identity/src/web/account/shared/hooks/keys.ts @@ -1 +1,2 @@ -export const CURRENT_ACCOUNT_QUERY_KEY = () => ["identity", "account", "current"] as const; +export const CURRENT_ACCOUNT_QUERY_KEY = (sessionId: string | null) => + ["identity", "account", sessionId, "current"] as const; diff --git a/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts b/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts index 66d54391..6b12560a 100644 --- a/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts +++ b/modules/identity/src/web/account/shared/hooks/use-current-account-query.ts @@ -2,6 +2,7 @@ import { useDataSource } from "@erp/core/hooks"; import { GetAccountProfileResponseSchema } from "@erp/identity/common"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; +import { useIdentityAuthSession } from "../../../context/identity-auth-session.context"; import { AccountProfileAdapter } from "../adapters"; import { getCurrentAccount } from "../api"; import type { AccountProfile } from "../entities"; @@ -9,10 +10,11 @@ import type { AccountProfile } from "../entities"; import { CURRENT_ACCOUNT_QUERY_KEY } from "./keys"; export const useCurrentAccountQuery = (): UseQueryResult => { + const { querySessionId } = useIdentityAuthSession(); const dataSource = useDataSource(); return useQuery({ - queryKey: CURRENT_ACCOUNT_QUERY_KEY(), + queryKey: CURRENT_ACCOUNT_QUERY_KEY(querySessionId), queryFn: async () => { const response = await getCurrentAccount(dataSource); const parsed = GetAccountProfileResponseSchema.safeParse(response); diff --git a/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts b/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts index 743de6ae..1c49f54f 100644 --- a/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts +++ b/modules/identity/src/web/account/shared/hooks/use-update-current-account-mutation.ts @@ -6,6 +6,7 @@ import { } from "@erp/identity/common"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useIdentityAuthSession } from "../../../context/identity-auth-session.context"; import { AccountProfileAdapter } from "../adapters"; import { updateCurrentAccount } from "../api"; import type { AccountProfile } from "../entities"; @@ -13,6 +14,7 @@ import type { AccountProfile } from "../entities"; import { CURRENT_ACCOUNT_QUERY_KEY } from "./keys"; export const useUpdateCurrentAccountMutation = () => { + const { querySessionId } = useIdentityAuthSession(); const dataSource = useDataSource(); const queryClient = useQueryClient(); @@ -30,7 +32,7 @@ export const useUpdateCurrentAccountMutation = () => { return AccountProfileAdapter.fromDto(parsed.data); }, onSuccess: async (account) => { - await queryClient.setQueryData(CURRENT_ACCOUNT_QUERY_KEY(), account); + await queryClient.setQueryData(CURRENT_ACCOUNT_QUERY_KEY(querySessionId), account); }, }); }; diff --git a/modules/identity/src/web/context/identity-auth-session.context.tsx b/modules/identity/src/web/context/identity-auth-session.context.tsx index aa0a3f94..dd057e14 100644 --- a/modules/identity/src/web/context/identity-auth-session.context.tsx +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -1,6 +1,8 @@ import { isApiClientError } from "@erp/core/client"; -import { useDataSource } from "@erp/core/hooks"; +import { CompanyQueryScopeProvider, useDataSource } from "@erp/core/hooks"; +import { changeCompanyQueryContext, clearSessionQueryContext } from "@erp/core/query"; import type { CurrentSessionResponseDTO } from "@erp/identity/common"; +import { useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, @@ -39,6 +41,7 @@ export interface IdentityAuthSessionContextValue { companySession: CompanySession; availableCompanies: ActiveCompany[]; activeCompany: ActiveCompany | null; + querySessionId: string | null; isAuthenticated: boolean; isLoading: boolean; login: (params: LoginForm) => Promise; @@ -46,7 +49,7 @@ export interface IdentityAuthSessionContextValue { restoreSession: () => Promise; refreshSession: () => Promise; refreshAccessToken: () => Promise; - selectActiveCompany: (companyId: string) => ActiveCompany | null; + selectActiveCompany: (companyId: string) => Promise; changeActiveCompany: (companyId: string) => Promise; updateActiveCompany: (company: { id: string; @@ -80,6 +83,11 @@ const isUnauthorizedError = (error: unknown) => isApiClientError(error) && error export function IdentityAuthSessionProvider({ children }: { children: React.ReactNode }) { const dataSource = useDataSource(); + const queryClient = useQueryClient(); + const sessionEpochRef = useRef(0); + const [sessionEpoch, setSessionEpoch] = useState(0); + const changingCompanyRef = useRef(false); + const [isChangingCompany, setIsChangingCompany] = useState(false); const loginMutation = useLoginMutation(); const logoutMutation = useLogoutMutation(); const refreshSessionMutation = useRefreshSessionMutation(); @@ -91,15 +99,22 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac const restoreInFlightRef = useRef | null>(null); const clearSession = useCallback(() => { + sessionEpochRef.current += 1; + setSessionEpoch(sessionEpochRef.current); + // clear destruye también consultas en vuelo; sus callbacks conservan la clave de la sesión antigua. + clearSessionQueryContext(queryClient); authTokenStorage.clear(); activeCompanyStorage.clear(); setSession(null); setCompanySession(EMPTY_COMPANY_SESSION); - }, []); + }, [queryClient]); const hydrateCompanySession = useCallback( - async (accessToken: string) => { + async (accessToken: string, epoch = sessionEpochRef.current) => { const response = await findAvailableCompanies(dataSource, accessToken); + if (epoch !== sessionEpochRef.current) { + throw new Error("Session changed"); + } const parsedResponse = AvailableCompaniesResponseDTOSchema.safeParse(response); if (!parsedResponse.success) { @@ -130,6 +145,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac return restoreInFlightRef.current; } + const epoch = sessionEpochRef.current; const restorePromise = Promise.resolve().then(async () => { const tokens = authTokenStorage.getTokens(); @@ -159,6 +175,9 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshToken: activeTokens.refreshToken, }); + if (epoch !== sessionEpochRef.current) { + return null; + } authTokenStorage.setTokens(refreshedTokens); activeTokens = refreshedTokens; response = await getCurrentSession(dataSource, activeTokens.accessToken); @@ -169,12 +188,17 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshToken: activeTokens.refreshToken, }); - await hydrateCompanySession(activeTokens.accessToken); + await hydrateCompanySession(activeTokens.accessToken, epoch); + if (epoch !== sessionEpochRef.current) { + return null; + } setSession(restoredSession); return restoredSession; } catch { - clearSession(); + if (epoch === sessionEpochRef.current) { + clearSession(); + } return null; } finally { restoreInFlightRef.current = null; @@ -189,7 +213,12 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac const login = useCallback( async (params: LoginForm) => { + clearSession(); + const epoch = sessionEpochRef.current; const nextSession = await loginMutation.mutateAsync(params); + if (epoch !== sessionEpochRef.current) { + throw new Error("Session changed"); + } authTokenStorage.setTokens({ accessToken: nextSession.accessToken, @@ -197,15 +226,16 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac }); activeCompanyStorage.clear(); - await hydrateCompanySession(nextSession.accessToken); + await hydrateCompanySession(nextSession.accessToken, epoch); setSession(nextSession); return nextSession; }, - [hydrateCompanySession, loginMutation] + [clearSession, hydrateCompanySession, loginMutation] ); const logout = useCallback(async () => { + const epoch = sessionEpochRef.current; const tokens = authTokenStorage.getTokens(); try { @@ -216,11 +246,14 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac }); } } finally { - clearSession(); + if (epoch === sessionEpochRef.current) { + clearSession(); + } } }, [clearSession, logoutMutation]); const refreshSession = useCallback(async () => { + const epoch = sessionEpochRef.current; const tokens = authTokenStorage.getTokens(); if (!tokens.refreshToken) { @@ -233,8 +266,11 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshToken: tokens.refreshToken, }); + if (epoch !== sessionEpochRef.current) { + return null; + } authTokenStorage.setTokens(refreshedTokens); - await hydrateCompanySession(refreshedTokens.accessToken); + await hydrateCompanySession(refreshedTokens.accessToken, epoch); if (session) { const refreshedSession: AuthSession = { @@ -251,6 +287,9 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac dataSource, refreshedTokens.accessToken ); + if (epoch !== sessionEpochRef.current) { + return null; + } const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, { accessToken: refreshedTokens.accessToken, refreshToken: refreshedTokens.refreshToken, @@ -260,7 +299,9 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac return refreshedSession; } catch { - clearSession(); + if (epoch === sessionEpochRef.current) { + clearSession(); + } return null; } }, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation, session]); @@ -271,7 +312,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac }, [refreshSession]); const selectActiveCompany = useCallback( - (companyId: string) => { + async (companyId: string) => { if (companySession.activeCompany?.id === companyId) { return companySession.activeCompany; } @@ -283,15 +324,31 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac return null; } - activeCompanyStorage.setActiveCompanyId(nextActiveCompany.id); - setCompanySession((currentSession) => ({ - ...currentSession, - activeCompany: nextActiveCompany, - })); - - return nextActiveCompany; + if (changingCompanyRef.current) { + return null; + } + changingCompanyRef.current = true; + setIsChangingCompany(true); + const epoch = sessionEpochRef.current; + try { + const changed = await changeCompanyQueryContext({ + queryClient, + isCurrentSession: () => epoch === sessionEpochRef.current, + activate: () => { + activeCompanyStorage.setActiveCompanyId(nextActiveCompany.id); + setCompanySession((currentSession) => ({ + ...currentSession, + activeCompany: nextActiveCompany, + })); + }, + }); + return changed ? nextActiveCompany : null; + } finally { + changingCompanyRef.current = false; + setIsChangingCompany(false); + } }, - [companySession.activeCompany, companySession.availableCompanies] + [companySession.activeCompany, companySession.availableCompanies, queryClient] ); const changeActiveCompany = useCallback( @@ -372,8 +429,8 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac ); useEffect(() => { - restoreSession(); - }, [restoreSession]); + restoreSession().catch(clearSession); + }, [restoreSession, clearSession]); useEffect(() => { setIdentityAuthSessionRuntimeHandlers({ @@ -393,8 +450,10 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac companySession, availableCompanies: companySession.availableCompanies, activeCompany: companySession.activeCompany, + querySessionId: session ? `${session.account.id}:${sessionEpoch}` : null, isAuthenticated: Boolean(session), isLoading: + isChangingCompany || isRestoringSession || loginMutation.isPending || logoutMutation.isPending || @@ -416,6 +475,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac clearSession, companySession, getAuthenticatedRedirectPath, + isChangingCompany, isRestoringSession, login, loginMutation.isPending, @@ -429,12 +489,24 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac updateActiveCompany, updateAccount, session, + sessionEpoch, ] ); return ( - {children} + + {children} + ); } diff --git a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts index f5203354..ed6ae06b 100644 --- a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts +++ b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.test.ts @@ -33,7 +33,7 @@ const valid = { }, }; -async function runtime(t: TestContext, error?: Error) { +async function runtime(t: TestContext, error?: Error, globalAccess = true) { const app = express(); app.use(express.json()); const calls: CompleteInitialOnboardingUseCaseInput[] = []; @@ -87,6 +87,7 @@ async function runtime(t: TestContext, error?: Error) { } assert.equal(name, "identity:general"); return { + authorization: { canSystem: async () => Result.ok(globalAccess) }, auth: { authenticatedOnly: () => [authenticate, requireAuthenticated()], tenantRequired: () => { @@ -244,3 +245,11 @@ test("POST /initial exige autenticación", async (t) => { assert.equal((await r.request({}, false, "/api/v1/onboarding/initial")).status, 401); assert.equal(r.provisionCalls(), 0); }); + +test("onboarding rechaza cuenta autenticada sin capacidad SYSTEM", async (t) => { + const subject = await runtime(t, undefined, false); + assert.equal((await subject.request()).status, 403); + assert.equal((await subject.request({}, true, "/api/v1/onboarding/initial")).status, 403); + assert.equal(subject.calls.length, 0); + assert.equal(subject.provisionCalls(), 0); +}); diff --git a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts index 0074842f..d1ed8fa0 100644 --- a/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts +++ b/modules/onboarding/src/api/infrastructure/express/onboarding.routes.ts @@ -1,5 +1,7 @@ import type { StartParams } from "@erp/core/api"; -import { requireIdentityAuthenticated } from "@erp/identity/api"; +import type { IIdentityPublicServices } from "@erp/identity/api"; +import { requireIdentityAuthenticated, requirePermission } from "@erp/identity/api"; +import { IdentityPermissionCodes } from "@erp/identity/common"; import { type NextFunction, type Request, type Response, Router } from "express"; import type { OnboardingInternalDeps } from "../di"; @@ -25,7 +27,13 @@ export const onboardingRouter = (params: StartParams) => { return controller.execute(req, res, next); }); - router.post("/initial", (req: Request, res: Response, next: NextFunction) => { + const identity = params.getService("identity:general"); + const initialGuard = requirePermission( + identity.authorization, + IdentityPermissionCodes.InitialOnboarding, + "SYSTEM" + ); + router.post("/initial", initialGuard, (req: Request, res: Response, next: NextFunction) => { const controller = new ProvisionInitialOnboardingController( deps.applicationServices.provisioner ); @@ -34,6 +42,7 @@ export const onboardingRouter = (params: StartParams) => { router.post( "/initial/complete", + initialGuard, validateCompleteInitialOnboardingRequest, (req: Request, res: Response, next: NextFunction) => { const controller = new CompleteInitialOnboardingController( diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/supplier-company-scope.test.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/supplier-company-scope.test.ts new file mode 100644 index 00000000..a001ff6e --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/supplier-company-scope.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Result } from "@repo/rdx-utils"; + +import { SupplierModel } from "../models"; + +import { SupplierRepository } from "./sequelize-supplier.repository"; + +for (const outcome of ["allowed", "foreign", "missing"] as const) { + test(`Supplier scoped update: ${outcome} conserva transacción y no revela existencia`, async (t) => { + const transaction = {}; + const payload = { + id: "supplier-id", + company_id: outcome === "foreign" ? "B" : "A", + name: "Changed", + }; + const repository = new SupplierRepository( + { mapToPersistence: () => Result.ok(payload) } as never, + {} as never, + {} as never + ); + t.mock.method( + SupplierModel, + "update", + async (changes: unknown, options: { where: unknown; transaction?: unknown }) => { + assert.deepEqual(changes, { name: "Changed" }); + assert.deepEqual(options.where, { id: payload.id, company_id: payload.company_id }); + assert.equal(options.transaction, transaction); + return [outcome === "allowed" ? 1 : 0]; + } + ); + const result = await repository.update({} as never, transaction as never); + assert.equal(result.isSuccess, outcome === "allowed"); + if (result.isFailure) { + assert.equal(result.error.message, "Concurrency conflict or not found update supplier"); + } + }); +} diff --git a/modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts b/modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts index 05e5c056..08c87261 100644 --- a/modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts +++ b/modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts @@ -1,3 +1,5 @@ +import type { CompanyQueryScope } from "@erp/core/query"; +import { companyQueryKey } from "@erp/core/query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { SupplierToListRowPatchAdapter } from "../adapters"; @@ -5,46 +7,64 @@ import type { Supplier, SupplierList, SupplierListRow } from "../entities"; import { LIST_SUPPLIERS_QUERY_KEY_PREFIX, SUPPLIER_QUERY_KEY } from "./keys"; -export function cancelSupplierListQueries(queryClient: QueryClient) { +export function cancelSupplierListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.cancelQueries({ - queryKey: LIST_SUPPLIERS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_SUPPLIERS_QUERY_KEY_PREFIX), }); } -export function invalidateSupplierListQueries(queryClient: QueryClient) { +export function invalidateSupplierListQueries( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +) { return queryClient.invalidateQueries({ - queryKey: LIST_SUPPLIERS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_SUPPLIERS_QUERY_KEY_PREFIX), }); } -export function invalidateSupplierDetailQuery(queryClient: QueryClient, supplierId: string) { +export function invalidateSupplierDetailQuery( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + supplierId: string +) { return queryClient.invalidateQueries({ - queryKey: SUPPLIER_QUERY_KEY(supplierId), + queryKey: companyQueryKey(queryScope, SUPPLIER_QUERY_KEY(supplierId)), exact: true, }); } export function setSupplierDetailCache( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, supplierId: string, supplier: Supplier ) { - queryClient.setQueryData(SUPPLIER_QUERY_KEY(supplierId), supplier); + queryClient.setQueryData( + companyQueryKey(queryScope, SUPPLIER_QUERY_KEY(supplierId)), + supplier + ); } -export function getAllSupplierListQueryKeys(queryClient: QueryClient): QueryKey[] { +export function getAllSupplierListQueryKeys( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient +): QueryKey[] { const entries = queryClient.getQueriesData({ - queryKey: LIST_SUPPLIERS_QUERY_KEY_PREFIX, + queryKey: companyQueryKey(queryScope, LIST_SUPPLIERS_QUERY_KEY_PREFIX), }); return entries.map(([key]) => key); } export function upsertSupplierInListCaches( + queryScope: CompanyQueryScope | null, queryClient: QueryClient, supplier: Pick & Partial ) { - const keys = getAllSupplierListQueryKeys(queryClient); + const keys = getAllSupplierListQueryKeys(queryScope, queryClient); for (const key of keys) { const page = queryClient.getQueryData(key); @@ -70,14 +90,26 @@ export function upsertSupplierInListCaches( } } -export function syncCreatedSupplierCaches(queryClient: QueryClient, supplier: Supplier) { - setSupplierDetailCache(queryClient, supplier.id, supplier); - return invalidateSupplierListQueries(queryClient); +export function syncCreatedSupplierCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + supplier: Supplier +) { + setSupplierDetailCache(queryScope, queryClient, supplier.id, supplier); + return invalidateSupplierListQueries(queryScope, queryClient); } -export function syncUpdatedSupplierCaches(queryClient: QueryClient, supplier: Supplier) { - setSupplierDetailCache(queryClient, supplier.id, supplier); - upsertSupplierInListCaches(queryClient, SupplierToListRowPatchAdapter.fromSupplier(supplier)); +export function syncUpdatedSupplierCaches( + queryScope: CompanyQueryScope | null, + queryClient: QueryClient, + supplier: Supplier +) { + setSupplierDetailCache(queryScope, queryClient, supplier.id, supplier); + upsertSupplierInListCaches( + queryScope, + queryClient, + SupplierToListRowPatchAdapter.fromSupplier(supplier) + ); - return invalidateSupplierListQueries(queryClient); + return invalidateSupplierListQueries(queryScope, queryClient); } diff --git a/modules/suppliers/src/web/shared/hooks/use-supplier-create-mutation.ts b/modules/suppliers/src/web/shared/hooks/use-supplier-create-mutation.ts index bfae5bc0..8eb1baa0 100644 --- a/modules/suppliers/src/web/shared/hooks/use-supplier-create-mutation.ts +++ b/modules/suppliers/src/web/shared/hooks/use-supplier-create-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -17,6 +17,7 @@ import { toValidationErrors } from "./to-validation-errors"; type CreateSupplierContext = {}; export const useSupplierCreateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = CreateSupplierRequestSchema; @@ -41,11 +42,11 @@ export const useSupplierCreateMutation = () => { }, onSuccess: (createdSupplier) => { - syncCreatedSupplierCaches(queryClient, createdSupplier); + return syncCreatedSupplierCaches(queryScope, queryClient, createdSupplier); }, onSettled: async () => { - await invalidateSupplierListQueries(queryClient); + await invalidateSupplierListQueries(queryScope, queryClient); }, }); }; diff --git a/modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts b/modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts index 2734d0b9..2a97aa21 100644 --- a/modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts +++ b/modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts @@ -1,4 +1,5 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetSupplierByIdAdapter } from "../adapters"; @@ -15,12 +16,13 @@ type SupplierGetQueryOptions = { export const useSupplierGetQuery = ( options?: SupplierGetQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const id = options?.id; const enabled = options?.enabled ?? Boolean(id); return useQuery({ - queryKey: SUPPLIER_QUERY_KEY(id), + queryKey: companyQueryKey(queryScope, SUPPLIER_QUERY_KEY(id)), queryFn: async ({ signal }) => { if (!id) { throw new Error("supplierId is required"); diff --git a/modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts b/modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts index 90e5d7d9..b46bd6a0 100644 --- a/modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts +++ b/modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts @@ -1,4 +1,4 @@ -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -18,6 +18,7 @@ import { toValidationErrors } from "./to-validation-errors"; type UpdateSupplierContext = {}; export const useSupplierUpdateMutation = () => { + const queryScope = useCompanyQueryScope(); const queryClient = useQueryClient(); const dataSource = useDataSource(); const schema = UpdateSupplierByIdRequestSchema; @@ -41,7 +42,7 @@ export const useSupplierUpdateMutation = () => { }, onSuccess: async (updatedSupplier) => { - await syncUpdatedSupplierCaches(queryClient, updatedSupplier); + await syncUpdatedSupplierCaches(queryScope, queryClient, updatedSupplier); }, }); }; diff --git a/modules/suppliers/src/web/shared/hooks/use-suppliers-list-query.ts b/modules/suppliers/src/web/shared/hooks/use-suppliers-list-query.ts index 79d2c905..a2507e5d 100644 --- a/modules/suppliers/src/web/shared/hooks/use-suppliers-list-query.ts +++ b/modules/suppliers/src/web/shared/hooks/use-suppliers-list-query.ts @@ -1,5 +1,6 @@ import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; +import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks"; +import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query"; import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListSuppliersAdapter } from "../adapters"; @@ -16,12 +17,13 @@ export interface SuppliersListQueryOptions { export const useSuppliersListQuery = ( options?: SuppliersListQueryOptions ): UseQueryResult => { + const queryScope = useCompanyQueryScope(); const dataSource = useDataSource(); const enabled = options?.enabled ?? true; const criteria = options?.criteria ?? {}; return useQuery({ - queryKey: LIST_SUPPLIERS_QUERY_KEY(criteria), + queryKey: companyQueryKey(queryScope, LIST_SUPPLIERS_QUERY_KEY(criteria)), queryFn: async ({ signal }) => { const dto: ListSuppliersResult = await getListSuppliersByCriteria(dataSource, { criteria, @@ -30,6 +32,7 @@ export const useSuppliersListQuery = ( return ListSuppliersAdapter.fromDTO(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria + placeholderData: (previousData, previousQuery) => + keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria }); }; diff --git a/tests/e2e/identity/tenant-cache.spec.ts b/tests/e2e/identity/tenant-cache.spec.ts new file mode 100644 index 00000000..289ce572 --- /dev/null +++ b/tests/e2e/identity/tenant-cache.spec.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; + +import { expect, test } from "@playwright/test"; + +import bcrypt from "../../../apps/server/node_modules/bcrypt/bcrypt.js"; +import mysql from "../../../apps/server/node_modules/mysql2/promise.js"; +import { loginViaUi } from "../support/login-via-ui"; + +// La cuenta secundaria solo se inserta en la BD efímera del launcher oficial. +test("IDENTITY-TENANT-CACHE: cambio de empresa y logout/login descartan el formulario anterior", async ({ + page, + request, +}) => { + const email = process.env.E2E_USER_EMAIL; + const password = process.env.E2E_USER_PASSWORD; + if (!(email && password && process.env.DB_NAME?.startsWith("factuges_erp_e2e_"))) { + throw new Error("Requires the official isolated E2E environment."); + } + const login = await request.post("http://127.0.0.1:3002/api/v1/identity/auth/login", { + data: { email, password }, + }); + expect(login.status()).toBe(200); + await loginViaUi(page); + const tokens = await login.json(); + const companyId = randomUUID(); + const created = await request.post("http://127.0.0.1:3002/api/v1/companies", { + headers: { authorization: `Bearer ${tokens.access_token}` }, + data: { id: companyId, legal_name: "Tenant cache B", slug: `cache-${companyId}` }, + }); + expect(created.status()).toBe(201); + const accountId = randomUUID(); + const secondEmail = `cache-${accountId}@example.test`; + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASS, + }); + try { + await connection.execute( + "INSERT INTO identity_accounts (id,email,password_hash,name,language_code,status,created_at,updated_at) VALUES (?,?,?,?, 'es','active',NOW(),NOW())", + [accountId, secondEmail, await bcrypt.hash(password, 4), "Tenant cache second user"] + ); + const membershipId = randomUUID(); + const roleId = randomUUID(); + await connection.execute( + "INSERT INTO identity_roles (id,company_id,code,name,is_system,permission_codes,created_at,updated_at) VALUES (?,?,'commercial','Commercial',false,?,NOW(),NOW())", + [roleId, companyId, JSON.stringify(["identity:permissions:read"])] + ); + await connection.execute( + "INSERT INTO identity_company_memberships (id,account_id,company_id,status,created_at,updated_at) VALUES (?,?,?,'active',NOW(),NOW())", + [membershipId, accountId, companyId] + ); + await connection.execute( + "INSERT INTO identity_company_membership_roles (membership_id,role_id) VALUES (?,?)", + [membershipId, roleId] + ); + await connection.execute( + "INSERT INTO initial_onboardings (id,account_id,status,company_id,created_at,updated_at,completed_at) VALUES (?,?,'completed',?,NOW(),NOW(),NOW())", + [randomUUID(), accountId, companyId] + ); + } finally { + await connection.end(); + } + + await page.goto("/settings/company/details"); + const name = page.locator('input[name="legalName"]'); + await expect(name).toBeVisible(); + await name.fill("Unsaved tenant A"); + await page.getByTestId("company-switcher-trigger").click(); + await page.getByTestId(`company-option-${companyId}`).click(); + await expect(name).toHaveValue("Tenant cache B"); + await name.fill("Unsaved old account"); + await page.locator("#account-menu").click(); + await page.locator("#account-logout").click(); + await expect(page.locator("#login-email")).toBeVisible(); + await page.locator("#login-email").fill(secondEmail); + await page.locator("#login-password").fill(password); + await page.locator("#login-submit").click(); + await expect(page.locator("#account-menu")).toBeVisible(); + await page.goto("/settings/company/details"); + await expect(name).toHaveValue("Tenant cache B"); + await expect(page.getByTestId("company-switcher-trigger")).toContainText("Tenant cache B"); +});