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 http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
|
import type { ICompanyPublicServices } from "@erp/companies/api";
|
||||||
import {
|
import {
|
||||||
AsyncLocalStorageOperationContextStore,
|
AsyncLocalStorageOperationContextStore,
|
||||||
AuditChangeModel,
|
AuditChangeModel,
|
||||||
@ -275,6 +276,11 @@ process.on("uncaughtException", async (error: Error) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await startModules({
|
await startModules({
|
||||||
|
tenantCompanyFinder: getServiceScoped<ICompanyPublicServices>(
|
||||||
|
"server-infrastructure",
|
||||||
|
["companies"],
|
||||||
|
"companies:general"
|
||||||
|
).finder,
|
||||||
config,
|
config,
|
||||||
app,
|
app,
|
||||||
database,
|
database,
|
||||||
|
|||||||
@ -46,7 +46,7 @@ test("crea el grafo mínimo y nunca persiste la contraseña en claro", async ()
|
|||||||
}) as never,
|
}) as never,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert.equal(queries.length, 6);
|
assert.equal(queries.length, 7);
|
||||||
assert.equal(result.email, "e2e@example.test");
|
assert.equal(result.email, "e2e@example.test");
|
||||||
assert.equal(result.companySlug, "e2e-company");
|
assert.equal(result.companySlug, "e2e-company");
|
||||||
const account = queries[0];
|
const account = queries[0];
|
||||||
@ -126,7 +126,8 @@ for (const [name, failureAt] of [
|
|||||||
["company", 2],
|
["company", 2],
|
||||||
["membership", 3],
|
["membership", 3],
|
||||||
["role", 4],
|
["role", 4],
|
||||||
["onboarding", 6],
|
["system-owner", 6],
|
||||||
|
["onboarding", 7],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`revierte la transacción si falla ${name}`, async () => {
|
test(`revierte la transacción si falla ${name}`, async () => {
|
||||||
const previous = process.env.NODE_ENV;
|
const previous = process.env.NODE_ENV;
|
||||||
@ -174,7 +175,7 @@ function dependenciesWithFailure(failureAt: number, events: string[]) {
|
|||||||
events.push(`query:${position}`);
|
events.push(`query:${position}`);
|
||||||
if (position === failureAt) {
|
if (position === failureAt) {
|
||||||
throw new Error(
|
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)",
|
"INSERT INTO identity_company_membership_roles (membership_id, role_id) VALUES (:membershipId, :roleId)",
|
||||||
{ membershipId, roleId }
|
{ membershipId, roleId }
|
||||||
);
|
);
|
||||||
|
await query("INSERT INTO identity_system_owner (id, account_id) VALUES (1, :accountId)", {
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
await query(
|
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())",
|
"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 }
|
{ 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,
|
createTestMembership,
|
||||||
createTestPaymentTerm,
|
createTestPaymentTerm,
|
||||||
findCompanyRow,
|
findCompanyRow,
|
||||||
|
grantTestPermissions,
|
||||||
loginByApi,
|
loginByApi,
|
||||||
} from "./http-test-fixtures.ts";
|
} from "./http-test-fixtures.ts";
|
||||||
import { createHttpTestRuntime } from "./http-test-runtime.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",
|
website: "https://acme.test",
|
||||||
});
|
});
|
||||||
|
|
||||||
await createTestMembership(runtime.database, {
|
const membership = await createTestMembership(runtime.database, {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
});
|
});
|
||||||
|
await grantTestPermissions(runtime.database, {
|
||||||
|
membershipId: membership.id,
|
||||||
|
companyId: company.id,
|
||||||
|
permissionCodes: ["identity:companies:update"],
|
||||||
|
});
|
||||||
|
|
||||||
const loginResponse = await loginByApi({
|
const loginResponse = await loginByApi({
|
||||||
request: runtime.request,
|
request: runtime.request,
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
createTestCompany,
|
createTestCompany,
|
||||||
createTestMembership,
|
createTestMembership,
|
||||||
createTestPaymentTerm,
|
createTestPaymentTerm,
|
||||||
|
grantTestPermissions,
|
||||||
loginByApi,
|
loginByApi,
|
||||||
} from "./http-test-fixtures";
|
} from "./http-test-fixtures";
|
||||||
import { createHttpTestRuntime } from "./http-test-runtime";
|
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`,
|
email: `company-audit-${randomUUID()}@example.test`,
|
||||||
password: "Password123!",
|
password: "Password123!",
|
||||||
});
|
});
|
||||||
|
await runtime.database
|
||||||
|
.getQueryInterface()
|
||||||
|
.bulkInsert("identity_system_owner", [{ id: 1, account_id: account.id }]);
|
||||||
const loginResponse = await loginByApi({
|
const loginResponse = await loginByApi({
|
||||||
request: runtime.request,
|
request: runtime.request,
|
||||||
email: account.email,
|
email: account.email,
|
||||||
@ -76,10 +80,16 @@ test("companies registra create, updates, no-ops y rollback con MariaDB", async
|
|||||||
legalName: "Original company",
|
legalName: "Original company",
|
||||||
email: "old@example.test",
|
email: "old@example.test",
|
||||||
});
|
});
|
||||||
await createTestMembership(runtime.database, {
|
const membership = await createTestMembership(runtime.database, {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
});
|
});
|
||||||
|
await grantTestPermissions(runtime.database, {
|
||||||
|
membershipId: membership.id,
|
||||||
|
companyId: company.id,
|
||||||
|
permissionCodes: ["identity:companies:update"],
|
||||||
|
});
|
||||||
|
|
||||||
const paymentTerm = await createTestPaymentTerm(runtime.database, {
|
const paymentTerm = await createTestPaymentTerm(runtime.database, {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
name: "30 days",
|
name: "30 days",
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@ -269,3 +269,31 @@ export function buildCompanyHeaders(params: {
|
|||||||
export function getQueryInterface(database: Sequelize): QueryInterface {
|
export function getQueryInterface(database: Sequelize): QueryInterface {
|
||||||
return database.getQueryInterface();
|
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 { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { ICompanyPublicServices } from "@erp/companies/api";
|
||||||
import {
|
import {
|
||||||
AsyncLocalStorageOperationContextStore,
|
AsyncLocalStorageOperationContextStore,
|
||||||
type IModuleServer,
|
type IModuleServer,
|
||||||
@ -127,6 +128,11 @@ export async function createHttpTestRuntime(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await startModules({
|
await startModules({
|
||||||
|
tenantCompanyFinder: getServiceScoped<ICompanyPublicServices>(
|
||||||
|
"server-infrastructure",
|
||||||
|
["companies"],
|
||||||
|
"companies:general"
|
||||||
|
).finder,
|
||||||
app,
|
app,
|
||||||
config,
|
config,
|
||||||
database,
|
database,
|
||||||
@ -214,6 +220,17 @@ function loadTestEnvironment(): void {
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
throw new Error(`Unable to load test environment file: ${TEST_ENV_FILE}`);
|
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 {
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import DineroFactory from "dinero.js";
|
import DineroFactory from "dinero.js";
|
||||||
import { Suspense } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
@ -23,18 +23,22 @@ import { getAppRouter } from "./routes";
|
|||||||
export const App = () => {
|
export const App = () => {
|
||||||
DineroFactory.globalLocale = "es-ES";
|
DineroFactory.globalLocale = "es-ES";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time
|
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);
|
console.log("import.meta.env.VITE_API_SERVER_URL => ", import.meta.env.VITE_API_SERVER_URL);
|
||||||
|
|
||||||
const axiosInstance = createAxiosInstance({
|
const [axiosInstance] = useState(() =>
|
||||||
|
createAxiosInstance({
|
||||||
baseURL: import.meta.env.VITE_API_SERVER_URL,
|
baseURL: import.meta.env.VITE_API_SERVER_URL,
|
||||||
getAccessToken: () => authTokenStorage.getAccessToken(),
|
getAccessToken: () => authTokenStorage.getAccessToken(),
|
||||||
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
|
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
|
||||||
@ -46,10 +50,11 @@ export const App = () => {
|
|||||||
window.location.replace("/login");
|
window.location.replace("/login");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const dataSource = createAxiosDataSource(axiosInstance);
|
const [dataSource] = useState(() => createAxiosDataSource(axiosInstance));
|
||||||
const appRouter = getAppRouter();
|
const [appRouter] = useState(() => getAppRouter());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n}>
|
<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 { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { getHomeCustomers } from "../api/home.api";
|
import { getHomeCustomers } from "../api/home.api";
|
||||||
|
|
||||||
export const useHomeCustomersTotalQuery = () => {
|
export const useHomeCustomersTotalQuery = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["home", "customers", "total"],
|
queryKey: companyQueryKey(queryScope, ["home", "customers", "total"]),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const response = await getHomeCustomers(dataSource, {
|
const response = await getHomeCustomers(dataSource, {
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { MoneyDTOHelper } from "@erp/core";
|
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 { MoneyHelper } from "@repo/rdx-utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { endOfMonth, format, startOfMonth } from "date-fns";
|
import { endOfMonth, format, startOfMonth } from "date-fns";
|
||||||
@ -10,12 +11,19 @@ import type { HomeMonthlyBilling } from "../entities/home.entity";
|
|||||||
const MONTHLY_BILLING_PAGE_SIZE = 100;
|
const MONTHLY_BILLING_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export const useHomeMonthlyBillingQuery = () => {
|
export const useHomeMonthlyBillingQuery = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd");
|
const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd");
|
||||||
const monthEnd = format(endOfMonth(new Date()), "yyyy-MM-dd");
|
const monthEnd = format(endOfMonth(new Date()), "yyyy-MM-dd");
|
||||||
|
|
||||||
return useQuery<HomeMonthlyBilling>({
|
return useQuery<HomeMonthlyBilling>({
|
||||||
queryKey: ["home", "issued-invoices", "monthly-billing", monthStart, monthEnd],
|
queryKey: companyQueryKey(queryScope, [
|
||||||
|
"home",
|
||||||
|
"issued-invoices",
|
||||||
|
"monthly-billing",
|
||||||
|
monthStart,
|
||||||
|
monthEnd,
|
||||||
|
]),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const response = await getHomeIssuedInvoices(dataSource, {
|
const response = await getHomeIssuedInvoices(dataSource, {
|
||||||
signal,
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { getHomeProformas } from "../api/home.api";
|
import { getHomeProformas } from "../api/home.api";
|
||||||
@ -18,10 +19,11 @@ export interface HomeProformasOverview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useHomeProformasOverviewQuery = () => {
|
export const useHomeProformasOverviewQuery = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
return useQuery<HomeProformasOverview>({
|
return useQuery<HomeProformasOverview>({
|
||||||
queryKey: ["home", "proformas", "overview"],
|
queryKey: companyQueryKey(queryScope, ["home", "proformas", "overview"]),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const response = await getHomeProformas(dataSource, {
|
const response = await getHomeProformas(dataSource, {
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { MoneyDTOHelper } from "@erp/core";
|
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 { useQueries } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { getHomeIssuedInvoices, getHomeProformas } from "../api/home.api";
|
import { getHomeIssuedInvoices, getHomeProformas } from "../api/home.api";
|
||||||
@ -8,12 +9,13 @@ import type { HomeRecentActivityItem } from "../entities/home.entity";
|
|||||||
const RECENT_ITEMS_LIMIT = 5;
|
const RECENT_ITEMS_LIMIT = 5;
|
||||||
|
|
||||||
export const useHomeRecentActivityQuery = () => {
|
export const useHomeRecentActivityQuery = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
const [recentProformasQuery, recentIssuedInvoicesQuery] = useQueries({
|
const [recentProformasQuery, recentIssuedInvoicesQuery] = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
queryKey: ["home", "proformas", "recent"],
|
queryKey: companyQueryKey(queryScope, ["home", "proformas", "recent"]),
|
||||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
const response = await getHomeProformas(dataSource, {
|
const response = await getHomeProformas(dataSource, {
|
||||||
signal,
|
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 }) => {
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
const response = await getHomeIssuedInvoices(dataSource, {
|
const response = await getHomeIssuedInvoices(dataSource, {
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
|||||||
isChangingCompany && "cursor-wait opacity-80",
|
isChangingCompany && "cursor-wait opacity-80",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
)}
|
)}
|
||||||
|
data-testid="company-switcher-trigger"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@ -72,9 +73,10 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-3 py-2"
|
className="gap-3 py-2"
|
||||||
|
data-testid={`company-option-${company.id}`}
|
||||||
disabled={company.isSelected || isChangingCompany}
|
disabled={company.isSelected || isChangingCompany}
|
||||||
key={company.id}
|
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">
|
<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}
|
{company.initials}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
|
||||||
|
|
||||||
import { AppMain } from "./app-main";
|
import { AppMain } from "./app-main";
|
||||||
|
import { CompanyPageOutlet } from "./company-page-outlet";
|
||||||
|
|
||||||
interface AppFullscreenLayoutFrameProps {
|
interface AppFullscreenLayoutFrameProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -18,7 +18,7 @@ export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameP
|
|||||||
export const AppFullscreenLayout = () => {
|
export const AppFullscreenLayout = () => {
|
||||||
return (
|
return (
|
||||||
<AppFullscreenLayoutFrame>
|
<AppFullscreenLayoutFrame>
|
||||||
<Outlet />
|
<CompanyPageOutlet />
|
||||||
</AppFullscreenLayoutFrame>
|
</AppFullscreenLayoutFrame>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { SidebarInset, SidebarProvider } from "@repo/shadcn-ui/components";
|
import { SidebarInset, SidebarProvider } from "@repo/shadcn-ui/components";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
|
||||||
|
|
||||||
import { AppMain } from "./app-main";
|
import { AppMain } from "./app-main";
|
||||||
import { AppSidebar } from "./app-sidebar";
|
import { AppSidebar } from "./app-sidebar";
|
||||||
import { AppTopbar } from "./app-topbar";
|
import { AppTopbar } from "./app-topbar";
|
||||||
|
import { CompanyPageOutlet } from "./company-page-outlet";
|
||||||
|
|
||||||
interface AppSidebarLayoutFrameProps {
|
interface AppSidebarLayoutFrameProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -43,7 +43,7 @@ export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps)
|
|||||||
export const AppSidebarLayout = () => {
|
export const AppSidebarLayout = () => {
|
||||||
return (
|
return (
|
||||||
<AppSidebarLayoutFrame>
|
<AppSidebarLayoutFrame>
|
||||||
<Outlet />
|
<CompanyPageOutlet />
|
||||||
</AppSidebarLayoutFrame>
|
</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 { useIdentityAuthSession } from "@erp/identity/client";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
function resolveCompanyDisplayName(legalName: string, tradeName: string | null) {
|
function resolveCompanyDisplayName(legalName: string, tradeName: string | null) {
|
||||||
@ -37,7 +36,6 @@ function resolveErrorMessage(error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAppCompanySwitcherController() {
|
export function useAppCompanySwitcherController() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { activeCompany, availableCompanies, changeActiveCompany } = useIdentityAuthSession();
|
const { activeCompany, availableCompanies, changeActiveCompany } = useIdentityAuthSession();
|
||||||
const [isChangingCompany, setIsChangingCompany] = useState(false);
|
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) {
|
if (isChangingCompany || companyId === activeCompany?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -82,21 +80,21 @@ export function useAppCompanySwitcherController() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await queryClient.cancelQueries();
|
|
||||||
queryClient.removeQueries({ type: "inactive" });
|
|
||||||
await queryClient.resetQueries({ type: "active" });
|
|
||||||
|
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
"Active company updated",
|
"Active company updated",
|
||||||
`${resolveCompanyDisplayName(nextActiveCompany.legalName, nextActiveCompany.tradeName)} is now active.`
|
`${resolveCompanyDisplayName(nextActiveCompany.legalName, nextActiveCompany.tradeName)} is now active.`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
showErrorToast("Unable to switch company", resolveErrorMessage(error));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsChangingCompany(false);
|
setIsChangingCompany(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectCompany = (companyId: string): void => {
|
||||||
|
selectCompany(companyId).catch((error: unknown) => {
|
||||||
|
showErrorToast("Unable to switch company", resolveErrorMessage(error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeCompany: activeCompanyView,
|
activeCompany: activeCompanyView,
|
||||||
companies: companyOptions,
|
companies: companyOptions,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useIdentityAuthSession } from "@erp/identity/client";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -7,27 +8,31 @@ import {
|
|||||||
getInitialOnboardingStatus,
|
getInitialOnboardingStatus,
|
||||||
provisionInitialOnboarding,
|
provisionInitialOnboarding,
|
||||||
} from "../api/initial-onboarding.api";
|
} 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 = () => {
|
export const useInitialOnboardingStatus = () => {
|
||||||
|
const { querySessionId } = useIdentityAuthSession();
|
||||||
const ds = useDataSource();
|
const ds = useDataSource();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: initialOnboardingStatusKey,
|
queryKey: initialOnboardingStatusKey(querySessionId),
|
||||||
queryFn: ({ signal }) => getInitialOnboardingStatus(ds, signal),
|
queryFn: ({ signal }) => getInitialOnboardingStatus(ds, signal),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const useProvisionInitialOnboarding = () => {
|
export const useProvisionInitialOnboarding = () => {
|
||||||
|
const { querySessionId } = useIdentityAuthSession();
|
||||||
const ds = useDataSource();
|
const ds = useDataSource();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => provisionInitialOnboarding(ds),
|
mutationFn: () => provisionInitialOnboarding(ds),
|
||||||
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v),
|
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey(querySessionId), v),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const useCompleteInitialOnboarding = () => {
|
export const useCompleteInitialOnboarding = () => {
|
||||||
|
const { querySessionId } = useIdentityAuthSession();
|
||||||
const ds = useDataSource();
|
const ds = useDataSource();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (v: InitialCompanyInput) => completeInitialOnboarding(ds, v),
|
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)
|
- [DTOs y schemas](./dto-guidelines.md)
|
||||||
- [Límites entre módulos](./module-boundaries.md)
|
- [Límites entre módulos](./module-boundaries.md)
|
||||||
- [Pruebas backend](./testing-guidelines.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
|
- sin helpers locales de auth
|
||||||
- usan el middleware tenant canónico documentado en `tenant-context.md`
|
- 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:
|
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
|
||||||
```http
|
sustituida en las operaciones Companies allí enumeradas. Los demás módulos conservan sus políticas
|
||||||
POST /companies
|
hasta una adopción explícita del guard; no se afirma cobertura granular universal.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Frontend actual
|
## 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.
|
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
|
## Búsquedas útiles de control
|
||||||
|
|
||||||
|
|||||||
@ -47,18 +47,19 @@ Actualmente ya valida:
|
|||||||
2. account autenticable
|
2. account autenticable
|
||||||
3. `X-Company-Id` presente
|
3. `X-Company-Id` presente
|
||||||
4. `X-Company-Id` con formato UUID válido
|
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`
|
6. company `active` en `companies`
|
||||||
7. solo entonces rellena `req.user.companyId`
|
7. solo entonces rellena `req.user.companyId`
|
||||||
|
|
||||||
Esto significa que hoy el contexto tenant garantiza:
|
Esto significa que hoy el contexto tenant garantiza:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
membership activa
|
(membership activa o Super-administrador activo)
|
||||||
+ company activa
|
+ 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
|
## Semántica esperada de errores
|
||||||
|
|
||||||
@ -70,8 +71,8 @@ sin X-Company-Id -> 403
|
|||||||
X-Company-Id inválido -> 400
|
X-Company-Id inválido -> 400
|
||||||
company inexistente -> 403
|
company inexistente -> 403
|
||||||
company disabled -> 403
|
company disabled -> 403
|
||||||
membership inexistente -> 403
|
membership inexistente en una cuenta ordinaria -> 403
|
||||||
membership disabled/invited -> 403
|
membership disabled/invited en una cuenta ordinaria -> 403
|
||||||
membership active + company active -> entra al controller
|
membership active + company active -> entra al controller
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -103,7 +104,7 @@ Actualmente usan `requireIdentityTenant(params)`:
|
|||||||
- `modules/customer-invoices`
|
- `modules/customer-invoices`
|
||||||
- `modules/factuges`
|
- `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
|
## Qué no debe ir en el token
|
||||||
|
|
||||||
@ -127,16 +128,22 @@ GET /companies/current
|
|||||||
PATCH /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
|
La edición `PATCH /companies/current` y `PATCH /companies/:company_id`, así como
|
||||||
usuario autenticado
|
`POST /companies/:company_id/enable` y `/disable`, requieren además
|
||||||
+ X-Company-Id válido
|
`identity:companies:update`. Antes bastaba el contexto empresarial.
|
||||||
+ membership activa en esa company
|
|
||||||
+ company activa
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## Document context
|
||||||
|
|
||||||
@ -145,3 +152,71 @@ Si un flujo documental necesita `companySlug`, debe resolverlo desde `companies`
|
|||||||
```txt
|
```txt
|
||||||
companyId -> companies:general.finder.findById(...) -> slug
|
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.
|
- No introducir acceso a red en UI ni reglas de negocio en Pages.
|
||||||
- Mantener routing, sesión, tenant y sistema visual como responsabilidades separadas.
|
- 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.
|
- 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 { useIdentityAuthSession } from "@erp/identity/client";
|
||||||
import { type DefaultError, useQuery } from "@tanstack/react-query";
|
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";
|
import { auditQueryKeys } from "./audit-query-keys";
|
||||||
|
|
||||||
export const useAuditEntry = (id: string | null) => {
|
export const useAuditEntry = (id: string | null) => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const { activeCompany } = useIdentityAuthSession();
|
const { activeCompany } = useIdentityAuthSession();
|
||||||
const companyId = activeCompany?.id ?? "none";
|
const companyId = activeCompany?.id ?? "none";
|
||||||
|
|
||||||
return useQuery<AuditEntryDetail, DefaultError>({
|
return useQuery<AuditEntryDetail, DefaultError>({
|
||||||
queryKey: auditQueryKeys.detail(companyId, id ?? "none"),
|
queryKey: companyQueryKey(queryScope, auditQueryKeys.detail(companyId, id ?? "none")),
|
||||||
queryFn: ({ signal }) => {
|
queryFn: ({ signal }) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("auditEntryId is required");
|
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 { useIdentityAuthSession } from "@erp/identity/client";
|
||||||
import { type DefaultError, useQuery } from "@tanstack/react-query";
|
import { type DefaultError, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -17,18 +18,23 @@ export const useAuditList = (
|
|||||||
params: AuditSearchParams | null,
|
params: AuditSearchParams | null,
|
||||||
options: UseAuditListOptions = {}
|
options: UseAuditListOptions = {}
|
||||||
) => {
|
) => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const { activeCompany } = useIdentityAuthSession();
|
const { activeCompany } = useIdentityAuthSession();
|
||||||
const companyId = activeCompany?.id ?? "none";
|
const companyId = activeCompany?.id ?? "none";
|
||||||
|
|
||||||
return useQuery<AuditList, DefaultError>({
|
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 }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const search = options.getSearchParams?.() ?? params ?? { pageNumber: 0, pageSize: 50 };
|
const search = options.getSearchParams?.() ?? params ?? { pageNumber: 0, pageSize: 50 };
|
||||||
return mapAuditList(await searchAudit(dataSource, { search, signal }));
|
return mapAuditList(await searchAudit(dataSource, { search, signal }));
|
||||||
},
|
},
|
||||||
enabled: activeCompany !== null && params !== null,
|
enabled: activeCompany !== null && params !== null,
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData, previousQuery) =>
|
||||||
|
keepPreviousCompanyData(queryScope, previousData, previousQuery),
|
||||||
refetchInterval: options.refetchInterval,
|
refetchInterval: options.refetchInterval,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import type { PaymentMethodSummary } from "../models";
|
|||||||
|
|
||||||
export interface IPaymentMethodRepository {
|
export interface IPaymentMethodRepository {
|
||||||
create(paymentMethod: PaymentMethod, transaction?: unknown): Promise<Result<void, Error>>;
|
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(
|
existsByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
|
|||||||
@ -42,7 +42,7 @@ class InMemoryPaymentMethodRepository implements IPaymentMethodRepository {
|
|||||||
return Result.ok();
|
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);
|
this.items.set(paymentMethod.id.toString(), paymentMethod);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,11 @@ export class PaymentMethodDisabler implements IPaymentMethodDisabler {
|
|||||||
return Result.ok(paymentMethod);
|
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) {
|
if (persistenceResult.isFailure) {
|
||||||
return Result.fail(persistenceResult.error);
|
return Result.fail(persistenceResult.error);
|
||||||
|
|||||||
@ -29,7 +29,11 @@ export class PaymentMethodEnabler implements IPaymentMethodEnabler {
|
|||||||
return Result.ok(paymentMethod);
|
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) {
|
if (persistenceResult.isFailure) {
|
||||||
return Result.fail(persistenceResult.error);
|
return Result.fail(persistenceResult.error);
|
||||||
|
|||||||
@ -43,7 +43,11 @@ export class PaymentMethodStatusChanger implements IPaymentMethodStatusChanger {
|
|||||||
return Result.ok(paymentMethod);
|
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) {
|
if (persistenceResult.isFailure) {
|
||||||
return Result.fail(persistenceResult.error);
|
return Result.fail(persistenceResult.error);
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export class PaymentMethodUpdater implements IPaymentMethodUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persistir cambios
|
// Persistir cambios
|
||||||
const saveResult = await this.repository.update(paymentMethod, transaction);
|
const saveResult = await this.repository.update(companyId, paymentMethod, transaction);
|
||||||
|
|
||||||
if (saveResult.isFailure) {
|
if (saveResult.isFailure) {
|
||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
|
|||||||
@ -72,7 +72,11 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (unmarkResult.data) {
|
if (unmarkResult.data) {
|
||||||
const saveCurrentResult = await this.repository.update(currentDefault, transaction);
|
const saveCurrentResult = await this.repository.update(
|
||||||
|
companyId,
|
||||||
|
currentDefault,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
if (saveCurrentResult.isFailure) {
|
if (saveCurrentResult.isFailure) {
|
||||||
return Result.fail(saveCurrentResult.error);
|
return Result.fail(saveCurrentResult.error);
|
||||||
}
|
}
|
||||||
@ -95,7 +99,11 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (markResult.data) {
|
if (markResult.data) {
|
||||||
const savePaymentMethodResult = await this.repository.update(paymentMethod, transaction);
|
const savePaymentMethodResult = await this.repository.update(
|
||||||
|
companyId,
|
||||||
|
paymentMethod,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
if (savePaymentMethodResult.isFailure) {
|
if (savePaymentMethodResult.isFailure) {
|
||||||
return Result.fail(savePaymentMethodResult.error);
|
return Result.fail(savePaymentMethodResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,10 @@ import {
|
|||||||
import type { TaxDefinitionSummary } from "../models";
|
import type { TaxDefinitionSummary } from "../models";
|
||||||
import type { ITaxDefinitionRepository } from "../repositories";
|
import type { ITaxDefinitionRepository } from "../repositories";
|
||||||
|
|
||||||
const valueOf = <T>(result: Result<T, Error>): T => {
|
const unwrapResult = <T>(result: Result<T, Error>): T => {
|
||||||
if (result.isFailure) throw result.error;
|
if (result.isFailure) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -30,32 +32,32 @@ export const buildTaxDefinition = (params?: {
|
|||||||
const companyId = params?.companyId ?? UniqueID.generateNewID();
|
const companyId = params?.companyId ?? UniqueID.generateNewID();
|
||||||
const allowedSurchargeCodes = params?.allowedSurchargeCodes;
|
const allowedSurchargeCodes = params?.allowedSurchargeCodes;
|
||||||
|
|
||||||
return valueOf(
|
return unwrapResult(
|
||||||
TaxDefinition.create(
|
TaxDefinition.create(
|
||||||
{
|
{
|
||||||
companyId,
|
companyId,
|
||||||
code: valueOf(TaxDefinitionCode.create("iva_21")),
|
code: unwrapResult(TaxDefinitionCode.create("iva_21")),
|
||||||
name: valueOf(TaxDefinitionName.create("IVA general")),
|
name: unwrapResult(TaxDefinitionName.create("IVA general")),
|
||||||
description: Maybe.some(valueOf(TextValue.create("IVA aplicable"))),
|
description: Maybe.some(unwrapResult(TextValue.create("IVA aplicable"))),
|
||||||
rate: valueOf(TaxRate.create({ value: 2100, scale: 2 })),
|
rate: unwrapResult(TaxRate.create({ value: 2100, scale: 2 })),
|
||||||
taxFamily: valueOf(TaxFamily.create("iva")),
|
taxFamily: unwrapResult(TaxFamily.create("iva")),
|
||||||
calculationBehavior: valueOf(TaxCalculationBehavior.create("additive")),
|
calculationBehavior: unwrapResult(TaxCalculationBehavior.create("additive")),
|
||||||
jurisdictionCountryCode: valueOf(CountryCode.create("ES")),
|
jurisdictionCountryCode: unwrapResult(CountryCode.create("ES")),
|
||||||
jurisdictionRegionCode: Maybe.some(valueOf(CountryRegionCode.create("ES-M"))),
|
jurisdictionRegionCode: Maybe.some(unwrapResult(CountryRegionCode.create("ES-M"))),
|
||||||
taxScope: valueOf(TaxScope.create("domestic")),
|
taxScope: unwrapResult(TaxScope.create("domestic")),
|
||||||
invoiceNote: Maybe.some(valueOf(TextValue.create("Factura con IVA"))),
|
invoiceNote: Maybe.some(unwrapResult(TextValue.create("Factura con IVA"))),
|
||||||
allowedSurchargeCodes:
|
allowedSurchargeCodes:
|
||||||
allowedSurchargeCodes === null
|
allowedSurchargeCodes === null
|
||||||
? Maybe.none()
|
? Maybe.none()
|
||||||
: Maybe.some(
|
: Maybe.some(
|
||||||
(allowedSurchargeCodes ?? ["rec_5_2", "rec_1_4"]).map((code) =>
|
(allowedSurchargeCodes ?? ["rec_5_2", "rec_1_4"]).map((code) =>
|
||||||
valueOf(TaxDefinitionCode.create(code))
|
unwrapResult(TaxDefinitionCode.create(code))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
isSystem: params?.isSystem ?? false,
|
isSystem: params?.isSystem ?? false,
|
||||||
isActive: params?.isActive ?? true,
|
isActive: params?.isActive ?? true,
|
||||||
validFrom: Maybe.some(valueOf(UtcDate.createFromISO("2026-01-01"))),
|
validFrom: Maybe.some(unwrapResult(UtcDate.createFromISO("2026-01-01"))),
|
||||||
validTo: Maybe.some(valueOf(UtcDate.createFromISO("2026-12-31"))),
|
validTo: Maybe.some(unwrapResult(UtcDate.createFromISO("2026-12-31"))),
|
||||||
},
|
},
|
||||||
params?.id ?? UniqueID.generateNewID()
|
params?.id ?? UniqueID.generateNewID()
|
||||||
)
|
)
|
||||||
@ -73,8 +75,11 @@ export class InMemoryTaxDefinitionRepository implements ITaxDefinitionRepository
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(taxDefinition: TaxDefinition): Promise<Result<void, Error>> {
|
async update(companyId: UniqueID, taxDefinition: TaxDefinition): Promise<Result<void, Error>> {
|
||||||
this.updateCalls += 1;
|
this.updateCalls += 1;
|
||||||
|
if (!taxDefinition.companyId.equals(companyId)) {
|
||||||
|
return Result.fail(new Error("Not found"));
|
||||||
|
}
|
||||||
this.items.set(taxDefinition.id.toString(), taxDefinition);
|
this.items.set(taxDefinition.id.toString(), taxDefinition);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
@ -82,7 +87,9 @@ export class InMemoryTaxDefinitionRepository implements ITaxDefinitionRepository
|
|||||||
async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<boolean, Error>> {
|
async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<boolean, Error>> {
|
||||||
this.deleteCalls += 1;
|
this.deleteCalls += 1;
|
||||||
const found = await this.getByIdInCompany(companyId, id);
|
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());
|
this.deleted.add(id.toString());
|
||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import type { TaxDefinitionSummary } from "../../tax-definitions/models";
|
|||||||
|
|
||||||
export interface ITaxDefinitionRepository {
|
export interface ITaxDefinitionRepository {
|
||||||
create(taxDefinition: TaxDefinition, transaction?: unknown): Promise<Result<void, Error>>;
|
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(
|
deleteByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { ITaxDefinitionRepository } from "../repositories";
|
|
||||||
import type { TaxDefinition } from "../../../domain";
|
import type { TaxDefinition } from "../../../domain";
|
||||||
|
import type { ITaxDefinitionRepository } from "../repositories";
|
||||||
|
|
||||||
export interface ITaxDefinitionStatusChanger {
|
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(
|
disable(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
@ -29,7 +33,7 @@ export class TaxDefinitionStatusChanger implements ITaxDefinitionStatusChanger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changeResult.data) {
|
if (changeResult.data) {
|
||||||
const saveResult = await this.repository.update(taxDefinition, transaction);
|
const saveResult = await this.repository.update(companyId, taxDefinition, transaction);
|
||||||
if (saveResult.isFailure) {
|
if (saveResult.isFailure) {
|
||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
@ -51,7 +55,7 @@ export class TaxDefinitionStatusChanger implements ITaxDefinitionStatusChanger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changeResult.data) {
|
if (changeResult.data) {
|
||||||
const saveResult = await this.repository.update(taxDefinition, transaction);
|
const saveResult = await this.repository.update(companyId, taxDefinition, transaction);
|
||||||
if (saveResult.isFailure) {
|
if (saveResult.isFailure) {
|
||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export class TaxDefinitionUpdater implements ITaxDefinitionUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updateResult.data) {
|
if (updateResult.data) {
|
||||||
const saveResult = await this.repository.update(taxDefinition, transaction);
|
const saveResult = await this.repository.update(companyId, taxDefinition, transaction);
|
||||||
if (saveResult.isFailure) {
|
if (saveResult.isFailure) {
|
||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import type { TaxRegimeSummary } from "../models";
|
|||||||
|
|
||||||
export interface ITaxRegimeRepository {
|
export interface ITaxRegimeRepository {
|
||||||
create(taxRegime: TaxRegime, transaction?: unknown): Promise<Result<void, Error>>;
|
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(
|
deleteByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
|
|||||||
@ -32,7 +32,11 @@ export class TaxRegimeStatusChanger implements ITaxRegimeStatusChanger {
|
|||||||
return Result.ok(taxRegime);
|
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) {
|
if (persistenceResult.isFailure) {
|
||||||
return Result.fail(persistenceResult.error);
|
return Result.fail(persistenceResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class TaxRegimeUpdater implements ITaxRegimeUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persistir cambios
|
// Persistir cambios
|
||||||
const saveResult = await this.repository.update(taxRegime, transaction);
|
const saveResult = await this.repository.update(companyId, taxRegime, transaction);
|
||||||
if (saveResult.isFailure) {
|
if (saveResult.isFailure) {
|
||||||
return Result.fail(saveResult.error);
|
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 {
|
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||||
EntityNotFoundError,
|
|
||||||
InfrastructureRepositoryError,
|
|
||||||
SequelizeRepository,
|
|
||||||
translateSequelizeError,
|
|
||||||
} from "@erp/core/api";
|
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
|
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
@ -61,6 +56,7 @@ export class SequelizePaymentMethodRepository
|
|||||||
* @returns Result<void, Error>
|
* @returns Result<void, Error>
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
|
companyId: UniqueID,
|
||||||
paymentMethod: PaymentMethod,
|
paymentMethod: PaymentMethod,
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
): Promise<Result<void, Error>> {
|
): Promise<Result<void, Error>> {
|
||||||
@ -70,17 +66,26 @@ export class SequelizePaymentMethodRepository
|
|||||||
return Result.fail(dtoResult.error);
|
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, {
|
const [affected] = await PaymentMethodModel.update(payload, {
|
||||||
where: { id },
|
where: { id, company_id: companyId.toPrimitive() },
|
||||||
transaction,
|
transaction,
|
||||||
individualHooks: true,
|
individualHooks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (affected === 0) {
|
if (affected === 0) {
|
||||||
return Result.fail(
|
const exists = await PaymentMethodModel.findOne({
|
||||||
new InfrastructureRepositoryError("Concurrency conflict or payment method not found")
|
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();
|
return Result.ok();
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||||
EntityNotFoundError,
|
|
||||||
InfrastructureRepositoryError,
|
|
||||||
SequelizeRepository,
|
|
||||||
translateSequelizeError,
|
|
||||||
} from "@erp/core/api";
|
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||||
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
@ -47,6 +42,7 @@ export class SequelizeTaxDefinitionRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
|
companyId: UniqueID,
|
||||||
taxDefinition: TaxDefinition,
|
taxDefinition: TaxDefinition,
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
): Promise<Result<void, Error>> {
|
): Promise<Result<void, Error>> {
|
||||||
@ -56,17 +52,26 @@ export class SequelizeTaxDefinitionRepository
|
|||||||
return Result.fail(dtoResult.error);
|
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, {
|
const [affected] = await TaxDefinitionModel.update(payload, {
|
||||||
where: { id },
|
where: { id, company_id: companyId.toPrimitive() },
|
||||||
transaction,
|
transaction,
|
||||||
individualHooks: true,
|
individualHooks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (affected === 0) {
|
if (affected === 0) {
|
||||||
return Result.fail(
|
const exists = await TaxDefinitionModel.findOne({
|
||||||
new InfrastructureRepositoryError("Concurrency conflict or tax definition not found")
|
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();
|
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 {
|
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||||
EntityNotFoundError,
|
|
||||||
InfrastructureRepositoryError,
|
|
||||||
SequelizeRepository,
|
|
||||||
translateSequelizeError,
|
|
||||||
} from "@erp/core/api";
|
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
@ -55,24 +50,37 @@ export class SequelizeTaxRegimeRepository
|
|||||||
* @param transaction - Transacción activa para la operación.
|
* @param transaction - Transacción activa para la operación.
|
||||||
* @returns Result<void, Error>
|
* @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 {
|
try {
|
||||||
const dtoResult = this.domainMapper.mapToPersistence(taxRegime);
|
const dtoResult = this.domainMapper.mapToPersistence(taxRegime);
|
||||||
if (dtoResult.isFailure) {
|
if (dtoResult.isFailure) {
|
||||||
return Result.fail(dtoResult.error);
|
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, {
|
const [affected] = await TaxRegimeModel.update(payload, {
|
||||||
where: { id },
|
where: { id, company_id: companyId.toPrimitive() },
|
||||||
transaction,
|
transaction,
|
||||||
individualHooks: true,
|
individualHooks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (affected === 0) {
|
if (affected === 0) {
|
||||||
return Result.fail(
|
const exists = await TaxRegimeModel.findOne({
|
||||||
new InfrastructureRepositoryError("Concurrency conflict or payment method not found")
|
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();
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const usePaymentMethodCreateMutation = () => {
|
export const usePaymentMethodCreateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -35,9 +37,12 @@ export const usePaymentMethodCreateMutation = () => {
|
|||||||
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentMethod) => {
|
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({
|
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 DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { type DeletePaymentMethodParams, deletePaymentMethod } from "../api";
|
import { type DeletePaymentMethodParams, deletePaymentMethod } from "../api";
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentMethodDeleteMutation = () => {
|
export const usePaymentMethodDeleteMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -20,8 +22,12 @@ export const usePaymentMethodDeleteMutation = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }),
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX }),
|
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 { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
||||||
@ -11,11 +12,12 @@ export const usePaymentMethodDetailQuery = (
|
|||||||
paymentMethodId?: string,
|
paymentMethodId?: string,
|
||||||
options?: { enabled?: boolean }
|
options?: { enabled?: boolean }
|
||||||
): UseQueryResult<PaymentMethod, DefaultError> => {
|
): UseQueryResult<PaymentMethod, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId);
|
const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId);
|
||||||
|
|
||||||
return useQuery<PaymentMethod, DefaultError>({
|
return useQuery<PaymentMethod, DefaultError>({
|
||||||
queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
|
queryKey: companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethodId)),
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getPaymentMethodById(dataSource, {
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentMethodDisableMutation = () => {
|
export const usePaymentMethodDisableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,8 +24,13 @@ export const usePaymentMethodDisableMutation = () => {
|
|||||||
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentMethod) => {
|
onSuccess: async (paymentMethod) => {
|
||||||
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX });
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentMethodEnableMutation = () => {
|
export const usePaymentMethodEnableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,8 +24,13 @@ export const usePaymentMethodEnableMutation = () => {
|
|||||||
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentMethod) => {
|
onSuccess: async (paymentMethod) => {
|
||||||
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX });
|
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 type { CriteriaDTO } from "@erp/core";
|
||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
|
||||||
|
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
|
||||||
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ListPaymentMethodsAdapter } from "../adapters";
|
import { ListPaymentMethodsAdapter } from "../adapters";
|
||||||
@ -16,17 +17,19 @@ export interface PaymentMethodsListQueryOptions {
|
|||||||
export const usePaymentMethodsListQuery = (
|
export const usePaymentMethodsListQuery = (
|
||||||
options?: PaymentMethodsListQueryOptions
|
options?: PaymentMethodsListQueryOptions
|
||||||
): UseQueryResult<PaymentMethodList, DefaultError> => {
|
): UseQueryResult<PaymentMethodList, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = options?.enabled ?? true;
|
||||||
const criteria = options?.criteria ?? {};
|
const criteria = options?.criteria ?? {};
|
||||||
|
|
||||||
return useQuery<PaymentMethodList, DefaultError>({
|
return useQuery<PaymentMethodList, DefaultError>({
|
||||||
queryKey: LIST_PAYMENT_METHODS_QUERY_KEY(criteria),
|
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY(criteria)),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getListPaymentMethodsByCriteria(dataSource, { signal, criteria });
|
const dto = await getListPaymentMethodsByCriteria(dataSource, { signal, criteria });
|
||||||
return ListPaymentMethodsAdapter.fromDto(dto);
|
return ListPaymentMethodsAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
enabled,
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
import { GetPaymentMethodByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentMethodSetDefaultMutation = () => {
|
export const usePaymentMethodSetDefaultMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,10 +24,17 @@ export const usePaymentMethodSetDefaultMutation = () => {
|
|||||||
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentMethod) => {
|
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([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }),
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethod.id) }),
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const usePaymentMethodUpdateMutation = () => {
|
export const usePaymentMethodUpdateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -35,9 +37,12 @@ export const usePaymentMethodUpdateMutation = () => {
|
|||||||
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
return GetPaymentMethodByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentMethod) => {
|
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({
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const usePaymentTermCreateMutation = () => {
|
export const usePaymentTermCreateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -35,9 +37,12 @@ export const usePaymentTermCreateMutation = () => {
|
|||||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentTerm) => {
|
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({
|
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 DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { type DeletePaymentTermParams, deletePaymentTerm } from "../api";
|
import { type DeletePaymentTermParams, deletePaymentTerm } from "../api";
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentTermDeleteMutation = () => {
|
export const usePaymentTermDeleteMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -20,8 +22,12 @@ export const usePaymentTermDeleteMutation = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }),
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX }),
|
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 { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||||
@ -11,11 +12,12 @@ export const usePaymentTermDetailQuery = (
|
|||||||
paymentTermId?: string,
|
paymentTermId?: string,
|
||||||
options?: { enabled?: boolean }
|
options?: { enabled?: boolean }
|
||||||
): UseQueryResult<PaymentTerm, DefaultError> => {
|
): UseQueryResult<PaymentTerm, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = (options?.enabled ?? true) && Boolean(paymentTermId);
|
const enabled = (options?.enabled ?? true) && Boolean(paymentTermId);
|
||||||
|
|
||||||
return useQuery<PaymentTerm, DefaultError>({
|
return useQuery<PaymentTerm, DefaultError>({
|
||||||
queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId),
|
queryKey: companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTermId)),
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getPaymentTermById(dataSource, {
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentTermDisableMutation = () => {
|
export const usePaymentTermDisableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,8 +24,13 @@ export const usePaymentTermDisableMutation = () => {
|
|||||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentTerm) => {
|
onSuccess: async (paymentTerm) => {
|
||||||
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetPaymentTermByIdAdapter } from "../adapters";
|
import { GetPaymentTermByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const usePaymentTermEnableMutation = () => {
|
export const usePaymentTermEnableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,8 +24,13 @@ export const usePaymentTermEnableMutation = () => {
|
|||||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentTerm) => {
|
onSuccess: async (paymentTerm) => {
|
||||||
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
|
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 type { CriteriaDTO } from "@erp/core";
|
||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
|
||||||
|
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
|
||||||
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ListPaymentTermsAdapter } from "../adapters";
|
import { ListPaymentTermsAdapter } from "../adapters";
|
||||||
@ -16,17 +17,19 @@ export interface PaymentTermsListQueryOptions {
|
|||||||
export const usePaymentTermListQuery = (
|
export const usePaymentTermListQuery = (
|
||||||
options?: PaymentTermsListQueryOptions
|
options?: PaymentTermsListQueryOptions
|
||||||
): UseQueryResult<PaymentTermList, DefaultError> => {
|
): UseQueryResult<PaymentTermList, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = options?.enabled ?? true;
|
||||||
const criteria = options?.criteria ?? {};
|
const criteria = options?.criteria ?? {};
|
||||||
|
|
||||||
return useQuery<PaymentTermList, DefaultError>({
|
return useQuery<PaymentTermList, DefaultError>({
|
||||||
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY(criteria),
|
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY(criteria)),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getListPaymentTermsByCriteria(dataSource, { signal, criteria });
|
const dto = await getListPaymentTermsByCriteria(dataSource, { signal, criteria });
|
||||||
return ListPaymentTermsAdapter.fromDto(dto);
|
return ListPaymentTermsAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
enabled,
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const usePaymentTermUpdateMutation = () => {
|
export const usePaymentTermUpdateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -35,9 +37,12 @@ export const usePaymentTermUpdateMutation = () => {
|
|||||||
return GetPaymentTermByIdAdapter.fromDto(dto);
|
return GetPaymentTermByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (paymentTerm) => {
|
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({
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { CreateTaxDefinitionRequestSchema } from "../../../../common";
|
import { CreateTaxDefinitionRequestSchema } from "../../../../common";
|
||||||
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
||||||
import { createTaxDefinition, type CreateTaxDefinitionParams } from "../api";
|
import { type CreateTaxDefinitionParams, createTaxDefinition } from "../api";
|
||||||
import type { TaxDefinition } from "../entities";
|
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";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const useTaxDefinitionCreateMutation = () => {
|
export const useTaxDefinitionCreateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
return useMutation<TaxDefinition, DefaultError, CreateTaxDefinitionParams>({
|
return useMutation<TaxDefinition, DefaultError, CreateTaxDefinitionParams>({
|
||||||
mutationKey: CREATE_TAX_DEFINITION_MUTATION_KEY,
|
mutationKey: CREATE_TAX_DEFINITION_MUTATION_KEY,
|
||||||
mutationFn: async (params) => {
|
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);
|
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));
|
return GetTaxDefinitionByIdAdapter.fromDto(await createTaxDefinition(dataSource, params));
|
||||||
},
|
},
|
||||||
onSuccess: async (taxDefinition) => {
|
onSuccess: async (taxDefinition) => {
|
||||||
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
|
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 { 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 = () => {
|
export const useTaxDefinitionDeleteMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
return useMutation<void, DefaultError, DeleteTaxDefinitionByIdParams>({
|
return useMutation<void, DefaultError, DeleteTaxDefinitionByIdParams>({
|
||||||
mutationKey: DELETE_TAX_DEFINITION_MUTATION_KEY,
|
mutationKey: DELETE_TAX_DEFINITION_MUTATION_KEY,
|
||||||
mutationFn: async (params) => { await deleteTaxDefinitionById(dataSource, params); },
|
mutationFn: async (params) => {
|
||||||
|
await deleteTaxDefinitionById(dataSource, params);
|
||||||
|
},
|
||||||
onSuccess: async (_result, params) => {
|
onSuccess: async (_result, params) => {
|
||||||
queryClient.removeQueries({ queryKey: TAX_DEFINITION_QUERY_KEY(params.taxDefinitionId) });
|
queryClient.removeQueries({
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
|
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 { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
||||||
import { getTaxDefinitionById } from "../api";
|
import { getTaxDefinitionById } from "../api";
|
||||||
import type { TaxDefinition } from "../entities";
|
import type { TaxDefinition } from "../entities";
|
||||||
|
|
||||||
import { TAX_DEFINITION_QUERY_KEY } from "./keys";
|
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 dataSource = useDataSource();
|
||||||
const enabled = (options?.enabled ?? true) && Boolean(taxDefinitionId);
|
const enabled = (options?.enabled ?? true) && Boolean(taxDefinitionId);
|
||||||
return useQuery<TaxDefinition, DefaultError>({
|
return useQuery<TaxDefinition, DefaultError>({
|
||||||
queryKey: TAX_DEFINITION_QUERY_KEY(taxDefinitionId),
|
queryKey: companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinitionId)),
|
||||||
enabled,
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
||||||
import { disableTaxDefinitionById, type DisableTaxDefinitionByIdParams } from "../api";
|
import { type DisableTaxDefinitionByIdParams, disableTaxDefinitionById } from "../api";
|
||||||
import type { TaxDefinition } from "../entities";
|
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 = () => {
|
export const useTaxDefinitionDisableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
return useMutation<TaxDefinition, DefaultError, DisableTaxDefinitionByIdParams>({
|
return useMutation<TaxDefinition, DefaultError, DisableTaxDefinitionByIdParams>({
|
||||||
mutationKey: DISABLE_TAX_DEFINITION_MUTATION_KEY,
|
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) => {
|
onSuccess: async (taxDefinition) => {
|
||||||
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
||||||
import { enableTaxDefinitionById, type EnableTaxDefinitionByIdParams } from "../api";
|
import { type EnableTaxDefinitionByIdParams, enableTaxDefinitionById } from "../api";
|
||||||
import type { TaxDefinition } from "../entities";
|
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 = () => {
|
export const useTaxDefinitionEnableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
return useMutation<TaxDefinition, DefaultError, EnableTaxDefinitionByIdParams>({
|
return useMutation<TaxDefinition, DefaultError, EnableTaxDefinitionByIdParams>({
|
||||||
mutationKey: ENABLE_TAX_DEFINITION_MUTATION_KEY,
|
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) => {
|
onSuccess: async (taxDefinition) => {
|
||||||
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
|
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 type { CriteriaDTO } from "@erp/core";
|
||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
|
||||||
|
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
|
||||||
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ListTaxDefinitionsAdapter } from "../adapters";
|
import { ListTaxDefinitionsAdapter } from "../adapters";
|
||||||
import { listTaxDefinitions } from "../api";
|
import { listTaxDefinitions } from "../api";
|
||||||
import type { TaxDefinitionList } from "../entities";
|
import type { TaxDefinitionList } from "../entities";
|
||||||
|
|
||||||
import { LIST_TAX_DEFINITIONS_QUERY_KEY } from "./keys";
|
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 dataSource = useDataSource();
|
||||||
const criteria = options?.criteria ?? {};
|
const criteria = options?.criteria ?? {};
|
||||||
return useQuery<TaxDefinitionList, DefaultError>({
|
return useQuery<TaxDefinitionList, DefaultError>({
|
||||||
queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY(criteria),
|
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY(criteria)),
|
||||||
enabled: options?.enabled ?? true,
|
enabled: options?.enabled ?? true,
|
||||||
queryFn: async ({ signal }) => ListTaxDefinitionsAdapter.fromDto(await listTaxDefinitions(dataSource, { signal, criteria })),
|
queryFn: async ({ signal }) =>
|
||||||
placeholderData: (previousData) => previousData,
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { UpdateTaxDefinitionByIdRequestSchema } from "../../../../common";
|
import { UpdateTaxDefinitionByIdRequestSchema } from "../../../../common";
|
||||||
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
import { GetTaxDefinitionByIdAdapter } from "../adapters";
|
||||||
import { type UpdateTaxDefinitionByIdParams, updateTaxDefinitionById } from "../api";
|
import { type UpdateTaxDefinitionByIdParams, updateTaxDefinitionById } from "../api";
|
||||||
import type { TaxDefinition } from "../entities";
|
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";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const useTaxDefinitionUpdateMutation = () => {
|
export const useTaxDefinitionUpdateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
return useMutation<TaxDefinition, DefaultError, UpdateTaxDefinitionByIdParams>({
|
return useMutation<TaxDefinition, DefaultError, UpdateTaxDefinitionByIdParams>({
|
||||||
mutationKey: UPDATE_TAX_DEFINITION_MUTATION_KEY,
|
mutationKey: UPDATE_TAX_DEFINITION_MUTATION_KEY,
|
||||||
mutationFn: async (params) => {
|
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);
|
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));
|
return GetTaxDefinitionByIdAdapter.fromDto(await updateTaxDefinitionById(dataSource, params));
|
||||||
},
|
},
|
||||||
onSuccess: async (taxDefinition) => {
|
onSuccess: async (taxDefinition) => {
|
||||||
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const useTaxRegimeCreateMutation = () => {
|
export const useTaxRegimeCreateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -35,9 +37,12 @@ export const useTaxRegimeCreateMutation = () => {
|
|||||||
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (taxRegime) => {
|
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({
|
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 DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { type DeleteTaxRegimeParams, deleteTaxRegime } from "../api";
|
import { type DeleteTaxRegimeParams, deleteTaxRegime } from "../api";
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const useTaxRegimeDeleteMutation = () => {
|
export const useTaxRegimeDeleteMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -20,8 +22,12 @@ export const useTaxRegimeDeleteMutation = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX }),
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: TAX_REGIMES_DETAIL_QUERY_KEY_PREFIX }),
|
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 { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetTaxRegimeByIdAdapter } from "../adapters";
|
import { GetTaxRegimeByIdAdapter } from "../adapters";
|
||||||
@ -11,11 +12,12 @@ export const useTaxRegimeDetailQuery = (
|
|||||||
taxRegimeId?: string,
|
taxRegimeId?: string,
|
||||||
options?: { enabled?: boolean }
|
options?: { enabled?: boolean }
|
||||||
): UseQueryResult<TaxRegime, DefaultError> => {
|
): UseQueryResult<TaxRegime, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = (options?.enabled ?? true) && Boolean(taxRegimeId);
|
const enabled = (options?.enabled ?? true) && Boolean(taxRegimeId);
|
||||||
|
|
||||||
return useQuery<TaxRegime, DefaultError>({
|
return useQuery<TaxRegime, DefaultError>({
|
||||||
queryKey: TAX_REGIME_QUERY_KEY(taxRegimeId),
|
queryKey: companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegimeId)),
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getTaxRegimeById(dataSource, {
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetTaxRegimeByIdAdapter } from "../adapters";
|
import { GetTaxRegimeByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const useTaxRegimeDisableMutation = () => {
|
export const useTaxRegimeDisableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,8 +24,13 @@ export const useTaxRegimeDisableMutation = () => {
|
|||||||
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (taxRegime) => {
|
onSuccess: async (taxRegime) => {
|
||||||
queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX });
|
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { GetTaxRegimeByIdAdapter } from "../adapters";
|
import { GetTaxRegimeByIdAdapter } from "../adapters";
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "./keys";
|
} from "./keys";
|
||||||
|
|
||||||
export const useTaxRegimeEnableMutation = () => {
|
export const useTaxRegimeEnableMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -22,8 +24,13 @@ export const useTaxRegimeEnableMutation = () => {
|
|||||||
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (taxRegime) => {
|
onSuccess: async (taxRegime) => {
|
||||||
queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime);
|
queryClient.setQueryData(
|
||||||
await queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX });
|
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 type { CriteriaDTO } from "@erp/core";
|
||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
|
||||||
|
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
|
||||||
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ListTaxRegimesAdapter } from "../adapters";
|
import { ListTaxRegimesAdapter } from "../adapters";
|
||||||
@ -16,17 +17,19 @@ export interface TaxRegimesListQueryOptions {
|
|||||||
export const useTaxRegimesListQuery = (
|
export const useTaxRegimesListQuery = (
|
||||||
options?: TaxRegimesListQueryOptions
|
options?: TaxRegimesListQueryOptions
|
||||||
): UseQueryResult<TaxRegimeList, DefaultError> => {
|
): UseQueryResult<TaxRegimeList, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = options?.enabled ?? true;
|
||||||
const criteria = options?.criteria ?? {};
|
const criteria = options?.criteria ?? {};
|
||||||
|
|
||||||
return useQuery<TaxRegimeList, DefaultError>({
|
return useQuery<TaxRegimeList, DefaultError>({
|
||||||
queryKey: LIST_TAX_REGIMES_QUERY_KEY(criteria),
|
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY(criteria)),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getListTaxRegimesByCriteria(dataSource, { signal, criteria });
|
const dto = await getListTaxRegimesByCriteria(dataSource, { signal, criteria });
|
||||||
return ListTaxRegimesAdapter.fromDto(dto);
|
return ListTaxRegimesAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
enabled,
|
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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
export const useTaxRegimeUpdateMutation = () => {
|
export const useTaxRegimeUpdateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -35,9 +37,12 @@ export const useTaxRegimeUpdateMutation = () => {
|
|||||||
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
return GetTaxRegimeByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (taxRegime) => {
|
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({
|
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;
|
transaction?: unknown;
|
||||||
}): Promise<Result<boolean, Error>>;
|
}): Promise<Result<boolean, Error>>;
|
||||||
findActiveByIds(params: {
|
findActiveByIds(params: {
|
||||||
|
allCompanies?: boolean;
|
||||||
companyIds: Collection<UniqueID>;
|
companyIds: Collection<UniqueID>;
|
||||||
transaction?: unknown;
|
transaction?: unknown;
|
||||||
}): Promise<Result<Collection<Company>, Error>>;
|
}): Promise<Result<Collection<Company>, Error>>;
|
||||||
|
|||||||
@ -77,6 +77,7 @@ test("create devuelve la company sin default payment term", async () => {
|
|||||||
const company = buildCompany();
|
const company = buildCompany();
|
||||||
|
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
adminAssigner: { assign: async () => Result.ok() },
|
||||||
inputMapper: new CreateCompanyInputMapper(),
|
inputMapper: new CreateCompanyInputMapper(),
|
||||||
creator: {
|
creator: {
|
||||||
create: async () => Result.ok(company),
|
create: async () => Result.ok(company),
|
||||||
@ -86,6 +87,7 @@ test("create devuelve la company sin default payment term", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
|
accountId: UniqueID.generateNewID(),
|
||||||
dto: {
|
dto: {
|
||||||
id: company.id.toPrimitive(),
|
id: company.id.toPrimitive(),
|
||||||
legal_name: company.legalName.toPrimitive(),
|
legal_name: company.legalName.toPrimitive(),
|
||||||
@ -118,12 +120,14 @@ test("create serializes the company public address", async () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
adminAssigner: { assign: async () => Result.ok() },
|
||||||
inputMapper: new CreateCompanyInputMapper(),
|
inputMapper: new CreateCompanyInputMapper(),
|
||||||
creator: { create: async () => Result.ok(company) },
|
creator: { create: async () => Result.ok(company) },
|
||||||
responseMapper: new CompanyPublicModelMapper(),
|
responseMapper: new CompanyPublicModelMapper(),
|
||||||
transactionManager: buildTransactionManager(),
|
transactionManager: buildTransactionManager(),
|
||||||
});
|
});
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
|
accountId: UniqueID.generateNewID(),
|
||||||
dto: {
|
dto: {
|
||||||
id: company.id.toPrimitive(),
|
id: company.id.toPrimitive(),
|
||||||
legal_name: company.legalName.toPrimitive(),
|
legal_name: company.legalName.toPrimitive(),
|
||||||
@ -150,6 +154,7 @@ test("create maps a complete request address to Company props", async () => {
|
|||||||
const company = buildCompany();
|
const company = buildCompany();
|
||||||
let capturedAddress: typeof company.address | undefined;
|
let capturedAddress: typeof company.address | undefined;
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
adminAssigner: { assign: async () => Result.ok() },
|
||||||
inputMapper: new CreateCompanyInputMapper(),
|
inputMapper: new CreateCompanyInputMapper(),
|
||||||
creator: {
|
creator: {
|
||||||
create: async ({ props }) => {
|
create: async ({ props }) => {
|
||||||
@ -161,6 +166,7 @@ test("create maps a complete request address to Company props", async () => {
|
|||||||
transactionManager: buildTransactionManager(),
|
transactionManager: buildTransactionManager(),
|
||||||
});
|
});
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
|
accountId: UniqueID.generateNewID(),
|
||||||
dto: {
|
dto: {
|
||||||
id: company.id.toPrimitive(),
|
id: company.id.toPrimitive(),
|
||||||
legal_name: company.legalName.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;
|
let creatorCalled = false;
|
||||||
const expectedError = new Error("invalid input");
|
const expectedError = new Error("invalid input");
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
adminAssigner: { assign: async () => Result.ok() },
|
||||||
inputMapper: { map: () => Result.fail(expectedError) },
|
inputMapper: { map: () => Result.fail(expectedError) },
|
||||||
creator: {
|
creator: {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
@ -201,7 +208,10 @@ test("create returns an input mapper failure without calling the creator", async
|
|||||||
responseMapper: new CompanyPublicModelMapper(),
|
responseMapper: new CompanyPublicModelMapper(),
|
||||||
transactionManager: buildTransactionManager(),
|
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(result.error, expectedError);
|
||||||
assert.equal(creatorCalled, false);
|
assert.equal(creatorCalled, false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import type { ITransactionManager } from "@erp/core/api";
|
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 { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common";
|
import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common";
|
||||||
@ -10,6 +12,7 @@ export class CreateCompanyUseCase {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
creator: ICompanyCreator;
|
creator: ICompanyCreator;
|
||||||
|
adminAssigner: IInitialCompanyAdminAssigner;
|
||||||
inputMapper: ICreateCompanyInputMapper;
|
inputMapper: ICreateCompanyInputMapper;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
@ -17,6 +20,7 @@ export class CreateCompanyUseCase {
|
|||||||
) {}
|
) {}
|
||||||
public execute(params: {
|
public execute(params: {
|
||||||
dto: CreateCompanyRequestDTO;
|
dto: CreateCompanyRequestDTO;
|
||||||
|
accountId: UniqueID;
|
||||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||||
const input = this.deps.inputMapper.map(params.dto);
|
const input = this.deps.inputMapper.map(params.dto);
|
||||||
if (input.isFailure) {
|
if (input.isFailure) {
|
||||||
@ -29,9 +33,18 @@ export class CreateCompanyUseCase {
|
|||||||
props: input.data.props,
|
props: input.data.props,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
return result.isFailure
|
if (result.isFailure) {
|
||||||
? Result.fail(result.error)
|
return Result.fail(result.error);
|
||||||
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
}
|
||||||
|
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) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import { UniqueID } from "@repo/rdx-ddd";
|
|||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { AvailableCompaniesResponseDTO, AvailableCompanyDTO } from "../../../../common";
|
import type { AvailableCompaniesResponseDTO, AvailableCompanyDTO } from "../../../../common";
|
||||||
|
import { CompaniesPermissionCodes } from "../../../../common";
|
||||||
import type { ICompanyRepository } from "../contracts";
|
import type { ICompanyRepository } from "../contracts";
|
||||||
import type { CompanyPublicModelMapper } from "../public";
|
import type { CompanyPublicModelMapper } from "../public";
|
||||||
|
|
||||||
export class FindAvailableCompaniesForAccountUseCase {
|
export class FindAvailableCompaniesForAccountUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
identityServices: Pick<IIdentityPublicServices, "companyAccess">;
|
identityServices: Pick<IIdentityPublicServices, "companyAccess" | "authorization">;
|
||||||
repository: ICompanyRepository;
|
repository: ICompanyRepository;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
@ -37,7 +38,16 @@ export class FindAvailableCompaniesForAccountUseCase {
|
|||||||
return Result.fail(accessibleCompanyIdsResult.error);
|
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({
|
const companiesResult = await this.deps.repository.findActiveByIds({
|
||||||
|
...(global.data ? { allCompanies: true } : {}),
|
||||||
companyIds: accessibleCompanyIdsResult.data,
|
companyIds: accessibleCompanyIdsResult.data,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { UpdateCurrentCompanyInputMapper } from "../../application/companies/map
|
|||||||
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
||||||
|
|
||||||
export type CompaniesInternalDeps = {
|
export type CompaniesInternalDeps = {
|
||||||
|
authorization: IIdentityPublicServices["authorization"];
|
||||||
companyAccess: IIdentityPublicServices["companyAccess"];
|
companyAccess: IIdentityPublicServices["companyAccess"];
|
||||||
publicFinder: CompanyPublicFinder;
|
publicFinder: CompanyPublicFinder;
|
||||||
useCases: {
|
useCases: {
|
||||||
@ -76,10 +77,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDepen
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
internal: {
|
internal: {
|
||||||
|
authorization: identityServices.authorization,
|
||||||
companyAccess: identityServices.companyAccess,
|
companyAccess: identityServices.companyAccess,
|
||||||
publicFinder,
|
publicFinder,
|
||||||
useCases: {
|
useCases: {
|
||||||
createCompany: new CreateCompanyUseCase({
|
createCompany: new CreateCompanyUseCase({
|
||||||
|
adminAssigner: identityServices.initialCompanyAdminAssigner,
|
||||||
creator,
|
creator,
|
||||||
inputMapper: createCompanyInputMapper,
|
inputMapper: createCompanyInputMapper,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
@ -96,7 +99,10 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDepen
|
|||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({
|
findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({
|
||||||
identityServices: { companyAccess: identityServices.companyAccess },
|
identityServices: {
|
||||||
|
companyAccess: identityServices.companyAccess,
|
||||||
|
authorization: identityServices.authorization,
|
||||||
|
},
|
||||||
repository,
|
repository,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
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 type { NextFunction, Request, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChangeCompanyStatusRequestSchema,
|
ChangeCompanyStatusRequestSchema,
|
||||||
|
CompaniesPermissionCodes,
|
||||||
CreateCompanyRequestSchema,
|
CreateCompanyRequestSchema,
|
||||||
GetCompanyByIdRequestSchema,
|
GetCompanyByIdRequestSchema,
|
||||||
UpdateCompanyByIdParamsRequestSchema,
|
UpdateCompanyByIdParamsRequestSchema,
|
||||||
@ -39,6 +45,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
|
requirePermission(deps.authorization, CompaniesPermissionCodes.Create, "SYSTEM"),
|
||||||
validateRequest(CreateCompanyRequestSchema, "body"),
|
validateRequest(CreateCompanyRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new CreateCompanyController(deps.useCases.createCompany);
|
const controller = new CreateCompanyController(deps.useCases.createCompany);
|
||||||
@ -65,6 +72,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/current",
|
"/current",
|
||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
|
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
|
||||||
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
|
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany);
|
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany);
|
||||||
@ -75,6 +83,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/:company_id",
|
"/:company_id",
|
||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
|
requirePermission(deps.authorization, CompaniesPermissionCodes.Read),
|
||||||
validateRequest(GetCompanyByIdRequestSchema, "params"),
|
validateRequest(GetCompanyByIdRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById);
|
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById);
|
||||||
@ -85,6 +94,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/:company_id",
|
"/:company_id",
|
||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
|
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
|
||||||
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
|
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
|
||||||
validateRequest(UpdateCompanyRequestSchema, "body"),
|
validateRequest(UpdateCompanyRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
@ -96,6 +106,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
router.post(
|
router.post(
|
||||||
"/:company_id/enable",
|
"/:company_id/enable",
|
||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
|
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
|
||||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new EnableCompanyController(deps.useCases.enableCompany);
|
const controller = new EnableCompanyController(deps.useCases.enableCompany);
|
||||||
@ -106,6 +117,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
router.post(
|
router.post(
|
||||||
"/:company_id/disable",
|
"/:company_id/disable",
|
||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
|
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
|
||||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new DisableCompanyController(deps.useCases.disableCompany);
|
const controller = new DisableCompanyController(deps.useCases.disableCompany);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ExpressController } from "@erp/core/api";
|
import { ExpressController } from "@erp/core/api";
|
||||||
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
import type { CreateCompanyRequestDTO } from "../../../../common";
|
import type { CreateCompanyRequestDTO } from "../../../../common";
|
||||||
import type { CreateCompanyUseCase } from "../../../application";
|
import type { CreateCompanyUseCase } from "../../../application";
|
||||||
@ -12,7 +13,11 @@ export class CreateCompanyController extends ExpressController {
|
|||||||
|
|
||||||
protected async executeImpl() {
|
protected async executeImpl() {
|
||||||
const dto = this.req.body as CreateCompanyRequestDTO;
|
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(
|
return result.match(
|
||||||
(data) => this.created(data),
|
(data) => this.created(data),
|
||||||
|
|||||||
@ -133,6 +133,7 @@ export class SequelizeCompanyRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async findActiveByIds(params: {
|
public async findActiveByIds(params: {
|
||||||
|
allCompanies?: boolean;
|
||||||
companyIds: Collection<UniqueID>;
|
companyIds: Collection<UniqueID>;
|
||||||
transaction?: unknown;
|
transaction?: unknown;
|
||||||
}): Promise<Result<Collection<Company>, Error>> {
|
}): Promise<Result<Collection<Company>, Error>> {
|
||||||
@ -141,13 +142,13 @@ export class SequelizeCompanyRepository
|
|||||||
...new Set(params.companyIds.getAll().map((companyId) => companyId.toPrimitive())),
|
...new Set(params.companyIds.getAll().map((companyId) => companyId.toPrimitive())),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0 && !params.allCompanies) {
|
||||||
return Result.ok(new Collection([]));
|
return Result.ok(new Collection([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await CompanyModel.findAll({
|
const rows = await CompanyModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
id: { [Op.in]: ids },
|
...(params.allCompanies ? {} : { id: { [Op.in]: ids } }),
|
||||||
status: COMPANY_STATUS.ACTIVE,
|
status: COMPANY_STATUS.ACTIVE,
|
||||||
},
|
},
|
||||||
transaction: asTransaction(params.transaction),
|
transaction: asTransaction(params.transaction),
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { IModuleServer } from "@erp/core/api";
|
import type { IModuleServer } from "@erp/core/api";
|
||||||
|
|
||||||
|
import { companiesPermissionDefinitions } from "../common/permissions";
|
||||||
|
|
||||||
import type { ICompanyPublicServices } from "./application";
|
import type { ICompanyPublicServices } from "./application";
|
||||||
import { buildCompaniesDependencies } from "./infrastructure/di";
|
import { buildCompaniesDependencies } from "./infrastructure/di";
|
||||||
import { models } from "./infrastructure/persistence/sequelize";
|
import { models } from "./infrastructure/persistence/sequelize";
|
||||||
@ -14,6 +16,7 @@ export const companiesModule: IModuleServer = {
|
|||||||
name: "companies",
|
name: "companies",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["identity", "catalogs"],
|
dependencies: ["identity", "catalogs"],
|
||||||
|
permissions: companiesPermissionDefinitions,
|
||||||
async setup(params) {
|
async setup(params) {
|
||||||
const { internal, publicServices } = buildCompaniesDependencies(params);
|
const { internal, publicServices } = buildCompaniesDependencies(params);
|
||||||
const general: ICompanyPublicServices = publicServices;
|
const general: ICompanyPublicServices = publicServices;
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./dto";
|
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 { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { CompanySettingsAdapter } from "../adapters";
|
import { CompanySettingsAdapter } from "../adapters";
|
||||||
@ -8,10 +9,11 @@ import type { CompanySettings } from "../entities";
|
|||||||
import { COMPANY_SETTINGS_QUERY_KEY } from "./keys";
|
import { COMPANY_SETTINGS_QUERY_KEY } from "./keys";
|
||||||
|
|
||||||
export const useCompanySettingsQuery = (): UseQueryResult<CompanySettings, DefaultError> => {
|
export const useCompanySettingsQuery = (): UseQueryResult<CompanySettings, DefaultError> => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
return useQuery<CompanySettings, DefaultError>({
|
return useQuery<CompanySettings, DefaultError>({
|
||||||
queryKey: COMPANY_SETTINGS_QUERY_KEY(),
|
queryKey: companyQueryKey(queryScope, COMPANY_SETTINGS_QUERY_KEY()),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const dto = await getCompanySettings(dataSource, { 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 { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ const toValidationErrors = (issues: { path: PropertyKey[]; message: string }[])
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const useCompanySettingsUpdateMutation = () => {
|
export const useCompanySettingsUpdateMutation = () => {
|
||||||
|
const queryScope = useCompanyQueryScope();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
@ -38,7 +40,7 @@ export const useCompanySettingsUpdateMutation = () => {
|
|||||||
return CompanySettingsAdapter.fromDto(dto);
|
return CompanySettingsAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (company) => {
|
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", {
|
mock.module("@erp/core/hooks", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
useCompanyQueryScope: () => null,
|
||||||
UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children,
|
UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,7 +18,8 @@
|
|||||||
"./client": "./src/web/manifest.ts",
|
"./client": "./src/web/manifest.ts",
|
||||||
"./globals.css": "./src/web/globals.css",
|
"./globals.css": "./src/web/globals.css",
|
||||||
"./components": "./src/web/components/index.ts",
|
"./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": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
|
|||||||
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",
|
"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 { ILogger } from "@repo/rdx-logger";
|
||||||
import type { Model, ModelStatic, Sequelize } from "sequelize";
|
import type { Model, ModelStatic, Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
import { collectModulePermissions } from "./module-permissions";
|
||||||
import type { IModuleServer } from "./module-server.interface";
|
import type { IModuleServer } from "./module-server.interface";
|
||||||
import type { ModulesRuntime } from "./modules-runtime";
|
import type { ModulesRuntime } from "./modules-runtime";
|
||||||
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
|
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
|
||||||
@ -54,8 +55,9 @@ export async function initModules(
|
|||||||
export async function initializeModuleStructure(
|
export async function initializeModuleStructure(
|
||||||
params: ModuleParams & { logger: ILogger; database: Sequelize }
|
params: ModuleParams & { logger: ILogger; database: Sequelize }
|
||||||
): Promise<ModulesRuntime> {
|
): Promise<ModulesRuntime> {
|
||||||
|
const permissionDefinitions = collectModulePermissions([...modules.values()]);
|
||||||
for (const name of modules.keys()) {
|
for (const name of modules.keys()) {
|
||||||
await setupModule(name, params, []);
|
await setupModule(name, { ...params, permissionDefinitions }, []);
|
||||||
}
|
}
|
||||||
await initializeModels(params.database, params.logger);
|
await initializeModels(params.database, params.logger);
|
||||||
validateDependencies();
|
validateDependencies();
|
||||||
@ -70,7 +72,9 @@ export async function initializeModuleStructure(
|
|||||||
throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
|
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. */
|
/** Ejecuta la fase opcional de conexión al runtime, que el server usa para HTTP. */
|
||||||
@ -199,7 +203,7 @@ async function warmupModules(
|
|||||||
`${name}.warmup`
|
`${name}.warmup`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (params.warmup.strict) {
|
if (params.warmup.strict || module.warmupRequired) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
params.logger.warn("Module warmup failed but continuing", {
|
params.logger.warn("Module warmup failed but continuing", {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
//Contrato para los Modules backend (Node.js)
|
//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";
|
import type { ModuleSetupResult, SetupParams, StartParams, WarmupParams } from "./types";
|
||||||
|
|
||||||
@ -15,6 +15,9 @@ export interface IModuleServer extends ModuleMetadata {
|
|||||||
*/
|
*/
|
||||||
dependencies?: string[];
|
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.
|
* Fase de construcción del módulo.
|
||||||
* Aquí se construye el dominio y se decide qué se expone.
|
* 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).
|
* 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;
|
warmup?: (params: WarmupParams) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import type { IAudit } from "@repo/rdx-audit";
|
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 { Model, ModelStatic, Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
import type { PermissionDefinition } from "../../common";
|
||||||
import type { IOperationContextStore } from "../infrastructure/context";
|
import type { IOperationContextStore } from "../infrastructure/context";
|
||||||
import type { ISequelizeTransactionContext } from "../infrastructure/persistence/sequelize";
|
import type { ISequelizeTransactionContext } from "../infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
@ -10,6 +13,17 @@ export interface SequelizeModel extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleParams = {
|
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.
|
* Acceso a servicios expuestos por otros módulos.
|
||||||
* Todas las dependencias ya están resueltas.
|
* Todas las dependencias ya están resueltas.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export type PermissionCode = string;
|
|||||||
|
|
||||||
export type PermissionDefinition = {
|
export type PermissionDefinition = {
|
||||||
code: PermissionCode;
|
code: PermissionCode;
|
||||||
|
scope: "SYSTEM" | "COMPANY";
|
||||||
module: string;
|
module: string;
|
||||||
description: 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