Login + selección de empresas

This commit is contained in:
David Arranz 2026-07-06 12:57:33 +02:00
parent faf7b2251c
commit 3e5791afdb
124 changed files with 3387 additions and 103 deletions

View File

@ -35,6 +35,7 @@
},
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/companies": "workspace:*",
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/customer-invoices": "workspace:*",
@ -74,4 +75,4 @@
"engines": {
"node": ">=22"
}
}
}

View File

@ -1,4 +1,5 @@
import catalogsAPIModule from "@erp/catalogs/api";
import companiesAPIModule from "@erp/companies/api";
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
import factuGESAPIModule from "@erp/factuges/api";
@ -15,6 +16,7 @@ import { registerModule } from "./lib";
// públicos de ese módulo.
export const registerModules = () => {
registerModule(identityAPIModule);
registerModule(companiesAPIModule);
registerModule(catalogsAPIModule);
registerModule(customersAPIModule);
registerModule(customerInvoicesAPIModule);

View File

@ -1,6 +1,10 @@
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { DataSourceProvider } from "@erp/core/hooks";
import { IdentityAuthSessionProvider, authTokenStorage } from "@erp/identity/client";
import {
IdentityAuthSessionProvider,
activeCompanyStorage,
authTokenStorage,
} from "@erp/identity/client";
import { i18n } from "@repo/i18next";
import { LoadingOverlay } from "@repo/rdx-ui/components";
import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components";
@ -30,6 +34,7 @@ export const App = () => {
const axiosInstance = createAxiosInstance({
baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken: () => authTokenStorage.getAccessToken(),
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
onAuthError: () => {
// TODO(identity-auth): siguiente incremento
// 1. detectar 401 recuperable

View File

@ -8,6 +8,7 @@ import type { ReactNode } from "react";
import type { RouteObject } from "react-router-dom";
import { RequireAuthRouteGuard } from "@/routes/require-auth-guard";
import { RequireCompanyRouteGuard } from "@/routes/require-company-guard";
interface BuildModuleRoutesParams {
modules: IModuleClient[];
@ -21,6 +22,7 @@ interface NormalizeRouteParams {
targetLayout: ModuleRouteLayout;
inheritedLayout?: ModuleRouteLayout;
inheritedProtected?: boolean;
inheritedRequireCompany?: boolean;
}
const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar";
@ -49,15 +51,27 @@ const wrapProtectedElement = (element: ReactNode, isProtected: boolean): ReactNo
return <RequireAuthRouteGuard>{element}</RequireAuthRouteGuard>;
};
const wrapCompanyElement = (element: ReactNode, requireCompany: boolean): ReactNode => {
if (!requireCompany) {
return element;
}
return <RequireCompanyRouteGuard>{element}</RequireCompanyRouteGuard>;
};
const normalizeRoute = ({
route,
module,
targetLayout,
inheritedLayout,
inheritedProtected,
inheritedRequireCompany,
}: NormalizeRouteParams): RouteObject | null => {
const routeLayout = resolveRouteLayout(route, module, inheritedLayout);
const routeProtected = resolveRouteProtected(route, module, inheritedProtected);
const routeRequireCompany =
routeProtected &&
(route.handle?.requireCompany ?? inheritedRequireCompany ?? true);
const normalizedChildren = route.children
?.map((child) =>
@ -67,6 +81,7 @@ const normalizeRoute = ({
targetLayout,
inheritedLayout: routeLayout,
inheritedProtected: routeProtected,
inheritedRequireCompany: routeRequireCompany,
})
)
.filter((child): child is RouteObject => child !== null);
@ -92,7 +107,9 @@ const normalizeRoute = ({
const normalizedRoute = {
...rest,
handle,
element: element ? wrapProtectedElement(element, routeProtected) : undefined,
element: element
? wrapProtectedElement(wrapCompanyElement(element, routeRequireCompany), routeProtected)
: undefined,
children: normalizedChildren,
} as RouteObject;

View File

@ -8,7 +8,7 @@ interface PublicOnlyRouteProps {
}
export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
const { isAuthenticated, isLoading } = useIdentityAuthSession();
const { getAuthenticatedRedirectPath, isAuthenticated, isLoading } = useIdentityAuthSession();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
@ -18,7 +18,14 @@ export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
}
if (isAuthenticated) {
return <Navigate replace to={returnTo?.startsWith("/") ? returnTo : "/proformas"} />;
return (
<Navigate
replace
to={getAuthenticatedRedirectPath({
returnTo,
})}
/>
);
}
return <>{children}</>;

View File

@ -0,0 +1,29 @@
import { useIdentityAuthSession } from "@erp/identity/client";
import { LoadingOverlay } from "@repo/rdx-ui/components";
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
interface RequireCompanyRouteGuardProps {
children: ReactNode;
}
export const RequireCompanyRouteGuard = ({ children }: RequireCompanyRouteGuardProps) => {
const location = useLocation();
const { activeCompany, availableCompanies, isLoading } = useIdentityAuthSession();
if (isLoading) {
return <LoadingOverlay />;
}
if (activeCompany) {
return <>{children}</>;
}
const returnTo = `${location.pathname}${location.search}`;
if (availableCompanies.length === 0) {
return <Navigate replace to="/no-companies" />;
}
return <Navigate replace to={`/company-selection?returnTo=${encodeURIComponent(returnTo)}`} />;
};

View File

@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicSe
export const catalogsAPIModule: IModuleServer = {
name: "catalogs",
version: "1.0.0",
dependencies: ["identity"],
dependencies: ["identity", "companies"],
/**
* Fase de SETUP

View File

@ -0,0 +1,33 @@
{
"name": "@erp/companies",
"description": "Companies module",
"version": "0.8.2",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts"
},
"devDependencies": {
"@types/express": "^4.17.21",
"rimraf": "^6.1.3",
"typescript": "^6.0.2"
},
"dependencies": {
"@erp/core": "workspace:*",
"@erp/identity": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"express": "^4.22.1",
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,19 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Company, CompanySlug } from "../../../domain";
export interface ICompanyRepository {
save(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
update(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
findById(id: UniqueID, transaction?: unknown): Promise<Result<Maybe<Company>, Error>>;
findBySlug(slug: CompanySlug, transaction?: unknown): Promise<Result<Maybe<Company>, Error>>;
existsActiveById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
findActiveByIds(params: {
companyIds: Collection<UniqueID>;
transaction?: unknown;
}): Promise<Result<Collection<Company>, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./company-repository.interface";

View File

@ -0,0 +1,4 @@
export * from "./contracts";
export * from "./services";
export * from "./use-cases";
export * from "./public";

View File

@ -0,0 +1,3 @@
export * from "./mappers";
export * from "./models";
export * from "./services";

View File

@ -0,0 +1,20 @@
import { maybeToNullable } from "@repo/rdx-ddd";
import type { Company } from "../../../../domain";
import type { CompanyPublicModel } from "../models";
export class CompanyPublicModelMapper {
public toPublicModel(company: Company): CompanyPublicModel {
return {
id: company.id.toPrimitive(),
legalName: company.legalName.toPrimitive(),
tradeName: maybeToNullable(company.tradeName, (value) => value.toPrimitive()),
tin: maybeToNullable(company.tin, (value) => value.toPrimitive()),
slug: company.slug.toPrimitive(),
email: maybeToNullable(company.email, (value) => value.toPrimitive()),
phone: maybeToNullable(company.phone, (value) => value.toPrimitive()),
website: maybeToNullable(company.website, (value) => value.toPrimitive()),
status: company.status.toPrimitive() as "active" | "disabled",
};
}
}

View File

@ -0,0 +1 @@
export * from "./company-public-model.mapper";

View File

@ -0,0 +1,11 @@
export interface CompanyPublicModel {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "disabled";
}

View File

@ -0,0 +1 @@
export * from "./company-public.model";

View File

@ -0,0 +1,58 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { ICompanyRepository } from "../../contracts";
import type { CompanyPublicModelMapper } from "../mappers";
import type { CompanyPublicModel } from "../models";
export interface ICompanyPublicFinder {
findById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<Maybe<CompanyPublicModel>, Error>>;
existsActiveById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
}
export class CompanyPublicFinder implements ICompanyPublicFinder {
public constructor(
private readonly deps: {
repository: ICompanyRepository;
mapper: CompanyPublicModelMapper;
}
) {}
public async findById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<Maybe<CompanyPublicModel>, Error>> {
const result = await this.deps.repository.findById(params.companyId, params.transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(
result.data.match(
(company) => Maybe.some(this.deps.mapper.toPublicModel(company)),
() => Maybe.none<CompanyPublicModel>()
)
);
}
public async existsActiveById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>> {
const result = await this.deps.repository.existsActiveById(params);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
}

View File

@ -0,0 +1,11 @@
import type { ICompanyPublicFinder } from "./company-public-finder";
export interface ICompanyPublicServices {
finder: ICompanyPublicFinder;
}
export const buildCompanyPublicServices = (
finder: ICompanyPublicFinder
): ICompanyPublicServices => ({
finder,
});

View File

@ -0,0 +1,2 @@
export * from "./company-public-finder";
export * from "./company-public-services";

View File

@ -0,0 +1,48 @@
import { Result } from "@repo/rdx-utils";
import type { Company, ICompanyCreateProps } from "../../../domain";
import { Company as CompanyAggregate, CompanySlugAlreadyExistsError } from "../../../domain";
import type { ICompanyRepository } from "../contracts";
export interface ICompanyCreator {
create(params: {
id: Company["id"];
props: ICompanyCreateProps;
transaction?: unknown;
}): Promise<Result<Company, Error>>;
}
export class CompanyCreator implements ICompanyCreator {
public constructor(private readonly repository: ICompanyRepository) {}
public async create(params: {
id: Company["id"];
props: ICompanyCreateProps;
transaction?: unknown;
}): Promise<Result<Company, Error>> {
const existingBySlug = await this.repository.findBySlug(params.props.slug, params.transaction);
if (existingBySlug.isFailure) {
return Result.fail(existingBySlug.error);
}
if (existingBySlug.data.isSome()) {
return Result.fail(
new CompanySlugAlreadyExistsError(
`Company slug "${params.props.slug.toPrimitive()}" already exists.`
)
);
}
const companyResult = CompanyAggregate.create(params.props, params.id);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
const saveResult = await this.repository.save(companyResult.data, params.transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
return Result.ok(companyResult.data);
}
}

View File

@ -0,0 +1,28 @@
import { Result } from "@repo/rdx-utils";
import type { Company } from "../../../domain";
import { CompanyNotFoundError } from "../../../domain";
import type { ICompanyRepository } from "../contracts";
export interface ICompanyFinder {
findCompanyById(companyId: Company["id"], transaction?: unknown): Promise<Result<Company, Error>>;
}
export class CompanyFinder implements ICompanyFinder {
public constructor(private readonly repository: ICompanyRepository) {}
public async findCompanyById(
companyId: Company["id"],
transaction?: unknown
): Promise<Result<Company, Error>> {
const result = await this.repository.findById(companyId, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
return result.data.match(
(company) => Result.ok(company),
() => Result.fail(new CompanyNotFoundError("Company not found."))
);
}
}

View File

@ -0,0 +1,40 @@
import { Result } from "@repo/rdx-utils";
import type { Company } from "../../../domain";
import type { ICompanyRepository } from "../contracts";
export interface ICompanyStatusChanger {
changeStatus(params: {
company: Company;
action: "enable" | "disable";
updatedAt: Company["updatedAt"];
transaction?: unknown;
}): Promise<Result<Company, Error>>;
}
export class CompanyStatusChanger implements ICompanyStatusChanger {
public constructor(private readonly repository: ICompanyRepository) {}
public async changeStatus(params: {
company: Company;
action: "enable" | "disable";
updatedAt: Company["updatedAt"];
transaction?: unknown;
}): Promise<Result<Company, Error>> {
const statusResult =
params.action === "enable"
? params.company.enable(params.updatedAt)
: params.company.disable(params.updatedAt);
if (statusResult.isFailure) {
return Result.fail(statusResult.error);
}
const persistResult = await this.repository.update(params.company, params.transaction);
if (persistResult.isFailure) {
return Result.fail(persistResult.error);
}
return Result.ok(params.company);
}
}

View File

@ -0,0 +1,69 @@
import { Result } from "@repo/rdx-utils";
import type { Company, CompanyPatchProps } from "../../../domain";
import { CompanySlugAlreadyExistsError } from "../../../domain";
import type { ICompanyRepository } from "../contracts";
import type { ICompanyFinder } from "./company-finder";
export interface ICompanyUpdater {
update(params: {
id: Company["id"];
patchProps: CompanyPatchProps;
transaction?: unknown;
}): Promise<Result<Company, Error>>;
}
export class CompanyUpdater implements ICompanyUpdater {
public constructor(
private readonly deps: {
finder: ICompanyFinder;
repository: ICompanyRepository;
}
) {}
public async update(params: {
id: Company["id"];
patchProps: CompanyPatchProps;
transaction?: unknown;
}): Promise<Result<Company, Error>> {
const companyResult = await this.deps.finder.findCompanyById(params.id, params.transaction);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
const company = companyResult.data;
if (params.patchProps.slug && !company.slug.equals(params.patchProps.slug)) {
const existingBySlug = await this.deps.repository.findBySlug(
params.patchProps.slug,
params.transaction
);
if (existingBySlug.isFailure) {
return Result.fail(existingBySlug.error);
}
if (
existingBySlug.data.isSome() &&
!existingBySlug.data.unwrap().id.equals(company.id)
) {
return Result.fail(
new CompanySlugAlreadyExistsError(
`Company slug "${params.patchProps.slug.toPrimitive()}" already exists.`
)
);
}
}
const updateResult = company.update(params.patchProps);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
const persistResult = await this.deps.repository.update(company, params.transaction);
if (persistResult.isFailure) {
return Result.fail(persistResult.error);
}
return Result.ok(company);
}
}

View File

@ -0,0 +1,4 @@
export * from "./company-creator";
export * from "./company-finder";
export * from "./company-status-changer";
export * from "./company-updater";

View File

@ -0,0 +1,135 @@
import type { ITransactionManager } from "@erp/core/api";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CreateCompanyRequestDTO, CompanyResponseDTO } from "../../../../common";
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyCreator } from "../services";
export class CreateCompanyUseCase {
public constructor(
private readonly deps: {
creator: ICompanyCreator;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { dto: CreateCompanyRequestDTO }): Promise<Result<CompanyResponseDTO, Error>> {
const mappedResult = mapCreateCompanyProps(params.dto);
if (mappedResult.isFailure) {
return Promise.resolve(Result.fail(mappedResult.error));
}
const { id, props } = mappedResult.data;
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const createResult = await this.deps.creator.create({ id, props, transaction });
if (createResult.isFailure) {
return Result.fail(createResult.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(createResult.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) {
const idResult = UniqueID.create(dto.id);
if (idResult.isFailure) {
return Result.fail(idResult.error);
}
const legalNameResult = Name.create(dto.legal_name);
if (legalNameResult.isFailure) {
return Result.fail(legalNameResult.error);
}
const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) => Name.create(value));
if (tradeNameResult.isFailure) {
return Result.fail(tradeNameResult.error);
}
const tinResult = maybeFromNullableResult(dto.tin ?? null, (value) => TextValue.create(value));
if (tinResult.isFailure) {
return Result.fail(tinResult.error);
}
const slugResult = CompanySlug.create(dto.slug);
if (slugResult.isFailure) {
return Result.fail(slugResult.error);
}
const emailResult = maybeFromNullableResult(dto.email ?? null, (value) => EmailAddress.create(value));
if (emailResult.isFailure) {
return Result.fail(emailResult.error);
}
const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) => PhoneNumber.create(value));
if (phoneResult.isFailure) {
return Result.fail(phoneResult.error);
}
const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) => URLAddress.create(value));
if (websiteResult.isFailure) {
return Result.fail(websiteResult.error);
}
const now = UtcDateTime.now();
const props: ICompanyCreateProps = {
legalName: legalNameResult.data,
tradeName: tradeNameResult.data,
tin: tinResult.data,
slug: slugResult.data,
email: emailResult.data,
phone: phoneResult.data,
website: websiteResult.data,
status: CompanyStatus.createActive(),
createdAt: now,
updatedAt: now,
};
return Result.ok({
id: idResult.data,
props,
});
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
status: model.status,
};
}

View File

@ -0,0 +1,72 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO } from "../../../../common";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyFinder, ICompanyStatusChanger } from "../services";
export class DisableCompanyUseCase {
public constructor(
private readonly deps: {
finder: ICompanyFinder;
changer: ICompanyStatusChanger;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { company_id: string }): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.company_id);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const findResult = await this.deps.finder.findCompanyById(companyIdResult.data, transaction);
if (findResult.isFailure) {
return Result.fail(findResult.error);
}
const changeResult = await this.deps.changer.changeStatus({
company: findResult.data,
action: "disable",
updatedAt: UtcDateTime.now(),
transaction,
});
if (changeResult.isFailure) {
return Result.fail(changeResult.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(changeResult.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
status: model.status,
};
}

View File

@ -0,0 +1,72 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO } from "../../../../common";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyFinder, ICompanyStatusChanger } from "../services";
export class EnableCompanyUseCase {
public constructor(
private readonly deps: {
finder: ICompanyFinder;
changer: ICompanyStatusChanger;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { company_id: string }): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.company_id);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const findResult = await this.deps.finder.findCompanyById(companyIdResult.data, transaction);
if (findResult.isFailure) {
return Result.fail(findResult.error);
}
const changeResult = await this.deps.changer.changeStatus({
company: findResult.data,
action: "enable",
updatedAt: UtcDateTime.now(),
transaction,
});
if (changeResult.isFailure) {
return Result.fail(changeResult.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(changeResult.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
status: model.status,
};
}

View File

@ -0,0 +1,72 @@
import type { IIdentityPublicServices } from "@erp/identity/api";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { AvailableCompaniesResponseDTO, AvailableCompanyDTO } from "../../../../common";
import type { ICompanyRepository } from "../contracts";
import type { CompanyPublicModelMapper } from "../public";
export class FindAvailableCompaniesForAccountUseCase {
public constructor(
private readonly deps: {
identityServices: IIdentityPublicServices;
repository: ICompanyRepository;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { accountId: string }): Promise<Result<AvailableCompaniesResponseDTO, Error>> {
const accountIdResult = UniqueID.create(params.accountId);
if (accountIdResult.isFailure) {
return Promise.resolve(Result.fail(accountIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const accessibleCompanyIdsResult =
await this.deps.identityServices.companyAccess.findAccessibleCompanyIds({
accountId: accountIdResult.data,
transaction,
});
if (accessibleCompanyIdsResult.isFailure) {
return Result.fail(accessibleCompanyIdsResult.error);
}
const companiesResult = await this.deps.repository.findActiveByIds({
companyIds: accessibleCompanyIdsResult.data,
transaction,
});
if (companiesResult.isFailure) {
return Result.fail(companiesResult.error);
}
const companies = companiesResult.data
.getAll()
.map((company) => this.deps.responseMapper.toPublicModel(company))
.map(
(company): AvailableCompanyDTO => ({
id: company.id,
legal_name: company.legalName,
trade_name: company.tradeName,
tin: company.tin,
slug: company.slug,
email: company.email,
phone: company.phone,
website: company.website,
status: "active",
})
);
return Result.ok({
companies,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,61 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO } from "../../../../common";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyFinder } from "../services";
export class GetCompanyByIdUseCase {
public constructor(
private readonly deps: {
finder: ICompanyFinder;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { company_id: string }): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.company_id);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const companyResult = await this.deps.finder.findCompanyById(companyIdResult.data, transaction);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(companyResult.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
status: model.status,
};
}

View File

@ -0,0 +1,6 @@
export * from "./create-company.use-case";
export * from "./disable-company.use-case";
export * from "./enable-company.use-case";
export * from "./find-available-companies-for-account.use-case";
export * from "./get-company-by-id.use-case";
export * from "./update-company.use-case";

View File

@ -0,0 +1,148 @@
import type { ITransactionManager } from "@erp/core/api";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
import { CompanySlug, type CompanyPatchProps } from "../../../domain";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyUpdater } from "../services";
export class UpdateCompanyUseCase {
public constructor(
private readonly deps: {
updater: ICompanyUpdater;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: {
company_id: string;
dto: UpdateCompanyRequestDTO;
}): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.company_id);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
const patchPropsResult = mapUpdateCompanyProps(params.dto);
if (patchPropsResult.isFailure) {
return Promise.resolve(Result.fail(patchPropsResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const updateResult = await this.deps.updater.update({
id: companyIdResult.data,
patchProps: patchPropsResult.data,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result<CompanyPatchProps, Error> {
const patchProps: CompanyPatchProps = {
updatedAt: UtcDateTime.now(),
};
if (dto.legal_name !== undefined) {
const legalNameResult = Name.create(dto.legal_name);
if (legalNameResult.isFailure) {
return Result.fail(legalNameResult.error);
}
patchProps.legalName = legalNameResult.data;
}
if (dto.trade_name !== undefined) {
const tradeNameResult = maybeFromNullableResult(dto.trade_name, (value) => Name.create(value));
if (tradeNameResult.isFailure) {
return Result.fail(tradeNameResult.error);
}
patchProps.tradeName = tradeNameResult.data;
}
if (dto.tin !== undefined) {
const tinResult = maybeFromNullableResult(dto.tin, (value) => TextValue.create(value));
if (tinResult.isFailure) {
return Result.fail(tinResult.error);
}
patchProps.tin = tinResult.data;
}
if (dto.slug !== undefined) {
const slugResult = CompanySlug.create(dto.slug);
if (slugResult.isFailure) {
return Result.fail(slugResult.error);
}
patchProps.slug = slugResult.data;
}
if (dto.email !== undefined) {
const emailResult = maybeFromNullableResult(dto.email, (value) => EmailAddress.create(value));
if (emailResult.isFailure) {
return Result.fail(emailResult.error);
}
patchProps.email = emailResult.data;
}
if (dto.phone !== undefined) {
const phoneResult = maybeFromNullableResult(dto.phone, (value) => PhoneNumber.create(value));
if (phoneResult.isFailure) {
return Result.fail(phoneResult.error);
}
patchProps.phone = phoneResult.data;
}
if (dto.website !== undefined) {
const websiteResult = maybeFromNullableResult(dto.website, (value) => URLAddress.create(value));
if (websiteResult.isFailure) {
return Result.fail(websiteResult.error);
}
patchProps.website = websiteResult.data;
}
return Result.ok(patchProps);
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
status: model.status,
};
}

View File

@ -0,0 +1 @@
export * from "./companies";

View File

@ -0,0 +1,47 @@
import { ValueObject, translateZodValidationError } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
interface CompanySlugProps {
value: string;
}
export class CompanySlug extends ValueObject<CompanySlugProps> {
private static readonly MIN_LENGTH = 2;
private static readonly MAX_LENGTH = 100;
static create(value: string): Result<CompanySlug, Error> {
const schema = z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: "Company slug must contain only lowercase letters, numbers, and hyphens.",
})
.min(CompanySlug.MIN_LENGTH, {
message: `Company slug must be at least ${CompanySlug.MIN_LENGTH} characters long.`,
})
.max(CompanySlug.MAX_LENGTH, {
message: `Company slug must be at most ${CompanySlug.MAX_LENGTH} characters long.`,
});
const result = schema.safeParse(value);
if (!result.success) {
return Result.fail(translateZodValidationError("CompanySlug creation failed", result.error));
}
return Result.ok(new CompanySlug({ value: result.data }));
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,53 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
interface CompanyStatusProps {
value: string;
}
export enum COMPANY_STATUS {
ACTIVE = "active",
DISABLED = "disabled",
}
export class CompanyStatus extends ValueObject<CompanyStatusProps> {
private static readonly ALLOWED_STATUSES = [COMPANY_STATUS.ACTIVE, COMPANY_STATUS.DISABLED] as const;
static create(value: string): Result<CompanyStatus, Error> {
if (!CompanyStatus.ALLOWED_STATUSES.includes(value as (typeof CompanyStatus.ALLOWED_STATUSES)[number])) {
return Result.fail(
new DomainValidationError("INVALID_COMPANY_STATUS", "status", `Status value not valid: ${value}`)
);
}
return Result.ok(new CompanyStatus({ value }));
}
static createActive(): CompanyStatus {
return new CompanyStatus({ value: COMPANY_STATUS.ACTIVE });
}
static createDisabled(): CompanyStatus {
return new CompanyStatus({ value: COMPANY_STATUS.DISABLED });
}
isActive(): boolean {
return this.props.value === COMPANY_STATUS.ACTIVE;
}
isDisabled(): boolean {
return this.props.value === COMPANY_STATUS.DISABLED;
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,215 @@
import {
AggregateRoot,
type EmailAddress,
type Name,
type PhoneNumber,
type TextValue,
type URLAddress,
type UniqueID,
type UtcDate,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { CompanySlug } from "./company-slug.vo";
import { CompanyStatus } from "./company-status.vo";
import { CompanyCannotBeUpdatedError } from "./errors";
export interface ICompanyCreateProps {
legalName: Name;
tradeName: Maybe<Name>;
tin: Maybe<TextValue>;
slug: CompanySlug;
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
status: CompanyStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
}
export type CompanyPatchProps = Partial<{
legalName: Name;
tradeName: Maybe<Name>;
tin: Maybe<TextValue>;
slug: CompanySlug;
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
}> & {
updatedAt: UtcDate;
};
export type CompanyInternalProps = ICompanyCreateProps;
export class Company extends AggregateRoot<CompanyInternalProps> {
protected constructor(props: CompanyInternalProps, id?: UniqueID) {
super(props, id);
}
static create(props: ICompanyCreateProps, id?: UniqueID): Result<Company, Error> {
const validationResult = Company.validateCreateProps(props);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(new Company(props, id));
}
static rehydrate(props: CompanyInternalProps, id: UniqueID): Company {
return new Company(props, id);
}
private static validateCreateProps(props: ICompanyCreateProps): Result<void, Error> {
if (!props.legalName) {
return Result.fail(new Error("Company legal name is required."));
}
if (!props.slug) {
return Result.fail(new Error("Company slug is required."));
}
if (!props.status) {
return Result.fail(new Error("Company status is required."));
}
if (!(props.createdAt && props.updatedAt)) {
return Result.fail(new Error("Company timestamps are required."));
}
return Result.ok();
}
public get legalName(): Name {
return this.props.legalName;
}
public get tradeName(): Maybe<Name> {
return this.props.tradeName;
}
public get tin(): Maybe<TextValue> {
return this.props.tin;
}
public get slug(): CompanySlug {
return this.props.slug;
}
public get email(): Maybe<EmailAddress> {
return this.props.email;
}
public get phone(): Maybe<PhoneNumber> {
return this.props.phone;
}
public get website(): Maybe<URLAddress> {
return this.props.website;
}
public get status(): CompanyStatus {
return this.props.status;
}
public get createdAt(): UtcDate {
return this.props.createdAt;
}
public get updatedAt(): UtcDate {
return this.props.updatedAt;
}
public update(patch: CompanyPatchProps): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.fail(new CompanyCannotBeUpdatedError("Disabled companies cannot be updated."));
}
let hasChanges = false;
if (patch.legalName && !this.props.legalName.equals(patch.legalName)) {
this.props.legalName = patch.legalName;
hasChanges = true;
}
if (
patch.tradeName !== undefined &&
!Company.sameMaybeValueObject(this.props.tradeName, patch.tradeName)
) {
this.props.tradeName = patch.tradeName;
hasChanges = true;
}
if (patch.tin !== undefined && !Company.sameMaybeValueObject(this.props.tin, patch.tin)) {
this.props.tin = patch.tin;
hasChanges = true;
}
if (patch.slug && !this.props.slug.equals(patch.slug)) {
this.props.slug = patch.slug;
hasChanges = true;
}
if (patch.email !== undefined && !Company.sameMaybeValueObject(this.props.email, patch.email)) {
this.props.email = patch.email;
hasChanges = true;
}
if (patch.phone !== undefined && !Company.sameMaybeValueObject(this.props.phone, patch.phone)) {
this.props.phone = patch.phone;
hasChanges = true;
}
if (
patch.website !== undefined &&
!Company.sameMaybeValueObject(this.props.website, patch.website)
) {
this.props.website = patch.website;
hasChanges = true;
}
if (!hasChanges) {
return Result.ok(false);
}
this.props.updatedAt = patch.updatedAt;
return Result.ok(true);
}
public disable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.ok(false);
}
this.props.status = CompanyStatus.createDisabled();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public enable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isActive()) {
return Result.ok(false);
}
this.props.status = CompanyStatus.createActive();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public isActive(): boolean {
return this.status.isActive();
}
private static sameMaybeValueObject<T extends { equals(other: T): boolean }>(
current: Maybe<T>,
next: Maybe<T>
): boolean {
return current.match(
(currentValue) =>
next.match(
(nextValue) => currentValue.equals(nextValue),
() => false
),
() => next.isNone()
);
}
}

View File

@ -0,0 +1,21 @@
import { DomainError } from "@repo/rdx-ddd";
export class CompanyCannotBeUpdatedError extends DomainError {}
export const isCompanyCannotBeUpdatedError = (e: unknown): e is CompanyCannotBeUpdatedError =>
e instanceof CompanyCannotBeUpdatedError;
export class CompanyNotFoundError extends DomainError {
public readonly code = "COMPANY_NOT_FOUND" as const;
}
export const isCompanyNotFoundError = (e: unknown): e is CompanyNotFoundError =>
e instanceof CompanyNotFoundError;
export class CompanySlugAlreadyExistsError extends DomainError {
public readonly code = "COMPANY_SLUG_ALREADY_EXISTS" as const;
}
export const isCompanySlugAlreadyExistsError = (
e: unknown
): e is CompanySlugAlreadyExistsError => e instanceof CompanySlugAlreadyExistsError;

View File

@ -0,0 +1,4 @@
export * from "./company-status.vo";
export * from "./company-slug.vo";
export * from "./company.aggregate";
export * from "./errors";

View File

@ -0,0 +1 @@
export * from "./companies";

View File

@ -0,0 +1,46 @@
import type { IModuleServer } from "@erp/core/api";
import type { ICompanyPublicServices } from "./application";
import { companiesRouter, models } from "./infrastructure";
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
export type { ICompanyPublicServices } from "./application";
export * from "./application";
export * from "./domain";
export * from "./infrastructure";
export * from "./infrastructure/persistence/sequelize";
export type CompaniesPublicServicesType = ICompanyPublicServices;
export const companiesAPIModule: IModuleServer = {
name: "companies",
version: "1.0.0",
dependencies: ["identity"],
async setup(params) {
const { logger } = params;
const internal = buildCompaniesDependencies(params);
const companiesServices: ICompanyPublicServices = buildCompaniesPublicServices(
params,
internal
);
logger.info("🚀 Companies module dependencies registered", {
label: this.name,
});
return {
models,
services: {
general: companiesServices,
},
internal,
};
},
async start(params) {
companiesRouter(params);
},
};
export default companiesAPIModule;

View File

@ -0,0 +1,104 @@
import { buildTransactionManager, type ModuleParams, type SetupParams } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api";
import type { Sequelize } from "sequelize";
import {
CompanyCreator,
CompanyFinder,
CompanyPublicFinder,
CompanyPublicModelMapper,
CompanyStatusChanger,
CompanyUpdater,
CreateCompanyUseCase,
DisableCompanyUseCase,
EnableCompanyUseCase,
FindAvailableCompaniesForAccountUseCase,
GetCompanyByIdUseCase,
type ICompanyPublicServices,
UpdateCompanyUseCase,
buildCompanyPublicServices,
} from "../../application";
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
export type CompaniesInternalDeps = {
repository: SequelizeCompanyRepository;
publicFinder: CompanyPublicFinder;
useCases: {
createCompany: () => CreateCompanyUseCase;
getCompanyById: () => GetCompanyByIdUseCase;
findAvailableCompaniesForAccount: () => FindAvailableCompaniesForAccountUseCase;
updateCompany: () => UpdateCompanyUseCase;
enableCompany: () => EnableCompanyUseCase;
disableCompany: () => DisableCompanyUseCase;
};
};
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => {
const { database, getService } = params;
const transactionManager = buildTransactionManager(database as Sequelize);
const responseMapper = new CompanyPublicModelMapper();
const identityServices = getService<IIdentityPublicServices>("identity:general");
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
const finder = new CompanyFinder(repository);
const creator = new CompanyCreator(repository);
const updater = new CompanyUpdater({ finder, repository });
const statusChanger = new CompanyStatusChanger(repository);
const publicFinder = new CompanyPublicFinder({
repository,
mapper: responseMapper,
});
return {
repository,
publicFinder,
useCases: {
createCompany: () =>
new CreateCompanyUseCase({
creator,
responseMapper,
transactionManager,
}),
getCompanyById: () =>
new GetCompanyByIdUseCase({
finder,
responseMapper,
transactionManager,
}),
findAvailableCompaniesForAccount: () =>
new FindAvailableCompaniesForAccountUseCase({
identityServices,
repository,
responseMapper,
transactionManager,
}),
updateCompany: () =>
new UpdateCompanyUseCase({
updater,
responseMapper,
transactionManager,
}),
enableCompany: () =>
new EnableCompanyUseCase({
finder,
changer: statusChanger,
responseMapper,
transactionManager,
}),
disableCompany: () =>
new DisableCompanyUseCase({
finder,
changer: statusChanger,
responseMapper,
transactionManager,
}),
},
};
};
export const buildCompaniesPublicServices = (
_params: SetupParams,
deps: CompaniesInternalDeps
): ICompanyPublicServices => {
return buildCompanyPublicServices(deps.publicFinder);
};

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,2 @@
export * from "./companies.di";
export * from "./company-public-services";

View File

@ -0,0 +1,46 @@
import {
ApiErrorMapper,
ConflictApiError,
type ErrorToApiRule,
NotFoundApiError,
ValidationApiError,
} from "@erp/core/api";
import {
type CompanyCannotBeUpdatedError,
type CompanyNotFoundError,
type CompanySlugAlreadyExistsError,
isCompanyCannotBeUpdatedError,
isCompanyNotFoundError,
isCompanySlugAlreadyExistsError,
} from "../../domain";
const companyNotFoundRule: ErrorToApiRule = {
priority: 120,
matches: isCompanyNotFoundError,
build: (error) =>
new NotFoundApiError((error as CompanyNotFoundError).message || "Company not found."),
};
const companySlugAlreadyExistsRule: ErrorToApiRule = {
priority: 120,
matches: isCompanySlugAlreadyExistsError,
build: (error) =>
new ConflictApiError(
(error as CompanySlugAlreadyExistsError).message || "Company slug already exists."
),
};
const companyCannotBeUpdatedRule: ErrorToApiRule = {
priority: 120,
matches: isCompanyCannotBeUpdatedError,
build: (error) =>
new ValidationApiError(
(error as CompanyCannotBeUpdatedError).message || "Company cannot be updated."
),
};
export const companiesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(companyNotFoundRule)
.register(companySlugAlreadyExistsRule)
.register(companyCannotBeUpdatedRule);

View File

@ -0,0 +1,85 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireIdentityAuthenticated } from "@erp/identity/api";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
import {
ChangeCompanyStatusRequestSchema,
CreateCompanyRequestSchema,
GetCompanyByIdRequestSchema,
UpdateCompanyByIdParamsRequestSchema,
UpdateCompanyRequestSchema,
} from "../../../common";
import type { CompaniesInternalDeps } from "../di";
import {
CreateCompanyController,
DisableCompanyController,
EnableCompanyController,
FindAvailableCompaniesController,
GetCompanyByIdController,
UpdateCompanyController,
} from "./controllers";
export const companiesRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const deps = getInternal<CompaniesInternalDeps>("companies");
const router = Router({ mergeParams: true });
router.use(...requireIdentityAuthenticated(params));
router.post(
"/",
validateRequest(CreateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new CreateCompanyController(deps.useCases.createCompany());
return controller.execute(req, res, next);
}
);
router.get("/available", (req: Request, res: Response, next: NextFunction) => {
const controller = new FindAvailableCompaniesController(
deps.useCases.findAvailableCompaniesForAccount()
);
return controller.execute(req, res, next);
});
router.get(
"/:company_id",
validateRequest(GetCompanyByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById());
return controller.execute(req, res, next);
}
);
router.patch(
"/:company_id",
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
validateRequest(UpdateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCompanyController(deps.useCases.updateCompany());
return controller.execute(req, res, next);
}
);
router.post(
"/:company_id/enable",
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new EnableCompanyController(deps.useCases.enableCompany());
return controller.execute(req, res, next);
}
);
router.post(
"/:company_id/disable",
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new DisableCompanyController(deps.useCases.disableCompany());
return controller.execute(req, res, next);
}
);
app.use(`${config.server.apiBasePath}/companies`, router);
};

View File

@ -0,0 +1,23 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { CreateCompanyRequestDTO } from "../../../../common";
import type { CreateCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class CreateCompanyController extends ExpressController {
public constructor(private readonly useCase: CreateCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const dto = this.req.body as CreateCompanyRequestDTO;
const result = await this.useCase.execute({ dto });
return result.match(
(data) => this.created(data),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,26 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { DisableCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class DisableCompanyController extends ExpressController {
public constructor(private readonly useCase: DisableCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const { company_id } = this.req.params;
if (!company_id) {
return this.invalidInputError("Company ID missing");
}
const result = await this.useCase.execute({ company_id });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,26 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { EnableCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class EnableCompanyController extends ExpressController {
public constructor(private readonly useCase: EnableCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const { company_id } = this.req.params;
if (!company_id) {
return this.invalidInputError("Company ID missing");
}
const result = await this.useCase.execute({ company_id });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,28 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { FindAvailableCompaniesForAccountUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class FindAvailableCompaniesController extends ExpressController {
public constructor(private readonly useCase: FindAvailableCompaniesForAccountUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) {
return this.unauthorizedError("Unauthorized");
}
const result = await this.useCase.execute({
accountId: user.userId.toPrimitive(),
});
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,26 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { GetCompanyByIdUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class GetCompanyByIdController extends ExpressController {
public constructor(private readonly useCase: GetCompanyByIdUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const { company_id } = this.req.params;
if (!company_id) {
return this.invalidInputError("Company ID missing");
}
const result = await this.useCase.execute({ company_id });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,6 @@
export * from "./create-company.controller";
export * from "./disable-company.controller";
export * from "./enable-company.controller";
export * from "./find-available-companies.controller";
export * from "./get-company-by-id.controller";
export * from "./update-company.controller";

View File

@ -0,0 +1,28 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { UpdateCompanyRequestDTO } from "../../../../common";
import type { UpdateCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class UpdateCompanyController extends ExpressController {
public constructor(private readonly useCase: UpdateCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const { company_id } = this.req.params;
if (!company_id) {
return this.invalidInputError("Company ID missing");
}
const dto = this.req.body as UpdateCompanyRequestDTO;
const result = await this.useCase.execute({ company_id, dto });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,3 @@
export * from "./companies.routes";
export * from "./companies-api-error-mapper";
export * from "./controllers";

View File

@ -0,0 +1,3 @@
export * from "./di";
export * from "./express";
export * from "./persistence";

View File

@ -0,0 +1 @@
export * from "./sequelize";

View File

@ -0,0 +1,7 @@
import companyModelInit from "./models/sequelize-company.model";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export const models = [companyModelInit];

View File

@ -0,0 +1 @@
export * from "./sequelize-company-domain.mapper";

View File

@ -0,0 +1,115 @@
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Company, CompanySlug, CompanyStatus, type CompanyInternalProps } from "../../../../domain";
import type { CompanyCreationAttributes, CompanyModel } from "../models";
function toUtcIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value;
}
export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
CompanyModel,
CompanyCreationAttributes,
Company
> {
public mapToDomain(source: CompanyModel, _params?: MapperParamsType): Result<Company, Error> {
try {
const errors: ValidationErrorDetail[] = [];
const id = extractOrPushError(UniqueID.create(source.id), "id", errors);
const legalName = extractOrPushError(Name.create(source.legal_name), "legal_name", errors);
const tradeName = extractOrPushError(
maybeFromNullableResult(source.trade_name, (value) => Name.create(value)),
"trade_name",
errors
);
const tin = extractOrPushError(
maybeFromNullableResult(source.tin, (value) => TextValue.create(value)),
"tin",
errors
);
const slug = extractOrPushError(CompanySlug.create(source.slug), "slug", errors);
const email = extractOrPushError(
maybeFromNullableResult(source.email, (value) => EmailAddress.create(value)),
"email",
errors
);
const phone = extractOrPushError(
maybeFromNullableResult(source.phone, (value) => PhoneNumber.create(value)),
"phone",
errors
);
const website = extractOrPushError(
maybeFromNullableResult(source.website, (value) => URLAddress.create(value)),
"website",
errors
);
const status = extractOrPushError(CompanyStatus.create(source.status), "status", errors);
const createdAt = extractOrPushError(
UtcDateTime.createFromISO(toUtcIsoString(source.created_at)),
"created_at",
errors
);
const updatedAt = extractOrPushError(
UtcDateTime.createFromISO(toUtcIsoString(source.updated_at)),
"updated_at",
errors
);
if (errors.length > 0) {
return Result.fail(new ValidationErrorCollection("Company props mapping failed", errors));
}
const props: CompanyInternalProps = {
legalName: legalName!,
tradeName: tradeName!,
tin: tin!,
slug: slug!,
email: email!,
phone: phone!,
website: website!,
status: status!,
createdAt: createdAt!,
updatedAt: updatedAt!,
};
return Result.ok(Company.rehydrate(props, id!));
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
public mapToPersistence(
source: Company,
_params?: MapperParamsType
): Result<CompanyCreationAttributes, Error> {
return Result.ok<CompanyCreationAttributes>({
id: source.id.toPrimitive(),
legal_name: source.legalName.toPrimitive(),
trade_name: maybeToNullable(source.tradeName, (value: Name) => value.toPrimitive()),
tin: maybeToNullable(source.tin, (value: TextValue) => value.toPrimitive()),
slug: source.slug.toPrimitive(),
email: maybeToNullable(source.email, (value: EmailAddress) => value.toPrimitive()),
phone: maybeToNullable(source.phone, (value: PhoneNumber) => value.toPrimitive()),
website: maybeToNullable(source.website, (value: URLAddress) => value.toPrimitive()),
status: source.status.toPrimitive(),
created_at: source.createdAt.toISOString(),
updated_at: source.updatedAt.toISOString(),
});
}
}

View File

@ -0,0 +1 @@
export * from "./sequelize-company.model";

View File

@ -0,0 +1,119 @@
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export type CompanyCreationAttributes = InferCreationAttributes<CompanyModel, {}>;
export class CompanyModel extends Model<
InferAttributes<CompanyModel>,
InferCreationAttributes<CompanyModel>
> {
declare id: string;
declare legal_name: string;
declare trade_name: string | null;
declare tin: string | null;
declare slug: string;
declare email: string | null;
declare phone: string | null;
declare website: string | null;
declare status: string;
declare created_at: string;
declare updated_at: string;
static associate(_database: Sequelize) {}
static hooks(_database: Sequelize) {}
}
export default (database: Sequelize) => {
CompanyModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
legal_name: {
type: DataTypes.STRING,
allowNull: false,
},
trade_name: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
tin: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
slug: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
email: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isEmail: true,
},
},
phone: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
website: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isUrl: true,
},
},
status: {
type: DataTypes.STRING,
allowNull: false,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize: database,
modelName: "CompanyModel",
tableName: "companies",
underscored: true,
timestamps: false,
indexes: [
{
name: "uq_companies_slug",
fields: ["slug"],
unique: true,
},
{
name: "idx_companies_status",
fields: ["status"],
},
{
name: "idx_companies_tin",
fields: ["tin"],
},
],
defaultScope: {},
scopes: {},
}
);
return CompanyModel;
};

View File

@ -0,0 +1 @@
export * from "./sequelize-company.repository";

View File

@ -0,0 +1,170 @@
import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { Op } from "sequelize";
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { ICompanyRepository } from "../../../../application";
import { COMPANY_STATUS, type Company } from "../../../../domain";
import type { SequelizeCompanyDomainMapper } from "../mappers";
import { CompanyModel } from "../models";
function asTransaction(transaction?: unknown): Transaction | undefined {
return transaction as Transaction | undefined;
}
export class SequelizeCompanyRepository
extends SequelizeRepository<Company>
implements ICompanyRepository
{
public constructor(
private readonly domainMapper: SequelizeCompanyDomainMapper,
database: Sequelize
) {
super({ database });
}
public async save(company: Company, transaction?: unknown): Promise<Result<void, Error>> {
try {
const persistenceResult = this.domainMapper.mapToPersistence(company);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);
}
await CompanyModel.create(persistenceResult.data, {
transaction: asTransaction(transaction),
});
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async update(company: Company, transaction?: unknown): Promise<Result<void, Error>> {
try {
const persistenceResult = this.domainMapper.mapToPersistence(company);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);
}
const { id, ...payload } = persistenceResult.data;
const [affectedRows] = await CompanyModel.update(payload, {
where: { id },
transaction: asTransaction(transaction),
});
if (affectedRows === 0) {
return Result.fail(new Error("Company not found for update."));
}
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findById(
id: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<Company>, Error>> {
try {
const row = await CompanyModel.findByPk(id.toPrimitive(), {
transaction: asTransaction(transaction),
});
if (!row) {
return Result.ok(Maybe.none<Company>());
}
const companyResult = this.domainMapper.mapToDomain(row);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
return Result.ok(Maybe.some(companyResult.data));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findBySlug(
slug: Company["slug"],
transaction?: unknown
): Promise<Result<Maybe<Company>, Error>> {
try {
const row = await CompanyModel.findOne({
where: { slug: slug.toPrimitive() },
transaction: asTransaction(transaction),
});
if (!row) {
return Result.ok(Maybe.none<Company>());
}
const companyResult = this.domainMapper.mapToDomain(row);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
return Result.ok(Maybe.some(companyResult.data));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async existsActiveById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>> {
try {
const count = await CompanyModel.count({
where: {
id: params.companyId.toPrimitive(),
status: COMPANY_STATUS.ACTIVE,
},
transaction: asTransaction(params.transaction),
});
return Result.ok(count > 0);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findActiveByIds(params: {
companyIds: Collection<UniqueID>;
transaction?: unknown;
}): Promise<Result<Collection<Company>, Error>> {
try {
const ids = [...new Set(params.companyIds.getAll().map((companyId) => companyId.toPrimitive()))];
if (ids.length === 0) {
return Result.ok(new Collection([]));
}
const rows = await CompanyModel.findAll({
where: {
id: { [Op.in]: ids },
status: COMPANY_STATUS.ACTIVE,
},
transaction: asTransaction(params.transaction),
});
const companies: Company[] = [];
for (const row of rows) {
const companyResult = this.domainMapper.mapToDomain(row);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
companies.push(companyResult.data);
}
return Result.ok(new Collection(companies));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./request";
export * from "./response";

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const ChangeCompanyStatusRequestSchema = z.object({
company_id: z.uuid(),
});
export type ChangeCompanyStatusRequestDTO = z.infer<typeof ChangeCompanyStatusRequestSchema>;

View File

@ -0,0 +1,15 @@
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
import { z } from "zod/v4";
export const CreateCompanyRequestSchema = z.object({
id: z.uuid(),
legal_name: z.string().min(1),
trade_name: z.string().nullable().optional(),
tin: TinSchema.nullable().optional(),
slug: z.string().min(1),
email: EmailSchema.nullable().optional(),
phone: z.string().nullable().optional(),
website: WebsiteSchema.nullable().optional(),
});
export type CreateCompanyRequestDTO = z.infer<typeof CreateCompanyRequestSchema>;

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const GetCompanyByIdRequestSchema = z.object({
company_id: z.uuid(),
});
export type GetCompanyByIdRequestDTO = z.infer<typeof GetCompanyByIdRequestSchema>;

View File

@ -0,0 +1,4 @@
export * from "./change-company-status.request.dto";
export * from "./create-company.request.dto";
export * from "./get-company-by-id.request.dto";
export * from "./update-company.request.dto";

View File

@ -0,0 +1,20 @@
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
import { z } from "zod/v4";
export const UpdateCompanyByIdParamsRequestSchema = z.object({
company_id: z.uuid(),
});
export type UpdateCompanyByIdParamsRequestDTO = z.infer<typeof UpdateCompanyByIdParamsRequestSchema>;
export const UpdateCompanyRequestSchema = z.object({
legal_name: z.string().min(1).optional(),
trade_name: z.string().nullable().optional(),
tin: TinSchema.nullable().optional(),
slug: z.string().min(1).optional(),
email: EmailSchema.nullable().optional(),
phone: z.string().nullable().optional(),
website: WebsiteSchema.nullable().optional(),
});
export type UpdateCompanyRequestDTO = z.infer<typeof UpdateCompanyRequestSchema>;

View File

@ -0,0 +1,21 @@
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
import { z } from "zod/v4";
export const AvailableCompanySchema = z.object({
id: z.uuid(),
legal_name: z.string(),
trade_name: z.string().nullable(),
tin: TinSchema.nullable(),
slug: z.string(),
email: EmailSchema.nullable(),
phone: z.string().nullable(),
website: WebsiteSchema.nullable(),
status: z.literal("active"),
});
export const AvailableCompaniesResponseSchema = z.object({
companies: z.array(AvailableCompanySchema),
});
export type AvailableCompanyDTO = z.infer<typeof AvailableCompanySchema>;
export type AvailableCompaniesResponseDTO = z.infer<typeof AvailableCompaniesResponseSchema>;

View File

@ -0,0 +1,18 @@
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
import { z } from "zod/v4";
export const CompanyStatusResponseSchema = z.enum(["active", "disabled"]);
export const CompanyResponseSchema = z.object({
id: z.uuid(),
legal_name: z.string(),
trade_name: z.string().nullable(),
tin: TinSchema.nullable(),
slug: z.string(),
email: EmailSchema.nullable(),
phone: z.string().nullable(),
website: WebsiteSchema.nullable(),
status: CompanyStatusResponseSchema,
});
export type CompanyResponseDTO = z.infer<typeof CompanyResponseSchema>;

View File

@ -0,0 +1,2 @@
export * from "./available-companies.response.dto";
export * from "./company.response.dto";

View File

@ -0,0 +1 @@
export * from "./dto";

View File

@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@erp/companies/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -14,6 +14,7 @@ export interface AxiosFactoryConfig {
* Debe devolver `null` si no hay token disponible.
*/
getAccessToken: () => string | null;
getActiveCompanyId: () => string | null;
/**
* Función opcional que se ejecuta cuando ocurre un error de autenticación (por ejemplo, 401).
@ -39,10 +40,11 @@ export const defaultAxiosRequestConfig: CreateAxiosDefaults = {
export const createAxiosInstance = ({
baseURL,
getAccessToken,
getActiveCompanyId,
onAuthError,
}: AxiosFactoryConfig): AxiosInstance => {
const instance = axios.create(defaultAxiosRequestConfig);
instance.defaults.baseURL = baseURL;
return setupInterceptors(instance, getAccessToken, onAuthError);
return setupInterceptors(instance, getAccessToken, getActiveCompanyId, onAuthError);
};

View File

@ -1,5 +1,35 @@
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
function resolvePathname(config: InternalAxiosRequestConfig, baseURL?: string) {
const requestUrl = config.url;
if (!requestUrl) {
return null;
}
try {
return new URL(requestUrl, baseURL).pathname;
} catch {
return null;
}
}
function shouldAttachCompanyHeader(pathname: string | null) {
if (!pathname) {
return false;
}
const excludedPathSuffixes = [
"/identity/auth/login",
"/identity/auth/refresh",
"/identity/auth/logout",
"/identity/auth/session",
"/companies/available",
];
return !excludedPathSuffixes.some((suffix) => pathname.endsWith(suffix));
}
/**
* Configura interceptores para una instancia de Axios.
*
@ -10,6 +40,7 @@ import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axio
export const setupInterceptors = (
axiosInstance: AxiosInstance,
getAccessToken: () => string | null,
getActiveCompanyId: () => string | null,
onAuthError?: () => void
) => {
axiosInstance.interceptors.request.use(
@ -18,6 +49,14 @@ export const setupInterceptors = (
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
const activeCompanyId = getActiveCompanyId();
const pathname = resolvePathname(config, axiosInstance.defaults.baseURL);
if (activeCompanyId && config.headers && shouldAttachCompanyHeader(pathname)) {
config.headers["X-Company-Id"] = activeCompanyId;
}
return config;
},
(error: unknown) => {

View File

@ -10,6 +10,7 @@ export type ModuleRouteLayout = "app-sidebar" | "auth" | "app-fullscreen";
export interface ModuleRouteHandle {
layout?: ModuleRouteLayout;
protected?: boolean;
requireCompany?: boolean;
}
export type ModuleRouteObject = Omit<RouteObject, "children" | "handle"> & {

View File

@ -15,7 +15,7 @@ export type { IProformaPublicServices } from "./application";
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
version: "1.0.0",
dependencies: ["catalogs", "customers", "identity"],
dependencies: ["catalogs", "customers", "identity", "companies"],
/**
* Fase de SETUP

View File

@ -10,7 +10,7 @@ export * from "./infrastructure/persistence/sequelize";
export const customersAPIModule: IModuleServer = {
name: "customers",
version: "1.0.0",
dependencies: ["catalogs", "identity"],
dependencies: ["catalogs", "identity", "companies"],
/**
* Fase de SETUP

View File

@ -6,7 +6,7 @@ import { buildFactugesDependencies } from "./infraestructure/di";
export const factugesAPIModule: IModuleServer = {
name: "factuges",
version: "1.0.0",
dependencies: ["catalogs", "customers", "customer-invoices", "identity"],
dependencies: ["catalogs", "customers", "customer-invoices", "identity", "companies"],
/**
* Fase de SETUP

View File

@ -5,5 +5,5 @@ import type { Company } from "../../../domain";
export interface ICompanyRepository {
save(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
update(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
findById(id: Company["id"], transaction?: unknown): Promise<Result<Maybe<Company>, Error>>;
findById(id: UniqueID, transaction?: unknown): Promise<Result<Maybe<Company>, Error>>;
}

View File

@ -0,0 +1,14 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
export interface ICompanyMembershipAccessRepository {
existsActiveByAccountAndCompany(params: {
accountId: UniqueID;
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
findActiveCompanyIdsByAccount(params: {
accountId: UniqueID;
transaction?: unknown;
}): Promise<Result<Collection<UniqueID>, Error>>;
}

View File

@ -1 +1,2 @@
export * from "./company-membership-access-repository.interface";
export * from "./company-membership-repository.interface";

View File

@ -1 +1,2 @@
export * from "./contracts";
export * from "./services";

View File

@ -0,0 +1,48 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { ICompanyMembershipAccessRepository } from "../contracts";
export interface IIdentityCompanyAccessChecker {
canAccessCompany(params: {
accountId: UniqueID;
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
findAccessibleCompanyIds(params: {
accountId: UniqueID;
transaction?: unknown;
}): Promise<Result<Collection<UniqueID>, Error>>;
}
export class IdentityCompanyAccessChecker implements IIdentityCompanyAccessChecker {
public constructor(private readonly repository: ICompanyMembershipAccessRepository) {}
public async canAccessCompany(params: {
accountId: UniqueID;
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>> {
const result = await this.repository.existsActiveByAccountAndCompany(params);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
public async findAccessibleCompanyIds(params: {
accountId: UniqueID;
transaction?: unknown;
}): Promise<Result<Collection<UniqueID>, Error>> {
const result = await this.repository.findActiveCompanyIdsByAccount(params);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
}

View File

@ -0,0 +1 @@
export * from "./company-access-checker";

View File

@ -1,10 +1,23 @@
import type { RequestHandler } from "express";
import type { Result } from "@repo/rdx-utils";
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
export type AuthenticatedOnlyMiddlewares = readonly [RequestHandler, RequestHandler];
export type TenantRequiredMiddlewares = readonly [RequestHandler, RequestHandler, RequestHandler];
export interface IIdentityTenantCompanyFinder {
existsActiveById(params: {
companyId: import("@repo/rdx-ddd").UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
}
export interface IIdentityAuthPublicServices {
authenticatedOnly(): AuthenticatedOnlyMiddlewares;
tenantRequired(): TenantRequiredMiddlewares;
tenantRequired(params: {
companyAccessChecker: IIdentityCompanyAccessChecker;
companyFinder: IIdentityTenantCompanyFinder;
}): TenantRequiredMiddlewares;
}

View File

@ -1,5 +1,7 @@
import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
export interface IIdentityPublicServices {
auth: IIdentityAuthPublicServices;
companyAccess: IIdentityCompanyAccessChecker;
}

View File

@ -5,7 +5,7 @@ import {
type Name,
type URLAddress,
type UniqueID,
type UtcDate,
type UtcDateTime,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
@ -20,8 +20,8 @@ export interface IAccountCreateProps {
avatarUrl: Maybe<URLAddress>;
languageCode: LanguageCode;
status: AccountStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
createdAt: UtcDateTime;
updatedAt: UtcDateTime;
}
export type AccountProfilePatchProps = Partial<{
@ -29,7 +29,7 @@ export type AccountProfilePatchProps = Partial<{
avatarUrl: Maybe<URLAddress>;
languageCode: LanguageCode;
}> & {
updatedAt: UtcDate;
updatedAt: UtcDateTime;
};
export type AccountInternalProps = IAccountCreateProps;
@ -104,11 +104,11 @@ export class Account extends AggregateRoot<AccountInternalProps> {
return this.props.status;
}
public get createdAt(): UtcDate {
public get createdAt(): UtcDateTime {
return this.props.createdAt;
}
public get updatedAt(): UtcDate {
public get updatedAt(): UtcDateTime {
return this.props.updatedAt;
}
@ -147,7 +147,7 @@ export class Account extends AggregateRoot<AccountInternalProps> {
public changePasswordHash(params: {
passwordHash: PasswordHash;
updatedAt: UtcDate;
updatedAt: UtcDateTime;
}): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
@ -162,7 +162,7 @@ export class Account extends AggregateRoot<AccountInternalProps> {
return Result.ok(true);
}
public disable(updatedAt: UtcDate): Result<boolean, Error> {
public disable(updatedAt: UtcDateTime): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.ok(false);
}
@ -172,7 +172,7 @@ export class Account extends AggregateRoot<AccountInternalProps> {
return Result.ok(true);
}
public enable(updatedAt: UtcDate): Result<boolean, Error> {
public enable(updatedAt: UtcDateTime): Result<boolean, Error> {
if (this.status.isActive()) {
return Result.ok(false);
}

View File

@ -1,5 +1,7 @@
import type {
IIdentityAuthPublicServices,
IIdentityCompanyAccessChecker,
IIdentityTenantCompanyFinder,
IIdentityPublicServices,
} from "../../application";
import {
@ -19,11 +21,14 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices {
return [authenticateUser(this.deps), requireAuthenticated()] as const;
}
public tenantRequired() {
public tenantRequired(params: {
companyAccessChecker: IIdentityCompanyAccessChecker;
companyFinder: IIdentityTenantCompanyFinder;
}) {
return [
authenticateUser(this.deps),
requireAuthenticated(),
requireCompanyContext(),
requireCompanyContext(params),
] as const;
}
}
@ -31,5 +36,6 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices {
export function buildIdentityPublicServices(deps: IdentityInternalDeps): IIdentityPublicServices {
return {
auth: new IdentityAuthPublicServices(deps.auth.authenticateUserDependencies),
companyAccess: deps.companyAccess.checker,
};
}

View File

@ -1,10 +1,15 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import {
type IIdentityCompanyAccessChecker,
IdentityCompanyAccessChecker,
} from "../../application";
import type { IAccountRepository } from "../../application/accounts";
import {
AccountAuthenticator,
type IAccessTokenVerifier,
type GetCurrentSessionUseCase,
GetCurrentSessionUseCase as GetCurrentSessionUseCaseImpl,
type IAccessTokenVerifier,
type LoginWithPasswordUseCase,
LoginWithPasswordUseCase as LoginWithPasswordUseCaseImpl,
type LogoutUseCase,
@ -13,11 +18,11 @@ import {
RefreshSessionUseCase as RefreshSessionUseCaseImpl,
SessionIssuer,
} from "../../application/authentication";
import type { IAccountRepository } from "../../application/accounts";
import { identityAuthenticateRequest } from "../express/middlewares";
import {
SequelizeAccountDomainMapper,
SequelizeAccountRepository,
SequelizeCompanyMembershipAccessRepository,
SequelizeRefreshTokenDomainMapper,
SequelizeRefreshTokenRepository,
} from "../persistence";
@ -44,6 +49,9 @@ export type IdentityInternalDeps = {
getCurrentSession: () => GetCurrentSessionUseCase;
};
};
companyAccess: {
checker: IIdentityCompanyAccessChecker;
};
};
export const buildIdentityDependencies = (params: ModuleParams): IdentityInternalDeps => {
@ -59,6 +67,9 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
new SequelizeRefreshTokenDomainMapper(),
database
);
const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository(
database
);
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
@ -120,5 +131,8 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
}),
},
},
companyAccess: {
checker: new IdentityCompanyAccessChecker(companyMembershipAccessRepository),
},
};
};

View File

@ -2,15 +2,12 @@ import {
ExpressController,
type RequestWithAuth,
UnauthorizedApiError,
ValidationApiError,
} from "@erp/core/api";
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import type { NextFunction, Request, Response } from "express";
import type { IAccessTokenVerifier, IAccountRepository } from "../../../application";
const COMPANY_HEADER_NAME = "X-Company-Id";
function parseBearerToken(authorization?: string): string | undefined {
if (!authorization) {
return undefined;
@ -24,20 +21,6 @@ function parseBearerToken(authorization?: string): string | undefined {
return token?.trim() || undefined;
}
function getCompanyIdFromHeader(req: Request): UniqueID | undefined | Error {
const rawCompanyId = req.get(COMPANY_HEADER_NAME);
if (!rawCompanyId) {
return undefined;
}
const companyIdResult = UniqueID.create(rawCompanyId);
if (companyIdResult.isFailure) {
return new Error(`Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`);
}
return companyIdResult.data;
}
export function authenticateUser(params: {
accessTokenVerifier: IAccessTokenVerifier;
accountRepository: IAccountRepository;
@ -77,24 +60,9 @@ export function authenticateUser(params: {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const companyIdFromHeader = getCompanyIdFromHeader(req);
if (companyIdFromHeader instanceof Error) {
return ExpressController.errorResponse(
new ValidationApiError(companyIdFromHeader.message, [
{
field: COMPANY_HEADER_NAME,
message: companyIdFromHeader.message,
},
]),
req,
res
);
}
(req as RequestWithAuth).user = {
userId: account.id,
email: emailResult.data,
companyId: companyIdFromHeader,
roles: [],
};
@ -104,4 +72,3 @@ export function authenticateUser(params: {
}
};
}

View File

@ -1,12 +1,24 @@
import { ExpressController, ForbiddenApiError, type RequestWithAuth } from "@erp/core/api";
import {
ExpressController,
ForbiddenApiError,
InternalApiError,
type RequestWithAuth,
ValidationApiError,
} from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import type { NextFunction, Request, Response } from "express";
export function requireCompanyContext() {
return (req: Request, res: Response, next: NextFunction) => {
/**
* TODO: validar membership real cuando exista persistencia de CompanyMembership.
*/
if (!(req as RequestWithAuth).user?.companyId) {
import type { IIdentityCompanyAccessChecker, IIdentityTenantCompanyFinder } from "../../../application";
const COMPANY_HEADER_NAME = "X-Company-Id";
export function requireCompanyContext(params: {
companyAccessChecker: IIdentityCompanyAccessChecker;
companyFinder: IIdentityTenantCompanyFinder;
}) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = (req as RequestWithAuth).user;
if (!user?.userId) {
return ExpressController.errorResponse(
new ForbiddenApiError("Company context required"),
req,
@ -14,6 +26,72 @@ export function requireCompanyContext() {
);
}
const rawCompanyId = req.get(COMPANY_HEADER_NAME);
if (!rawCompanyId) {
return ExpressController.errorResponse(
new ForbiddenApiError("Company context required"),
req,
res
);
}
const companyIdResult = UniqueID.create(rawCompanyId);
if (companyIdResult.isFailure) {
return ExpressController.errorResponse(
new ValidationApiError(`Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`, [
{
field: COMPANY_HEADER_NAME,
message: `Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`,
},
]),
req,
res
);
}
const companyId = companyIdResult.data;
const canAccessResult = await params.companyAccessChecker.canAccessCompany({
accountId: user.userId,
companyId,
});
if (canAccessResult.isFailure) {
return ExpressController.errorResponse(
new InternalApiError(canAccessResult.error.message),
req,
res
);
}
if (!canAccessResult.data) {
return ExpressController.errorResponse(
new ForbiddenApiError("Company context required"),
req,
res
);
}
const companyExistsResult = await params.companyFinder.existsActiveById({
companyId,
});
if (companyExistsResult.isFailure) {
return ExpressController.errorResponse(
new InternalApiError(companyExistsResult.error.message),
req,
res
);
}
if (!companyExistsResult.data) {
return ExpressController.errorResponse(
new ForbiddenApiError("Company context required"),
req,
res
);
}
user.companyId = companyId;
next();
};
}

View File

@ -3,6 +3,15 @@ import type { RequestHandler } from "express";
import type { IIdentityPublicServices } from "../../../application";
type CompaniesGeneralServices = {
finder: {
existsActiveById(params: {
companyId: import("@repo/rdx-ddd").UniqueID;
transaction?: unknown;
}): Promise<import("@repo/rdx-utils").Result<boolean, Error>>;
};
};
export function requireIdentityAuthenticated(params: StartParams): RequestHandler[] {
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
return [...identityServices.auth.authenticatedOnly()];
@ -10,5 +19,11 @@ export function requireIdentityAuthenticated(params: StartParams): RequestHandle
export function requireIdentityTenant(params: StartParams): RequestHandler[] {
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
return [...identityServices.auth.tenantRequired()];
const companiesServices = params.getService<CompaniesGeneralServices>("companies:general");
return [
...identityServices.auth.tenantRequired({
companyAccessChecker: identityServices.companyAccess,
companyFinder: companiesServices.finder,
}),
];
}

View File

@ -1,8 +1,9 @@
import accountModelInit from "./models/sequelize-account.model";
import companyMembershipModelInit from "./models/sequelize-company-membership.model";
import refreshTokenModelInit from "./models/sequelize-refresh-token.model";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export const models = [accountModelInit, refreshTokenModelInit];
export const models = [accountModelInit, refreshTokenModelInit, companyMembershipModelInit];

View File

@ -1,2 +1,3 @@
export * from "./sequelize-account.model";
export * from "./sequelize-company-membership.model";
export * from "./sequelize-refresh-token.model";

View File

@ -0,0 +1,90 @@
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export type CompanyMembershipCreationAttributes = InferCreationAttributes<CompanyMembershipModel, {}>;
export class CompanyMembershipModel extends Model<
InferAttributes<CompanyMembershipModel>,
InferCreationAttributes<CompanyMembershipModel>
> {
declare id: string;
declare account_id: string;
declare company_id: string;
declare status: string;
declare created_at: string;
declare updated_at: string;
static associate(_database: Sequelize) {}
static hooks(_database: Sequelize) {}
}
export default (database: Sequelize) => {
CompanyMembershipModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
account_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "identity_accounts",
key: "id",
},
},
company_id: {
type: DataTypes.UUID,
allowNull: false,
},
status: {
type: DataTypes.STRING,
allowNull: false,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize: database,
modelName: "CompanyMembershipModel",
tableName: "identity_company_memberships",
underscored: true,
timestamps: false,
indexes: [
{
name: "uq_identity_company_memberships_account_company",
fields: ["account_id", "company_id"],
unique: true,
},
{
name: "idx_identity_company_memberships_account_id",
fields: ["account_id"],
},
{
name: "idx_identity_company_memberships_company_id",
fields: ["company_id"],
},
{
name: "idx_identity_company_memberships_status",
fields: ["status"],
},
],
defaultScope: {},
scopes: {},
}
);
return CompanyMembershipModel;
};

View File

@ -1,2 +1,3 @@
export * from "./sequelize-account.repository";
export * from "./sequelize-company-membership-access.repository";
export * from "./sequelize-refresh-token.repository";

View File

@ -0,0 +1,73 @@
import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { ICompanyMembershipAccessRepository } from "../../../../application";
import { COMPANY_MEMBERSHIP_STATUS } from "../../../../domain";
import { CompanyMembershipModel } from "../models";
function asTransaction(transaction?: unknown): Transaction | undefined {
return transaction as Transaction | undefined;
}
export class SequelizeCompanyMembershipAccessRepository
extends SequelizeRepository<never>
implements ICompanyMembershipAccessRepository
{
public constructor(database: Sequelize) {
super({ database });
}
public async existsActiveByAccountAndCompany(params: {
accountId: import("@repo/rdx-ddd").UniqueID;
companyId: import("@repo/rdx-ddd").UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>> {
try {
const count = await CompanyMembershipModel.count({
where: {
account_id: params.accountId.toPrimitive(),
company_id: params.companyId.toPrimitive(),
status: COMPANY_MEMBERSHIP_STATUS.ACTIVE,
},
transaction: asTransaction(params.transaction),
});
return Result.ok(count > 0);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findActiveCompanyIdsByAccount(params: {
accountId: import("@repo/rdx-ddd").UniqueID;
transaction?: unknown;
}): Promise<Result<Collection<UniqueID>, Error>> {
try {
const rows = await CompanyMembershipModel.findAll({
attributes: ["company_id"],
where: {
account_id: params.accountId.toPrimitive(),
status: COMPANY_MEMBERSHIP_STATUS.ACTIVE,
},
transaction: asTransaction(params.transaction),
});
const companyIds: UniqueID[] = [];
for (const row of rows) {
const companyIdResult = UniqueID.create(row.company_id);
if (companyIdResult.isFailure) {
return Result.fail(companyIdResult.error);
}
companyIds.push(companyIdResult.data);
}
return Result.ok(new Collection(companyIds));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -1,7 +1,9 @@
import type { ModuleClientParams } from "@erp/core/client";
import type { RouteObject } from "react-router-dom";
import { CompanySelectionPage } from "./company-selection";
import { LoginPage } from "./login";
import { NoCompaniesPage } from "./no-companies";
export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] {
return [
@ -13,6 +15,23 @@ export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] {
},
element: <LoginPage />,
},
{
path: "company-selection",
handle: {
layout: "app-fullscreen",
protected: true,
requireCompany: false,
},
element: <CompanySelectionPage />,
},
{
path: "no-companies",
handle: {
layout: "app-fullscreen",
protected: true,
requireCompany: false,
},
element: <NoCompaniesPage />,
},
];
}

View File

@ -0,0 +1 @@
export * from "./use-company-selection-page-controller";

View File

@ -0,0 +1,59 @@
import { useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useIdentityAuthSession } from "../../context";
function resolveSafeReturnTo(returnTo: string | null) {
if (!returnTo) {
return null;
}
if (!returnTo.startsWith("/") || returnTo.startsWith("//")) {
return null;
}
return returnTo;
}
export function useCompanySelectionPageController() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const {
availableCompanies,
activeCompany,
isAuthenticated,
isLoading,
logout,
selectActiveCompany,
getAuthenticatedRedirectPath,
} = useIdentityAuthSession();
const returnTo = useMemo(() => resolveSafeReturnTo(searchParams.get("returnTo")), [searchParams]);
const handleSelectCompany = (companyId: string) => {
const selectedCompany = selectActiveCompany(companyId);
if (!selectedCompany) {
return;
}
navigate(returnTo ?? "/proformas", { replace: true });
};
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return {
activeCompany,
availableCompanies,
isAuthenticated,
isLoading,
handleLogout,
handleSelectCompany,
redirectPath: getAuthenticatedRedirectPath({
returnTo,
}),
};
}

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