Login + selección de empresas
This commit is contained in:
parent
faf7b2251c
commit
3e5791afdb
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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}</>;
|
||||
|
||||
29
apps/web/src/routes/require-company-guard.tsx
Normal file
29
apps/web/src/routes/require-company-guard.tsx
Normal 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)}`} />;
|
||||
};
|
||||
@ -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
|
||||
|
||||
33
modules/companies/package.json
Normal file
33
modules/companies/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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>>;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-repository.interface";
|
||||
4
modules/companies/src/api/application/companies/index.ts
Normal file
4
modules/companies/src/api/application/companies/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./contracts";
|
||||
export * from "./services";
|
||||
export * from "./use-cases";
|
||||
export * from "./public";
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./mappers";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-public-model.mapper";
|
||||
@ -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";
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-public.model";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import type { ICompanyPublicFinder } from "./company-public-finder";
|
||||
|
||||
export interface ICompanyPublicServices {
|
||||
finder: ICompanyPublicFinder;
|
||||
}
|
||||
|
||||
export const buildCompanyPublicServices = (
|
||||
finder: ICompanyPublicFinder
|
||||
): ICompanyPublicServices => ({
|
||||
finder,
|
||||
});
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./company-public-finder";
|
||||
export * from "./company-public-services";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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."))
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./company-creator";
|
||||
export * from "./company-finder";
|
||||
export * from "./company-status-changer";
|
||||
export * from "./company-updater";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
1
modules/companies/src/api/application/index.ts
Normal file
1
modules/companies/src/api/application/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./companies";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
215
modules/companies/src/api/domain/companies/company.aggregate.ts
Normal file
215
modules/companies/src/api/domain/companies/company.aggregate.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
21
modules/companies/src/api/domain/companies/errors.ts
Normal file
21
modules/companies/src/api/domain/companies/errors.ts
Normal 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;
|
||||
4
modules/companies/src/api/domain/companies/index.ts
Normal file
4
modules/companies/src/api/domain/companies/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./company-status.vo";
|
||||
export * from "./company-slug.vo";
|
||||
export * from "./company.aggregate";
|
||||
export * from "./errors";
|
||||
1
modules/companies/src/api/domain/index.ts
Normal file
1
modules/companies/src/api/domain/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./companies";
|
||||
46
modules/companies/src/api/index.ts
Normal file
46
modules/companies/src/api/index.ts
Normal 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;
|
||||
104
modules/companies/src/api/infrastructure/di/companies.di.ts
Normal file
104
modules/companies/src/api/infrastructure/di/companies.di.ts
Normal 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);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export {};
|
||||
2
modules/companies/src/api/infrastructure/di/index.ts
Normal file
2
modules/companies/src/api/infrastructure/di/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./companies.di";
|
||||
export * from "./company-public-services";
|
||||
@ -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);
|
||||
@ -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);
|
||||
};
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./companies.routes";
|
||||
export * from "./companies-api-error-mapper";
|
||||
export * from "./controllers";
|
||||
3
modules/companies/src/api/infrastructure/index.ts
Normal file
3
modules/companies/src/api/infrastructure/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./di";
|
||||
export * from "./express";
|
||||
export * from "./persistence";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize";
|
||||
@ -0,0 +1,7 @@
|
||||
import companyModelInit from "./models/sequelize-company.model";
|
||||
|
||||
export * from "./mappers";
|
||||
export * from "./models";
|
||||
export * from "./repositories";
|
||||
|
||||
export const models = [companyModelInit];
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize-company-domain.mapper";
|
||||
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize-company.model";
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize-company.repository";
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
modules/companies/src/common/dto/index.ts
Normal file
2
modules/companies/src/common/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./request";
|
||||
export * from "./response";
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
4
modules/companies/src/common/dto/request/index.ts
Normal file
4
modules/companies/src/common/dto/request/index.ts
Normal 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";
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
2
modules/companies/src/common/dto/response/index.ts
Normal file
2
modules/companies/src/common/dto/response/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./available-companies.response.dto";
|
||||
export * from "./company.response.dto";
|
||||
1
modules/companies/src/common/index.ts
Normal file
1
modules/companies/src/common/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./dto";
|
||||
26
modules/companies/tsconfig.json
Normal file
26
modules/companies/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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"> & {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from "./company-membership-access-repository.interface";
|
||||
export * from "./company-membership-repository.interface";
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./contracts";
|
||||
export * from "./services";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-access-checker";
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./sequelize-account.model";
|
||||
export * from "./sequelize-company-membership.model";
|
||||
export * from "./sequelize-refresh-token.model";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./sequelize-account.repository";
|
||||
export * from "./sequelize-company-membership-access.repository";
|
||||
export * from "./sequelize-refresh-token.repository";
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-company-selection-page-controller";
|
||||
@ -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
Loading…
Reference in New Issue
Block a user