ERP-ARCH-01-tenant-authorization-cache
This commit is contained in:
parent
da362fef64
commit
3d8256b694
@ -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<ICompanyPublicServices>(
|
||||
"server-infrastructure",
|
||||
["companies"],
|
||||
"companies:general"
|
||||
).finder,
|
||||
config,
|
||||
app,
|
||||
database,
|
||||
|
||||
@ -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`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@ -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 }
|
||||
|
||||
355
apps/server/src/testing/http/authorization-policy.http.test.ts
Normal file
355
apps/server/src/testing/http/authorization-policy.http.test.ts
Normal file
@ -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<IIdentityPublicServices>("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<IIdentityPublicServices>(
|
||||
"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
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1 @@
|
||||
{}
|
||||
@ -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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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<ICompanyPublicServices>(
|
||||
"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 {
|
||||
|
||||
@ -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"
|
||||
);
|
||||
});
|
||||
131
apps/server/src/testing/http/tenant-authorization.http.test.ts
Normal file
131
apps/server/src/testing/http/tenant-authorization.http.test.ts
Normal file
@ -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<IIdentityPublicServices>(
|
||||
"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
|
||||
);
|
||||
});
|
||||
@ -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 (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<HomeMonthlyBilling>({
|
||||
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,
|
||||
|
||||
@ -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<HomeProformasOverview>({
|
||||
queryKey: ["home", "proformas", "overview"],
|
||||
queryKey: companyQueryKey(queryScope, ["home", "proformas", "overview"]),
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await getHomeProformas(dataSource, {
|
||||
signal,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
<DropdownMenuItem
|
||||
className="gap-3 py-2"
|
||||
data-testid={`company-option-${company.id}`}
|
||||
disabled={company.isSelected || isChangingCompany}
|
||||
key={company.id}
|
||||
onSelect={() => handleSelectCompany(company.id)}
|
||||
onClick={() => handleSelectCompany(company.id)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-foreground">
|
||||
{company.initials}
|
||||
|
||||
@ -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 (
|
||||
<AppFullscreenLayoutFrame>
|
||||
<Outlet />
|
||||
<CompanyPageOutlet />
|
||||
</AppFullscreenLayoutFrame>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<AppSidebarLayoutFrame>
|
||||
<Outlet />
|
||||
<CompanyPageOutlet />
|
||||
</AppSidebarLayoutFrame>
|
||||
);
|
||||
};
|
||||
|
||||
14
apps/web/src/layout/company-page-outlet.tsx
Normal file
14
apps/web/src/layout/company-page-outlet.tsx
Normal file
@ -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 <LoadingOverlay />;
|
||||
}
|
||||
return <Outlet key={`${scope?.sessionId ?? "none"}:${scope?.companyId ?? "none"}`} />;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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<boolean, Error>`. 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<IIdentityPublicServices>("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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<AuditEntryDetail, DefaultError>({
|
||||
queryKey: auditQueryKeys.detail(companyId, id ?? "none"),
|
||||
queryKey: companyQueryKey(queryScope, auditQueryKeys.detail(companyId, id ?? "none")),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!id) {
|
||||
throw new Error("auditEntryId is required");
|
||||
|
||||
@ -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<AuditList, DefaultError>({
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -7,7 +7,11 @@ import type { PaymentMethodSummary } from "../models";
|
||||
|
||||
export interface IPaymentMethodRepository {
|
||||
create(paymentMethod: PaymentMethod, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(paymentMethod: PaymentMethod, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(
|
||||
companyId: UniqueID,
|
||||
paymentMethod: PaymentMethod,
|
||||
transaction?: unknown
|
||||
): Promise<Result<void, Error>>;
|
||||
existsByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
|
||||
@ -42,7 +42,7 @@ class InMemoryPaymentMethodRepository implements IPaymentMethodRepository {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
async update(paymentMethod: PaymentMethod): Promise<Result<void, Error>> {
|
||||
async update(_companyId: UniqueID, paymentMethod: PaymentMethod): Promise<Result<void, Error>> {
|
||||
this.items.set(paymentMethod.id.toString(), paymentMethod);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -15,8 +15,10 @@ import {
|
||||
import type { TaxDefinitionSummary } from "../models";
|
||||
import type { ITaxDefinitionRepository } from "../repositories";
|
||||
|
||||
const valueOf = <T>(result: Result<T, Error>): T => {
|
||||
if (result.isFailure) throw result.error;
|
||||
const unwrapResult = <T>(result: Result<T, Error>): 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<Result<void, Error>> {
|
||||
async update(companyId: UniqueID, taxDefinition: TaxDefinition): Promise<Result<void, Error>> {
|
||||
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<Result<boolean, Error>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -7,7 +7,11 @@ import type { TaxDefinitionSummary } from "../../tax-definitions/models";
|
||||
|
||||
export interface ITaxDefinitionRepository {
|
||||
create(taxDefinition: TaxDefinition, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(taxDefinition: TaxDefinition, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(
|
||||
companyId: UniqueID,
|
||||
taxDefinition: TaxDefinition,
|
||||
transaction?: unknown
|
||||
): Promise<Result<void, Error>>;
|
||||
deleteByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
|
||||
@ -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<Result<TaxDefinition, Error>>;
|
||||
enable(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
transaction?: unknown
|
||||
): Promise<Result<TaxDefinition, Error>>;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -7,7 +7,11 @@ import type { TaxRegimeSummary } from "../models";
|
||||
|
||||
export interface ITaxRegimeRepository {
|
||||
create(taxRegime: TaxRegime, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(taxRegime: TaxRegime, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(
|
||||
companyId: UniqueID,
|
||||
taxRegime: TaxRegime,
|
||||
transaction?: unknown
|
||||
): Promise<Result<void, Error>>;
|
||||
deleteByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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<void, Error>
|
||||
*/
|
||||
async update(
|
||||
companyId: UniqueID,
|
||||
paymentMethod: PaymentMethod,
|
||||
transaction?: Transaction
|
||||
): Promise<Result<void, Error>> {
|
||||
@ -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();
|
||||
|
||||
@ -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<Result<void, Error>> {
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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<void, Error>
|
||||
*/
|
||||
async update(taxRegime: TaxRegime, transaction?: Transaction): Promise<Result<void, Error>> {
|
||||
async update(
|
||||
companyId: UniqueID,
|
||||
taxRegime: TaxRegime,
|
||||
transaction?: Transaction
|
||||
): Promise<Result<void, Error>> {
|
||||
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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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<PaymentMethod, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId);
|
||||
|
||||
return useQuery<PaymentMethod, DefaultError>({
|
||||
queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
|
||||
queryKey: companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethodId)),
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getPaymentMethodById(dataSource, {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<PaymentMethodList, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? true;
|
||||
const criteria = options?.criteria ?? {};
|
||||
|
||||
return useQuery<PaymentMethodList, DefaultError>({
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
@ -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)),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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<PaymentTerm, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = (options?.enabled ?? true) && Boolean(paymentTermId);
|
||||
|
||||
return useQuery<PaymentTerm, DefaultError>({
|
||||
queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId),
|
||||
queryKey: companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTermId)),
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getPaymentTermById(dataSource, {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<PaymentTermList, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? true;
|
||||
const criteria = options?.criteria ?? {};
|
||||
|
||||
return useQuery<PaymentTermList, DefaultError>({
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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<TaxDefinition, DefaultError, CreateTaxDefinitionParams>({
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<void, DefaultError, DeleteTaxDefinitionByIdParams>({
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<TaxDefinition, DefaultError> => {
|
||||
export const useTaxDefinitionDetailQuery = (
|
||||
taxDefinitionId?: string,
|
||||
options?: { enabled?: boolean }
|
||||
): UseQueryResult<TaxDefinition, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = (options?.enabled ?? true) && Boolean(taxDefinitionId);
|
||||
return useQuery<TaxDefinition, DefaultError>({
|
||||
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 })
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<TaxDefinition, DefaultError, DisableTaxDefinitionByIdParams>({
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<TaxDefinition, DefaultError, EnableTaxDefinitionByIdParams>({
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<CriteriaDTO>; }
|
||||
export interface TaxDefinitionListQueryOptions {
|
||||
enabled?: boolean;
|
||||
criteria?: Partial<CriteriaDTO>;
|
||||
}
|
||||
|
||||
export const useTaxDefinitionListQuery = (options?: TaxDefinitionListQueryOptions): UseQueryResult<TaxDefinitionList, DefaultError> => {
|
||||
export const useTaxDefinitionListQuery = (
|
||||
options?: TaxDefinitionListQueryOptions
|
||||
): UseQueryResult<TaxDefinitionList, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const criteria = options?.criteria ?? {};
|
||||
return useQuery<TaxDefinitionList, DefaultError>({
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<TaxDefinition, DefaultError, UpdateTaxDefinitionByIdParams>({
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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<TaxRegime, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = (options?.enabled ?? true) && Boolean(taxRegimeId);
|
||||
|
||||
return useQuery<TaxRegime, DefaultError>({
|
||||
queryKey: TAX_REGIME_QUERY_KEY(taxRegimeId),
|
||||
queryKey: companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegimeId)),
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getTaxRegimeById(dataSource, {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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<TaxRegimeList, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
const enabled = options?.enabled ?? true;
|
||||
const criteria = options?.criteria ?? {};
|
||||
|
||||
return useQuery<TaxRegimeList, DefaultError>({
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@ export interface ICompanyRepository {
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<boolean, Error>>;
|
||||
findActiveByIds(params: {
|
||||
allCompanies?: boolean;
|
||||
companyIds: Collection<UniqueID>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<Company>, Error>>;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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<Result<CompanyResponseDTO, Error>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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<IIdentityPublicServices, "companyAccess">;
|
||||
identityServices: Pick<IIdentityPublicServices, "companyAccess" | "authorization">;
|
||||
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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -133,6 +133,7 @@ export class SequelizeCompanyRepository
|
||||
}
|
||||
|
||||
public async findActiveByIds(params: {
|
||||
allCompanies?: boolean;
|
||||
companyIds: Collection<UniqueID>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<Company>, 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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./dto";
|
||||
export * from "./permissions";
|
||||
|
||||
21
modules/companies/src/common/permissions.ts
Normal file
21
modules/companies/src/common/permissions.ts
Normal file
@ -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[];
|
||||
@ -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<CompanySettings, DefaultError> => {
|
||||
const queryScope = useCompanyQueryScope();
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useQuery<CompanySettings, DefaultError>({
|
||||
queryKey: COMPANY_SETTINGS_QUERY_KEY(),
|
||||
queryKey: companyQueryKey(queryScope, COMPANY_SETTINGS_QUERY_KEY()),
|
||||
enabled: true,
|
||||
queryFn: async ({ signal }) => {
|
||||
const dto = await getCompanySettings(dataSource, { signal });
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -63,6 +63,7 @@ mock.module("@erp/core/components", {
|
||||
|
||||
mock.module("@erp/core/hooks", {
|
||||
namedExports: {
|
||||
useCompanyQueryScope: () => null,
|
||||
UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
modules/core/src/api/modules/module-permissions.test.ts
Normal file
38
modules/core/src/api/modules/module-permissions.test.ts
Normal file
@ -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 }] },
|
||||
])
|
||||
);
|
||||
});
|
||||
26
modules/core/src/api/modules/module-permissions.ts
Normal file
26
modules/core/src/api/modules/module-permissions.ts
Normal file
@ -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<string, PermissionDefinition>();
|
||||
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()]);
|
||||
}
|
||||
@ -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/
|
||||
);
|
||||
});
|
||||
|
||||
@ -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<ModulesRuntime> {
|
||||
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", {
|
||||
|
||||
@ -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> | void;
|
||||
}
|
||||
|
||||
@ -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<Result<boolean, Error>>;
|
||||
};
|
||||
|
||||
/** 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.
|
||||
|
||||
@ -2,6 +2,7 @@ export type PermissionCode = string;
|
||||
|
||||
export type PermissionDefinition = {
|
||||
code: PermissionCode;
|
||||
scope: "SYSTEM" | "COMPANY";
|
||||
module: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
22
modules/core/src/web/hooks/company-query-scope.tsx
Normal file
22
modules/core/src/web/hooks/company-query-scope.tsx
Normal file
@ -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<CompanyQueryScope | null>(null);
|
||||
|
||||
export function CompanyQueryScopeProvider({
|
||||
scope,
|
||||
children,
|
||||
}: {
|
||||
scope: CompanyQueryScope | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CompanyQueryScopeContext.Provider value={scope}>{children}</CompanyQueryScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCompanyQueryScope() {
|
||||
return useContext(CompanyQueryScopeContext);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user