Compare commits
2 Commits
faf7b2251c
...
4bba8228cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bba8228cc | |||
| 3e5791afdb |
@ -35,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@erp/auth": "workspace:*",
|
"@erp/auth": "workspace:*",
|
||||||
|
"@erp/companies": "workspace:*",
|
||||||
"@erp/core": "workspace:*",
|
"@erp/core": "workspace:*",
|
||||||
"@erp/catalogs": "workspace:*",
|
"@erp/catalogs": "workspace:*",
|
||||||
"@erp/customer-invoices": "workspace:*",
|
"@erp/customer-invoices": "workspace:*",
|
||||||
@ -74,4 +75,4 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import catalogsAPIModule from "@erp/catalogs/api";
|
import catalogsAPIModule from "@erp/catalogs/api";
|
||||||
|
import companiesAPIModule from "@erp/companies/api";
|
||||||
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
||||||
import customersAPIModule from "@erp/customers/api";
|
import customersAPIModule from "@erp/customers/api";
|
||||||
import factuGESAPIModule from "@erp/factuges/api";
|
import factuGESAPIModule from "@erp/factuges/api";
|
||||||
@ -15,6 +16,7 @@ import { registerModule } from "./lib";
|
|||||||
// públicos de ese módulo.
|
// públicos de ese módulo.
|
||||||
export const registerModules = () => {
|
export const registerModules = () => {
|
||||||
registerModule(identityAPIModule);
|
registerModule(identityAPIModule);
|
||||||
|
registerModule(companiesAPIModule);
|
||||||
registerModule(catalogsAPIModule);
|
registerModule(catalogsAPIModule);
|
||||||
registerModule(customersAPIModule);
|
registerModule(customersAPIModule);
|
||||||
registerModule(customerInvoicesAPIModule);
|
registerModule(customerInvoicesAPIModule);
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
|
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
|
||||||
import { DataSourceProvider } from "@erp/core/hooks";
|
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 { i18n } from "@repo/i18next";
|
||||||
import { LoadingOverlay } from "@repo/rdx-ui/components";
|
import { LoadingOverlay } from "@repo/rdx-ui/components";
|
||||||
import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components";
|
import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components";
|
||||||
@ -30,6 +34,7 @@ export const App = () => {
|
|||||||
const axiosInstance = createAxiosInstance({
|
const axiosInstance = createAxiosInstance({
|
||||||
baseURL: import.meta.env.VITE_API_SERVER_URL,
|
baseURL: import.meta.env.VITE_API_SERVER_URL,
|
||||||
getAccessToken: () => authTokenStorage.getAccessToken(),
|
getAccessToken: () => authTokenStorage.getAccessToken(),
|
||||||
|
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
|
||||||
onAuthError: () => {
|
onAuthError: () => {
|
||||||
// TODO(identity-auth): siguiente incremento
|
// TODO(identity-auth): siguiente incremento
|
||||||
// 1. detectar 401 recuperable
|
// 1. detectar 401 recuperable
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { ReactNode } from "react";
|
|||||||
import type { RouteObject } from "react-router-dom";
|
import type { RouteObject } from "react-router-dom";
|
||||||
|
|
||||||
import { RequireAuthRouteGuard } from "@/routes/require-auth-guard";
|
import { RequireAuthRouteGuard } from "@/routes/require-auth-guard";
|
||||||
|
import { RequireCompanyRouteGuard } from "@/routes/require-company-guard";
|
||||||
|
|
||||||
interface BuildModuleRoutesParams {
|
interface BuildModuleRoutesParams {
|
||||||
modules: IModuleClient[];
|
modules: IModuleClient[];
|
||||||
@ -21,6 +22,7 @@ interface NormalizeRouteParams {
|
|||||||
targetLayout: ModuleRouteLayout;
|
targetLayout: ModuleRouteLayout;
|
||||||
inheritedLayout?: ModuleRouteLayout;
|
inheritedLayout?: ModuleRouteLayout;
|
||||||
inheritedProtected?: boolean;
|
inheritedProtected?: boolean;
|
||||||
|
inheritedRequireCompany?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar";
|
const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar";
|
||||||
@ -49,15 +51,27 @@ const wrapProtectedElement = (element: ReactNode, isProtected: boolean): ReactNo
|
|||||||
return <RequireAuthRouteGuard>{element}</RequireAuthRouteGuard>;
|
return <RequireAuthRouteGuard>{element}</RequireAuthRouteGuard>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wrapCompanyElement = (element: ReactNode, requireCompany: boolean): ReactNode => {
|
||||||
|
if (!requireCompany) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RequireCompanyRouteGuard>{element}</RequireCompanyRouteGuard>;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeRoute = ({
|
const normalizeRoute = ({
|
||||||
route,
|
route,
|
||||||
module,
|
module,
|
||||||
targetLayout,
|
targetLayout,
|
||||||
inheritedLayout,
|
inheritedLayout,
|
||||||
inheritedProtected,
|
inheritedProtected,
|
||||||
|
inheritedRequireCompany,
|
||||||
}: NormalizeRouteParams): RouteObject | null => {
|
}: NormalizeRouteParams): RouteObject | null => {
|
||||||
const routeLayout = resolveRouteLayout(route, module, inheritedLayout);
|
const routeLayout = resolveRouteLayout(route, module, inheritedLayout);
|
||||||
const routeProtected = resolveRouteProtected(route, module, inheritedProtected);
|
const routeProtected = resolveRouteProtected(route, module, inheritedProtected);
|
||||||
|
const routeRequireCompany =
|
||||||
|
routeProtected &&
|
||||||
|
(route.handle?.requireCompany ?? inheritedRequireCompany ?? true);
|
||||||
|
|
||||||
const normalizedChildren = route.children
|
const normalizedChildren = route.children
|
||||||
?.map((child) =>
|
?.map((child) =>
|
||||||
@ -67,6 +81,7 @@ const normalizeRoute = ({
|
|||||||
targetLayout,
|
targetLayout,
|
||||||
inheritedLayout: routeLayout,
|
inheritedLayout: routeLayout,
|
||||||
inheritedProtected: routeProtected,
|
inheritedProtected: routeProtected,
|
||||||
|
inheritedRequireCompany: routeRequireCompany,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((child): child is RouteObject => child !== null);
|
.filter((child): child is RouteObject => child !== null);
|
||||||
@ -92,7 +107,9 @@ const normalizeRoute = ({
|
|||||||
const normalizedRoute = {
|
const normalizedRoute = {
|
||||||
...rest,
|
...rest,
|
||||||
handle,
|
handle,
|
||||||
element: element ? wrapProtectedElement(element, routeProtected) : undefined,
|
element: element
|
||||||
|
? wrapProtectedElement(wrapCompanyElement(element, routeRequireCompany), routeProtected)
|
||||||
|
: undefined,
|
||||||
children: normalizedChildren,
|
children: normalizedChildren,
|
||||||
} as RouteObject;
|
} as RouteObject;
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ interface PublicOnlyRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
|
export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
|
||||||
const { isAuthenticated, isLoading } = useIdentityAuthSession();
|
const { getAuthenticatedRedirectPath, isAuthenticated, isLoading } = useIdentityAuthSession();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const returnTo = searchParams.get("returnTo");
|
const returnTo = searchParams.get("returnTo");
|
||||||
@ -18,7 +18,14 @@ export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Navigate replace to={returnTo?.startsWith("/") ? returnTo : "/proformas"} />;
|
return (
|
||||||
|
<Navigate
|
||||||
|
replace
|
||||||
|
to={getAuthenticatedRedirectPath({
|
||||||
|
returnTo,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
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)}`} />;
|
||||||
|
};
|
||||||
205
docs/dev/seed-local-admin.sql
Normal file
205
docs/dev/seed-local-admin.sql
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- seed-local-admin.sql
|
||||||
|
-- ERP Backend - Local development seed
|
||||||
|
-- ============================================================
|
||||||
|
--
|
||||||
|
-- Purpose:
|
||||||
|
-- Creates a local admin account, one active company and the
|
||||||
|
-- active membership between them.
|
||||||
|
--
|
||||||
|
-- Intended use:
|
||||||
|
-- Local/dev environments only.
|
||||||
|
--
|
||||||
|
-- Credentials:
|
||||||
|
-- email: admin@local.test
|
||||||
|
-- password: Passw0rd123
|
||||||
|
--
|
||||||
|
-- Important:
|
||||||
|
-- Replace <BCRYPT_HASH_GENERADO> before running this script.
|
||||||
|
--
|
||||||
|
-- Generate bcrypt hash from the workspace:
|
||||||
|
--
|
||||||
|
-- node -e "const bcrypt=require('bcrypt'); bcrypt.hash('Admin123!', 10).then(console.log)"
|
||||||
|
--
|
||||||
|
-- This script is idempotent:
|
||||||
|
-- - It does not duplicate the admin account if the email exists.
|
||||||
|
-- - It does not duplicate the company if the slug exists.
|
||||||
|
-- - It does not duplicate the membership if it already exists.
|
||||||
|
--
|
||||||
|
-- If you need to force password reset for the existing admin,
|
||||||
|
-- uncomment the UPDATE block near the end.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET @admin_id = UUID();
|
||||||
|
|
||||||
|
SET @admin_email = 'admin@local.test';
|
||||||
|
|
||||||
|
SET
|
||||||
|
@admin_password_hash = '$2a$10$qgmmm34tJug4HydlKwcZxOVA5u5zoDTLE5lkH//sp55tl5au2wNQm';
|
||||||
|
|
||||||
|
SET
|
||||||
|
@company_id1 = "5e4dc5b3-96b9-4968-9490-14bd032fec5f" -- UUID();
|
||||||
|
SET
|
||||||
|
@company_slug1 = 'rodax';
|
||||||
|
|
||||||
|
SET @company_legal_name1 = 'Rodax Software S.L.';
|
||||||
|
|
||||||
|
SET @company_trade_name1 = 'Rodax';
|
||||||
|
|
||||||
|
SET @company_id2 = UUID();
|
||||||
|
|
||||||
|
SET @company_slug2 = 'company2';
|
||||||
|
|
||||||
|
SET @company_legal_name2 = 'Empresa 2 S.L.';
|
||||||
|
|
||||||
|
SET @company_trade_name2 = 'Empresa 2';
|
||||||
|
|
||||||
|
SET @company_id3 = UUID();
|
||||||
|
|
||||||
|
SET @company_slug3 = 'company3';
|
||||||
|
|
||||||
|
SET @company_legal_name3 = 'Empresa 2 S.L.';
|
||||||
|
|
||||||
|
SET @company_trade_name3 = 'Empresa 3';
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Account admin
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
identity_accounts (
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
name,
|
||||||
|
avatar_url,
|
||||||
|
language_code,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@admin_id,
|
||||||
|
@admin_email,
|
||||||
|
@admin_password_hash,
|
||||||
|
'Administrador',
|
||||||
|
NULL,
|
||||||
|
'es',
|
||||||
|
'active',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
WHERE
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM identity_accounts
|
||||||
|
WHERE
|
||||||
|
email = @admin_email
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recover the real account id if the account already existed.
|
||||||
|
SELECT id INTO @admin_id
|
||||||
|
FROM identity_accounts
|
||||||
|
WHERE
|
||||||
|
email = @admin_email
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Company
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
companies (
|
||||||
|
id,
|
||||||
|
legal_name,
|
||||||
|
trade_name,
|
||||||
|
tin,
|
||||||
|
slug,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
website,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@company_id,
|
||||||
|
@company_legal_name,
|
||||||
|
@company_trade_name,
|
||||||
|
NULL,
|
||||||
|
@company_slug,
|
||||||
|
@admin_email,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'active',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
WHERE
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM companies
|
||||||
|
WHERE
|
||||||
|
slug = @company_slug
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recover the real company id if the company already existed.
|
||||||
|
SELECT id INTO @company_id
|
||||||
|
FROM companies
|
||||||
|
WHERE
|
||||||
|
slug = @company_slug
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Account-company membership
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
identity_company_memberships (
|
||||||
|
id,
|
||||||
|
account_id,
|
||||||
|
company_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT UUID(), @admin_id, @company_id, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
WHERE
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM identity_company_memberships
|
||||||
|
WHERE
|
||||||
|
account_id = @admin_id
|
||||||
|
AND company_id = @company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Optional: force admin password/status update in local/dev
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- UPDATE identity_accounts
|
||||||
|
-- SET
|
||||||
|
-- password_hash = @admin_password_hash,
|
||||||
|
-- status = 'active',
|
||||||
|
-- updated_at = CURRENT_TIMESTAMP
|
||||||
|
-- WHERE email = @admin_email;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Final check
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
a.id AS account_id,
|
||||||
|
a.email,
|
||||||
|
a.status AS account_status,
|
||||||
|
c.id AS company_id,
|
||||||
|
c.legal_name,
|
||||||
|
c.trade_name,
|
||||||
|
c.slug,
|
||||||
|
c.status AS company_status,
|
||||||
|
m.status AS membership_status
|
||||||
|
FROM
|
||||||
|
identity_accounts a
|
||||||
|
JOIN identity_company_memberships m ON m.account_id = a.id
|
||||||
|
JOIN companies c ON c.id = m.company_id
|
||||||
|
WHERE
|
||||||
|
a.email = @admin_email
|
||||||
|
AND c.slug = @company_slug;
|
||||||
@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicSe
|
|||||||
export const catalogsAPIModule: IModuleServer = {
|
export const catalogsAPIModule: IModuleServer = {
|
||||||
name: "catalogs",
|
name: "catalogs",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["identity"],
|
dependencies: ["identity", "companies"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* 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.
|
* Debe devolver `null` si no hay token disponible.
|
||||||
*/
|
*/
|
||||||
getAccessToken: () => string | null;
|
getAccessToken: () => string | null;
|
||||||
|
getActiveCompanyId: () => string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Función opcional que se ejecuta cuando ocurre un error de autenticación (por ejemplo, 401).
|
* 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 = ({
|
export const createAxiosInstance = ({
|
||||||
baseURL,
|
baseURL,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
|
getActiveCompanyId,
|
||||||
onAuthError,
|
onAuthError,
|
||||||
}: AxiosFactoryConfig): AxiosInstance => {
|
}: AxiosFactoryConfig): AxiosInstance => {
|
||||||
const instance = axios.create(defaultAxiosRequestConfig);
|
const instance = axios.create(defaultAxiosRequestConfig);
|
||||||
instance.defaults.baseURL = baseURL;
|
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";
|
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.
|
* Configura interceptores para una instancia de Axios.
|
||||||
*
|
*
|
||||||
@ -10,6 +40,7 @@ import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axio
|
|||||||
export const setupInterceptors = (
|
export const setupInterceptors = (
|
||||||
axiosInstance: AxiosInstance,
|
axiosInstance: AxiosInstance,
|
||||||
getAccessToken: () => string | null,
|
getAccessToken: () => string | null,
|
||||||
|
getActiveCompanyId: () => string | null,
|
||||||
onAuthError?: () => void
|
onAuthError?: () => void
|
||||||
) => {
|
) => {
|
||||||
axiosInstance.interceptors.request.use(
|
axiosInstance.interceptors.request.use(
|
||||||
@ -18,6 +49,14 @@ export const setupInterceptors = (
|
|||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
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;
|
return config;
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export type ModuleRouteLayout = "app-sidebar" | "auth" | "app-fullscreen";
|
|||||||
export interface ModuleRouteHandle {
|
export interface ModuleRouteHandle {
|
||||||
layout?: ModuleRouteLayout;
|
layout?: ModuleRouteLayout;
|
||||||
protected?: boolean;
|
protected?: boolean;
|
||||||
|
requireCompany?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleRouteObject = Omit<RouteObject, "children" | "handle"> & {
|
export type ModuleRouteObject = Omit<RouteObject, "children" | "handle"> & {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export type { IProformaPublicServices } from "./application";
|
|||||||
export const customerInvoicesAPIModule: IModuleServer = {
|
export const customerInvoicesAPIModule: IModuleServer = {
|
||||||
name: "customer-invoices",
|
name: "customer-invoices",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["catalogs", "customers", "identity"],
|
dependencies: ["catalogs", "customers", "identity", "companies"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* Fase de SETUP
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export * from "./infrastructure/persistence/sequelize";
|
|||||||
export const customersAPIModule: IModuleServer = {
|
export const customersAPIModule: IModuleServer = {
|
||||||
name: "customers",
|
name: "customers",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["catalogs", "identity"],
|
dependencies: ["catalogs", "identity", "companies"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* Fase de SETUP
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { buildFactugesDependencies } from "./infraestructure/di";
|
|||||||
export const factugesAPIModule: IModuleServer = {
|
export const factugesAPIModule: IModuleServer = {
|
||||||
name: "factuges",
|
name: "factuges",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["catalogs", "customers", "customer-invoices", "identity"],
|
dependencies: ["catalogs", "customers", "customer-invoices", "identity", "companies"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* Fase de SETUP
|
||||||
|
|||||||
@ -5,5 +5,5 @@ import type { Company } from "../../../domain";
|
|||||||
export interface ICompanyRepository {
|
export interface ICompanyRepository {
|
||||||
save(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
|
save(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
|
||||||
update(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";
|
export * from "./company-membership-repository.interface";
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./contracts";
|
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 { RequestHandler } from "express";
|
||||||
|
import type { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
|
||||||
|
|
||||||
export type AuthenticatedOnlyMiddlewares = readonly [RequestHandler, RequestHandler];
|
export type AuthenticatedOnlyMiddlewares = readonly [RequestHandler, RequestHandler];
|
||||||
|
|
||||||
export type TenantRequiredMiddlewares = readonly [RequestHandler, 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 {
|
export interface IIdentityAuthPublicServices {
|
||||||
authenticatedOnly(): AuthenticatedOnlyMiddlewares;
|
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 { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
|
||||||
|
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
|
||||||
|
|
||||||
export interface IIdentityPublicServices {
|
export interface IIdentityPublicServices {
|
||||||
auth: IIdentityAuthPublicServices;
|
auth: IIdentityAuthPublicServices;
|
||||||
|
companyAccess: IIdentityCompanyAccessChecker;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
type Name,
|
type Name,
|
||||||
type URLAddress,
|
type URLAddress,
|
||||||
type UniqueID,
|
type UniqueID,
|
||||||
type UtcDate,
|
type UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -20,8 +20,8 @@ export interface IAccountCreateProps {
|
|||||||
avatarUrl: Maybe<URLAddress>;
|
avatarUrl: Maybe<URLAddress>;
|
||||||
languageCode: LanguageCode;
|
languageCode: LanguageCode;
|
||||||
status: AccountStatus;
|
status: AccountStatus;
|
||||||
createdAt: UtcDate;
|
createdAt: UtcDateTime;
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDateTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountProfilePatchProps = Partial<{
|
export type AccountProfilePatchProps = Partial<{
|
||||||
@ -29,7 +29,7 @@ export type AccountProfilePatchProps = Partial<{
|
|||||||
avatarUrl: Maybe<URLAddress>;
|
avatarUrl: Maybe<URLAddress>;
|
||||||
languageCode: LanguageCode;
|
languageCode: LanguageCode;
|
||||||
}> & {
|
}> & {
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDateTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountInternalProps = IAccountCreateProps;
|
export type AccountInternalProps = IAccountCreateProps;
|
||||||
@ -104,11 +104,11 @@ export class Account extends AggregateRoot<AccountInternalProps> {
|
|||||||
return this.props.status;
|
return this.props.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get createdAt(): UtcDate {
|
public get createdAt(): UtcDateTime {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get updatedAt(): UtcDate {
|
public get updatedAt(): UtcDateTime {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +147,7 @@ export class Account extends AggregateRoot<AccountInternalProps> {
|
|||||||
|
|
||||||
public changePasswordHash(params: {
|
public changePasswordHash(params: {
|
||||||
passwordHash: PasswordHash;
|
passwordHash: PasswordHash;
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDateTime;
|
||||||
}): Result<boolean, Error> {
|
}): Result<boolean, Error> {
|
||||||
if (this.status.isDisabled()) {
|
if (this.status.isDisabled()) {
|
||||||
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
|
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
|
||||||
@ -162,7 +162,7 @@ export class Account extends AggregateRoot<AccountInternalProps> {
|
|||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public disable(updatedAt: UtcDate): Result<boolean, Error> {
|
public disable(updatedAt: UtcDateTime): Result<boolean, Error> {
|
||||||
if (this.status.isDisabled()) {
|
if (this.status.isDisabled()) {
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
@ -172,7 +172,7 @@ export class Account extends AggregateRoot<AccountInternalProps> {
|
|||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enable(updatedAt: UtcDate): Result<boolean, Error> {
|
public enable(updatedAt: UtcDateTime): Result<boolean, Error> {
|
||||||
if (this.status.isActive()) {
|
if (this.status.isActive()) {
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
IIdentityAuthPublicServices,
|
IIdentityAuthPublicServices,
|
||||||
|
IIdentityCompanyAccessChecker,
|
||||||
|
IIdentityTenantCompanyFinder,
|
||||||
IIdentityPublicServices,
|
IIdentityPublicServices,
|
||||||
} from "../../application";
|
} from "../../application";
|
||||||
import {
|
import {
|
||||||
@ -19,11 +21,14 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices {
|
|||||||
return [authenticateUser(this.deps), requireAuthenticated()] as const;
|
return [authenticateUser(this.deps), requireAuthenticated()] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
public tenantRequired() {
|
public tenantRequired(params: {
|
||||||
|
companyAccessChecker: IIdentityCompanyAccessChecker;
|
||||||
|
companyFinder: IIdentityTenantCompanyFinder;
|
||||||
|
}) {
|
||||||
return [
|
return [
|
||||||
authenticateUser(this.deps),
|
authenticateUser(this.deps),
|
||||||
requireAuthenticated(),
|
requireAuthenticated(),
|
||||||
requireCompanyContext(),
|
requireCompanyContext(params),
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -31,5 +36,6 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices {
|
|||||||
export function buildIdentityPublicServices(deps: IdentityInternalDeps): IIdentityPublicServices {
|
export function buildIdentityPublicServices(deps: IdentityInternalDeps): IIdentityPublicServices {
|
||||||
return {
|
return {
|
||||||
auth: new IdentityAuthPublicServices(deps.auth.authenticateUserDependencies),
|
auth: new IdentityAuthPublicServices(deps.auth.authenticateUserDependencies),
|
||||||
|
companyAccess: deps.companyAccess.checker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type IIdentityCompanyAccessChecker,
|
||||||
|
IdentityCompanyAccessChecker,
|
||||||
|
} from "../../application";
|
||||||
|
import type { IAccountRepository } from "../../application/accounts";
|
||||||
import {
|
import {
|
||||||
AccountAuthenticator,
|
AccountAuthenticator,
|
||||||
type IAccessTokenVerifier,
|
|
||||||
type GetCurrentSessionUseCase,
|
type GetCurrentSessionUseCase,
|
||||||
GetCurrentSessionUseCase as GetCurrentSessionUseCaseImpl,
|
GetCurrentSessionUseCase as GetCurrentSessionUseCaseImpl,
|
||||||
|
type IAccessTokenVerifier,
|
||||||
type LoginWithPasswordUseCase,
|
type LoginWithPasswordUseCase,
|
||||||
LoginWithPasswordUseCase as LoginWithPasswordUseCaseImpl,
|
LoginWithPasswordUseCase as LoginWithPasswordUseCaseImpl,
|
||||||
type LogoutUseCase,
|
type LogoutUseCase,
|
||||||
@ -13,11 +18,11 @@ import {
|
|||||||
RefreshSessionUseCase as RefreshSessionUseCaseImpl,
|
RefreshSessionUseCase as RefreshSessionUseCaseImpl,
|
||||||
SessionIssuer,
|
SessionIssuer,
|
||||||
} from "../../application/authentication";
|
} from "../../application/authentication";
|
||||||
import type { IAccountRepository } from "../../application/accounts";
|
|
||||||
import { identityAuthenticateRequest } from "../express/middlewares";
|
import { identityAuthenticateRequest } from "../express/middlewares";
|
||||||
import {
|
import {
|
||||||
SequelizeAccountDomainMapper,
|
SequelizeAccountDomainMapper,
|
||||||
SequelizeAccountRepository,
|
SequelizeAccountRepository,
|
||||||
|
SequelizeCompanyMembershipAccessRepository,
|
||||||
SequelizeRefreshTokenDomainMapper,
|
SequelizeRefreshTokenDomainMapper,
|
||||||
SequelizeRefreshTokenRepository,
|
SequelizeRefreshTokenRepository,
|
||||||
} from "../persistence";
|
} from "../persistence";
|
||||||
@ -44,6 +49,9 @@ export type IdentityInternalDeps = {
|
|||||||
getCurrentSession: () => GetCurrentSessionUseCase;
|
getCurrentSession: () => GetCurrentSessionUseCase;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
companyAccess: {
|
||||||
|
checker: IIdentityCompanyAccessChecker;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildIdentityDependencies = (params: ModuleParams): IdentityInternalDeps => {
|
export const buildIdentityDependencies = (params: ModuleParams): IdentityInternalDeps => {
|
||||||
@ -59,6 +67,9 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
|||||||
new SequelizeRefreshTokenDomainMapper(),
|
new SequelizeRefreshTokenDomainMapper(),
|
||||||
database
|
database
|
||||||
);
|
);
|
||||||
|
const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository(
|
||||||
|
database
|
||||||
|
);
|
||||||
|
|
||||||
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
|
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
|
||||||
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
|
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,
|
ExpressController,
|
||||||
type RequestWithAuth,
|
type RequestWithAuth,
|
||||||
UnauthorizedApiError,
|
UnauthorizedApiError,
|
||||||
ValidationApiError,
|
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
|
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
import type { IAccessTokenVerifier, IAccountRepository } from "../../../application";
|
import type { IAccessTokenVerifier, IAccountRepository } from "../../../application";
|
||||||
|
|
||||||
const COMPANY_HEADER_NAME = "X-Company-Id";
|
|
||||||
|
|
||||||
function parseBearerToken(authorization?: string): string | undefined {
|
function parseBearerToken(authorization?: string): string | undefined {
|
||||||
if (!authorization) {
|
if (!authorization) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -24,20 +21,6 @@ function parseBearerToken(authorization?: string): string | undefined {
|
|||||||
return token?.trim() || 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: {
|
export function authenticateUser(params: {
|
||||||
accessTokenVerifier: IAccessTokenVerifier;
|
accessTokenVerifier: IAccessTokenVerifier;
|
||||||
accountRepository: IAccountRepository;
|
accountRepository: IAccountRepository;
|
||||||
@ -77,24 +60,9 @@ export function authenticateUser(params: {
|
|||||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
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 = {
|
(req as RequestWithAuth).user = {
|
||||||
userId: account.id,
|
userId: account.id,
|
||||||
email: emailResult.data,
|
email: emailResult.data,
|
||||||
companyId: companyIdFromHeader,
|
|
||||||
roles: [],
|
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";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
export function requireCompanyContext() {
|
import type { IIdentityCompanyAccessChecker, IIdentityTenantCompanyFinder } from "../../../application";
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
/**
|
const COMPANY_HEADER_NAME = "X-Company-Id";
|
||||||
* TODO: validar membership real cuando exista persistencia de CompanyMembership.
|
|
||||||
*/
|
export function requireCompanyContext(params: {
|
||||||
if (!(req as RequestWithAuth).user?.companyId) {
|
companyAccessChecker: IIdentityCompanyAccessChecker;
|
||||||
|
companyFinder: IIdentityTenantCompanyFinder;
|
||||||
|
}) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const user = (req as RequestWithAuth).user;
|
||||||
|
if (!user?.userId) {
|
||||||
return ExpressController.errorResponse(
|
return ExpressController.errorResponse(
|
||||||
new ForbiddenApiError("Company context required"),
|
new ForbiddenApiError("Company context required"),
|
||||||
req,
|
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();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,15 @@ import type { RequestHandler } from "express";
|
|||||||
|
|
||||||
import type { IIdentityPublicServices } from "../../../application";
|
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[] {
|
export function requireIdentityAuthenticated(params: StartParams): RequestHandler[] {
|
||||||
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
||||||
return [...identityServices.auth.authenticatedOnly()];
|
return [...identityServices.auth.authenticatedOnly()];
|
||||||
@ -10,5 +19,11 @@ export function requireIdentityAuthenticated(params: StartParams): RequestHandle
|
|||||||
|
|
||||||
export function requireIdentityTenant(params: StartParams): RequestHandler[] {
|
export function requireIdentityTenant(params: StartParams): RequestHandler[] {
|
||||||
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
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 accountModelInit from "./models/sequelize-account.model";
|
||||||
|
import companyMembershipModelInit from "./models/sequelize-company-membership.model";
|
||||||
import refreshTokenModelInit from "./models/sequelize-refresh-token.model";
|
import refreshTokenModelInit from "./models/sequelize-refresh-token.model";
|
||||||
|
|
||||||
export * from "./mappers";
|
export * from "./mappers";
|
||||||
export * from "./models";
|
export * from "./models";
|
||||||
export * from "./repositories";
|
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-account.model";
|
||||||
|
export * from "./sequelize-company-membership.model";
|
||||||
export * from "./sequelize-refresh-token.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-account.repository";
|
||||||
|
export * from "./sequelize-company-membership-access.repository";
|
||||||
export * from "./sequelize-refresh-token.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 { ModuleClientParams } from "@erp/core/client";
|
||||||
import type { RouteObject } from "react-router-dom";
|
import type { RouteObject } from "react-router-dom";
|
||||||
|
|
||||||
|
import { CompanySelectionPage } from "./company-selection";
|
||||||
import { LoginPage } from "./login";
|
import { LoginPage } from "./login";
|
||||||
|
import { NoCompaniesPage } from "./no-companies";
|
||||||
|
|
||||||
export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] {
|
export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] {
|
||||||
return [
|
return [
|
||||||
@ -13,6 +15,23 @@ export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] {
|
|||||||
},
|
},
|
||||||
element: <LoginPage />,
|
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";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user