From 3e5791afdbbcad63e8a47d7421145e322e62a6fd Mon Sep 17 00:00:00 2001 From: david Date: Mon, 6 Jul 2026 12:57:33 +0200 Subject: [PATCH] =?UTF-8?q?Login=20+=20selecci=C3=B3n=20de=20empresas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/package.json | 3 +- apps/server/src/register-modules.ts | 2 + apps/web/src/app.tsx | 7 +- apps/web/src/components/module-routes.tsx | 19 +- apps/web/src/routes/public-only-guard.tsx | 11 +- apps/web/src/routes/require-company-guard.tsx | 29 +++ modules/catalogs/src/api/index.ts | 2 +- modules/companies/package.json | 33 +++ .../contracts/company-repository.interface.ts | 19 ++ .../application/companies/contracts/index.ts | 1 + .../src/api/application/companies/index.ts | 4 + .../api/application/companies/public/index.ts | 3 + .../mappers/company-public-model.mapper.ts | 20 ++ .../companies/public/mappers/index.ts | 1 + .../public/models/company-public.model.ts | 11 + .../companies/public/models/index.ts | 1 + .../public/services/company-public-finder.ts | 58 +++++ .../services/company-public-services.ts | 11 + .../companies/public/services/index.ts | 2 + .../companies/services/company-creator.ts | 48 ++++ .../companies/services/company-finder.ts | 28 +++ .../services/company-status-changer.ts | 40 ++++ .../companies/services/company-updater.ts | 69 ++++++ .../application/companies/services/index.ts | 4 + .../use-cases/create-company.use-case.ts | 135 +++++++++++ .../use-cases/disable-company.use-case.ts | 72 ++++++ .../use-cases/enable-company.use-case.ts | 72 ++++++ ...vailable-companies-for-account.use-case.ts | 72 ++++++ .../use-cases/get-company-by-id.use-case.ts | 61 +++++ .../application/companies/use-cases/index.ts | 6 + .../use-cases/update-company.use-case.ts | 148 ++++++++++++ .../companies/src/api/application/index.ts | 1 + .../api/domain/companies/company-slug.vo.ts | 47 ++++ .../api/domain/companies/company-status.vo.ts | 53 +++++ .../api/domain/companies/company.aggregate.ts | 215 ++++++++++++++++++ .../src/api/domain/companies/errors.ts | 21 ++ .../src/api/domain/companies/index.ts | 4 + modules/companies/src/api/domain/index.ts | 1 + modules/companies/src/api/index.ts | 46 ++++ .../src/api/infrastructure/di/companies.di.ts | 104 +++++++++ .../di/company-public-services.ts | 1 + .../src/api/infrastructure/di/index.ts | 2 + .../express/companies-api-error-mapper.ts | 46 ++++ .../express/companies.routes.ts | 85 +++++++ .../controllers/create-company.controller.ts | 23 ++ .../controllers/disable-company.controller.ts | 26 +++ .../controllers/enable-company.controller.ts | 26 +++ .../find-available-companies.controller.ts | 28 +++ .../get-company-by-id.controller.ts | 26 +++ .../express/controllers/index.ts | 6 + .../controllers/update-company.controller.ts | 28 +++ .../src/api/infrastructure/express/index.ts | 3 + .../companies/src/api/infrastructure/index.ts | 3 + .../api/infrastructure/persistence/index.ts | 1 + .../persistence/sequelize/index.ts | 7 + .../persistence/sequelize/mappers/index.ts | 1 + .../sequelize-company-domain.mapper.ts | 115 ++++++++++ .../persistence/sequelize/models/index.ts | 1 + .../models/sequelize-company.model.ts | 119 ++++++++++ .../sequelize/repositories/index.ts | 1 + .../sequelize-company.repository.ts | 170 ++++++++++++++ modules/companies/src/common/dto/index.ts | 2 + .../change-company-status.request.dto.ts | 7 + .../dto/request/create-company.request.dto.ts | 15 ++ .../request/get-company-by-id.request.dto.ts | 7 + .../companies/src/common/dto/request/index.ts | 4 + .../dto/request/update-company.request.dto.ts | 20 ++ .../available-companies.response.dto.ts | 21 ++ .../dto/response/company.response.dto.ts | 18 ++ .../src/common/dto/response/index.ts | 2 + modules/companies/src/common/index.ts | 1 + modules/companies/tsconfig.json | 26 +++ .../axios/create-axios-instance.ts | 4 +- .../data-source/axios/setup-interceptors.ts | 39 ++++ .../lib/modules/module-client.interface.ts | 1 + modules/customer-invoices/src/api/index.ts | 2 +- modules/customers/src/api/index.ts | 2 +- modules/factuges/src/api/index.ts | 2 +- .../contracts/company-repository.interface.ts | 2 +- ...-membership-access-repository.interface.ts | 14 ++ .../company-memberships/contracts/index.ts | 1 + .../application/company-memberships/index.ts | 1 + .../services/company-access-checker.ts | 48 ++++ .../company-memberships/services/index.ts | 1 + ...identity-auth-public-services.interface.ts | 15 +- .../identity-public-services.interface.ts | 2 + .../api/domain/accounts/account.aggregate.ts | 18 +- .../di/identity-public-services.ts | 10 +- .../src/api/infrastructure/di/identity.di.ts | 18 +- .../authenticate-user.middleware.ts | 33 --- .../require-company-context.middleware.ts | 92 +++++++- .../require-identity-middlewares.ts | 17 +- .../persistence/sequelize/index.ts | 3 +- .../persistence/sequelize/models/index.ts | 1 + .../sequelize-company-membership.model.ts | 90 ++++++++ .../sequelize/repositories/index.ts | 1 + ...ze-company-membership-access.repository.ts | 73 ++++++ modules/identity/src/web/auth-routes.tsx | 21 +- .../company-selection/controllers/index.ts | 1 + .../use-company-selection-page-controller.ts | 59 +++++ .../src/web/company-selection/index.ts | 2 + .../ui/company-selection-page.tsx | 90 ++++++++ .../src/web/company-selection/ui/index.ts | 1 + .../context/identity-auth-session.context.tsx | 108 ++++++++- modules/identity/src/web/index.ts | 3 +- .../login/controllers/use-login-controller.ts | 3 +- .../controllers/use-login-page-controller.ts | 12 +- .../identity/src/web/login/ui/login-page.tsx | 14 +- .../src/web/no-companies/controllers/index.ts | 1 + .../use-no-companies-page-controller.ts | 22 ++ .../identity/src/web/no-companies/index.ts | 2 + .../identity/src/web/no-companies/ui/index.ts | 1 + .../web/no-companies/ui/no-companies-page.tsx | 53 +++++ .../shared/adapters/active-company.adapter.ts | 103 +++++++++ .../identity/src/web/shared/adapters/index.ts | 2 +- .../shared/api/dto/available-companies.dto.ts | 20 ++ .../api/find-available-companies.api.ts | 17 ++ modules/identity/src/web/shared/api/index.ts | 3 +- .../shared/entities/active-company.entity.ts | 31 +++ .../identity/src/web/shared/entities/index.ts | 2 +- .../shared/storage/active-company-storage.ts | 23 ++ .../identity/src/web/shared/storage/index.ts | 2 +- packages/typescript-config/root.json | 3 + pnpm-lock.yaml | 76 ++++++- 124 files changed, 3387 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/routes/require-company-guard.tsx create mode 100644 modules/companies/package.json create mode 100644 modules/companies/src/api/application/companies/contracts/company-repository.interface.ts create mode 100644 modules/companies/src/api/application/companies/contracts/index.ts create mode 100644 modules/companies/src/api/application/companies/index.ts create mode 100644 modules/companies/src/api/application/companies/public/index.ts create mode 100644 modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts create mode 100644 modules/companies/src/api/application/companies/public/mappers/index.ts create mode 100644 modules/companies/src/api/application/companies/public/models/company-public.model.ts create mode 100644 modules/companies/src/api/application/companies/public/models/index.ts create mode 100644 modules/companies/src/api/application/companies/public/services/company-public-finder.ts create mode 100644 modules/companies/src/api/application/companies/public/services/company-public-services.ts create mode 100644 modules/companies/src/api/application/companies/public/services/index.ts create mode 100644 modules/companies/src/api/application/companies/services/company-creator.ts create mode 100644 modules/companies/src/api/application/companies/services/company-finder.ts create mode 100644 modules/companies/src/api/application/companies/services/company-status-changer.ts create mode 100644 modules/companies/src/api/application/companies/services/company-updater.ts create mode 100644 modules/companies/src/api/application/companies/services/index.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/index.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts create mode 100644 modules/companies/src/api/application/index.ts create mode 100644 modules/companies/src/api/domain/companies/company-slug.vo.ts create mode 100644 modules/companies/src/api/domain/companies/company-status.vo.ts create mode 100644 modules/companies/src/api/domain/companies/company.aggregate.ts create mode 100644 modules/companies/src/api/domain/companies/errors.ts create mode 100644 modules/companies/src/api/domain/companies/index.ts create mode 100644 modules/companies/src/api/domain/index.ts create mode 100644 modules/companies/src/api/index.ts create mode 100644 modules/companies/src/api/infrastructure/di/companies.di.ts create mode 100644 modules/companies/src/api/infrastructure/di/company-public-services.ts create mode 100644 modules/companies/src/api/infrastructure/di/index.ts create mode 100644 modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts create mode 100644 modules/companies/src/api/infrastructure/express/companies.routes.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/find-available-companies.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/index.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/index.ts create mode 100644 modules/companies/src/api/infrastructure/index.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/index.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/index.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/mappers/index.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/models/index.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/repositories/index.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts create mode 100644 modules/companies/src/common/dto/index.ts create mode 100644 modules/companies/src/common/dto/request/change-company-status.request.dto.ts create mode 100644 modules/companies/src/common/dto/request/create-company.request.dto.ts create mode 100644 modules/companies/src/common/dto/request/get-company-by-id.request.dto.ts create mode 100644 modules/companies/src/common/dto/request/index.ts create mode 100644 modules/companies/src/common/dto/request/update-company.request.dto.ts create mode 100644 modules/companies/src/common/dto/response/available-companies.response.dto.ts create mode 100644 modules/companies/src/common/dto/response/company.response.dto.ts create mode 100644 modules/companies/src/common/dto/response/index.ts create mode 100644 modules/companies/src/common/index.ts create mode 100644 modules/companies/tsconfig.json create mode 100644 modules/identity/src/api/application/company-memberships/contracts/company-membership-access-repository.interface.ts create mode 100644 modules/identity/src/api/application/company-memberships/services/company-access-checker.ts create mode 100644 modules/identity/src/api/application/company-memberships/services/index.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership.model.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership-access.repository.ts create mode 100644 modules/identity/src/web/company-selection/controllers/index.ts create mode 100644 modules/identity/src/web/company-selection/controllers/use-company-selection-page-controller.ts create mode 100644 modules/identity/src/web/company-selection/index.ts create mode 100644 modules/identity/src/web/company-selection/ui/company-selection-page.tsx create mode 100644 modules/identity/src/web/company-selection/ui/index.ts create mode 100644 modules/identity/src/web/no-companies/controllers/index.ts create mode 100644 modules/identity/src/web/no-companies/controllers/use-no-companies-page-controller.ts create mode 100644 modules/identity/src/web/no-companies/index.ts create mode 100644 modules/identity/src/web/no-companies/ui/index.ts create mode 100644 modules/identity/src/web/no-companies/ui/no-companies-page.tsx create mode 100644 modules/identity/src/web/shared/adapters/active-company.adapter.ts create mode 100644 modules/identity/src/web/shared/api/dto/available-companies.dto.ts create mode 100644 modules/identity/src/web/shared/api/find-available-companies.api.ts create mode 100644 modules/identity/src/web/shared/entities/active-company.entity.ts create mode 100644 modules/identity/src/web/shared/storage/active-company-storage.ts diff --git a/apps/server/package.json b/apps/server/package.json index e50dcc8b..ca73bb86 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@erp/auth": "workspace:*", + "@erp/companies": "workspace:*", "@erp/core": "workspace:*", "@erp/catalogs": "workspace:*", "@erp/customer-invoices": "workspace:*", @@ -74,4 +75,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index df47f97f..16744e8c 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -1,4 +1,5 @@ import catalogsAPIModule from "@erp/catalogs/api"; +import companiesAPIModule from "@erp/companies/api"; import customerInvoicesAPIModule from "@erp/customer-invoices/api"; import customersAPIModule from "@erp/customers/api"; import factuGESAPIModule from "@erp/factuges/api"; @@ -15,6 +16,7 @@ import { registerModule } from "./lib"; // públicos de ese módulo. export const registerModules = () => { registerModule(identityAPIModule); + registerModule(companiesAPIModule); registerModule(catalogsAPIModule); registerModule(customersAPIModule); registerModule(customerInvoicesAPIModule); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 027774f8..3511a591 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,6 +1,10 @@ import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client"; import { DataSourceProvider } from "@erp/core/hooks"; -import { IdentityAuthSessionProvider, authTokenStorage } from "@erp/identity/client"; +import { + IdentityAuthSessionProvider, + activeCompanyStorage, + authTokenStorage, +} from "@erp/identity/client"; import { i18n } from "@repo/i18next"; import { LoadingOverlay } from "@repo/rdx-ui/components"; import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components"; @@ -30,6 +34,7 @@ export const App = () => { const axiosInstance = createAxiosInstance({ baseURL: import.meta.env.VITE_API_SERVER_URL, getAccessToken: () => authTokenStorage.getAccessToken(), + getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(), onAuthError: () => { // TODO(identity-auth): siguiente incremento // 1. detectar 401 recuperable diff --git a/apps/web/src/components/module-routes.tsx b/apps/web/src/components/module-routes.tsx index 395d7c1c..4c8ea0cc 100644 --- a/apps/web/src/components/module-routes.tsx +++ b/apps/web/src/components/module-routes.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import type { RouteObject } from "react-router-dom"; import { RequireAuthRouteGuard } from "@/routes/require-auth-guard"; +import { RequireCompanyRouteGuard } from "@/routes/require-company-guard"; interface BuildModuleRoutesParams { modules: IModuleClient[]; @@ -21,6 +22,7 @@ interface NormalizeRouteParams { targetLayout: ModuleRouteLayout; inheritedLayout?: ModuleRouteLayout; inheritedProtected?: boolean; + inheritedRequireCompany?: boolean; } const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar"; @@ -49,15 +51,27 @@ const wrapProtectedElement = (element: ReactNode, isProtected: boolean): ReactNo return {element}; }; +const wrapCompanyElement = (element: ReactNode, requireCompany: boolean): ReactNode => { + if (!requireCompany) { + return element; + } + + return {element}; +}; + const normalizeRoute = ({ route, module, targetLayout, inheritedLayout, inheritedProtected, + inheritedRequireCompany, }: NormalizeRouteParams): RouteObject | null => { const routeLayout = resolveRouteLayout(route, module, inheritedLayout); const routeProtected = resolveRouteProtected(route, module, inheritedProtected); + const routeRequireCompany = + routeProtected && + (route.handle?.requireCompany ?? inheritedRequireCompany ?? true); const normalizedChildren = route.children ?.map((child) => @@ -67,6 +81,7 @@ const normalizeRoute = ({ targetLayout, inheritedLayout: routeLayout, inheritedProtected: routeProtected, + inheritedRequireCompany: routeRequireCompany, }) ) .filter((child): child is RouteObject => child !== null); @@ -92,7 +107,9 @@ const normalizeRoute = ({ const normalizedRoute = { ...rest, handle, - element: element ? wrapProtectedElement(element, routeProtected) : undefined, + element: element + ? wrapProtectedElement(wrapCompanyElement(element, routeRequireCompany), routeProtected) + : undefined, children: normalizedChildren, } as RouteObject; diff --git a/apps/web/src/routes/public-only-guard.tsx b/apps/web/src/routes/public-only-guard.tsx index d6213876..0f807420 100644 --- a/apps/web/src/routes/public-only-guard.tsx +++ b/apps/web/src/routes/public-only-guard.tsx @@ -8,7 +8,7 @@ interface PublicOnlyRouteProps { } export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => { - const { isAuthenticated, isLoading } = useIdentityAuthSession(); + const { getAuthenticatedRedirectPath, isAuthenticated, isLoading } = useIdentityAuthSession(); const [searchParams] = useSearchParams(); const returnTo = searchParams.get("returnTo"); @@ -18,7 +18,14 @@ export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => { } if (isAuthenticated) { - return ; + return ( + + ); } return <>{children}; diff --git a/apps/web/src/routes/require-company-guard.tsx b/apps/web/src/routes/require-company-guard.tsx new file mode 100644 index 00000000..5071c9f5 --- /dev/null +++ b/apps/web/src/routes/require-company-guard.tsx @@ -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 ; + } + + if (activeCompany) { + return <>{children}; + } + + const returnTo = `${location.pathname}${location.search}`; + + if (availableCompanies.length === 0) { + return ; + } + + return ; +}; diff --git a/modules/catalogs/src/api/index.ts b/modules/catalogs/src/api/index.ts index b11a763e..1dd6632a 100644 --- a/modules/catalogs/src/api/index.ts +++ b/modules/catalogs/src/api/index.ts @@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType>; + update(company: Company, transaction?: unknown): Promise>; + findById(id: UniqueID, transaction?: unknown): Promise, Error>>; + findBySlug(slug: CompanySlug, transaction?: unknown): Promise, Error>>; + existsActiveById(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise>; + findActiveByIds(params: { + companyIds: Collection; + transaction?: unknown; + }): Promise, Error>>; +} diff --git a/modules/companies/src/api/application/companies/contracts/index.ts b/modules/companies/src/api/application/companies/contracts/index.ts new file mode 100644 index 00000000..4a2beff9 --- /dev/null +++ b/modules/companies/src/api/application/companies/contracts/index.ts @@ -0,0 +1 @@ +export * from "./company-repository.interface"; diff --git a/modules/companies/src/api/application/companies/index.ts b/modules/companies/src/api/application/companies/index.ts new file mode 100644 index 00000000..9a94be2f --- /dev/null +++ b/modules/companies/src/api/application/companies/index.ts @@ -0,0 +1,4 @@ +export * from "./contracts"; +export * from "./services"; +export * from "./use-cases"; +export * from "./public"; diff --git a/modules/companies/src/api/application/companies/public/index.ts b/modules/companies/src/api/application/companies/public/index.ts new file mode 100644 index 00000000..b5bfd7e1 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/index.ts @@ -0,0 +1,3 @@ +export * from "./mappers"; +export * from "./models"; +export * from "./services"; diff --git a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts new file mode 100644 index 00000000..efff1ca9 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts @@ -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", + }; + } +} diff --git a/modules/companies/src/api/application/companies/public/mappers/index.ts b/modules/companies/src/api/application/companies/public/mappers/index.ts new file mode 100644 index 00000000..00eaf7ae --- /dev/null +++ b/modules/companies/src/api/application/companies/public/mappers/index.ts @@ -0,0 +1 @@ +export * from "./company-public-model.mapper"; diff --git a/modules/companies/src/api/application/companies/public/models/company-public.model.ts b/modules/companies/src/api/application/companies/public/models/company-public.model.ts new file mode 100644 index 00000000..2fe97b19 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/models/company-public.model.ts @@ -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"; +} diff --git a/modules/companies/src/api/application/companies/public/models/index.ts b/modules/companies/src/api/application/companies/public/models/index.ts new file mode 100644 index 00000000..6eb18234 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/models/index.ts @@ -0,0 +1 @@ +export * from "./company-public.model"; diff --git a/modules/companies/src/api/application/companies/public/services/company-public-finder.ts b/modules/companies/src/api/application/companies/public/services/company-public-finder.ts new file mode 100644 index 00000000..5b929900 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/services/company-public-finder.ts @@ -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, Error>>; + + existsActiveById(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise>; +} + +export class CompanyPublicFinder implements ICompanyPublicFinder { + public constructor( + private readonly deps: { + repository: ICompanyRepository; + mapper: CompanyPublicModelMapper; + } + ) {} + + public async findById(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise, 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() + ) + ); + } + + public async existsActiveById(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise> { + const result = await this.deps.repository.existsActiveById(params); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data); + } +} diff --git a/modules/companies/src/api/application/companies/public/services/company-public-services.ts b/modules/companies/src/api/application/companies/public/services/company-public-services.ts new file mode 100644 index 00000000..1e2fd70f --- /dev/null +++ b/modules/companies/src/api/application/companies/public/services/company-public-services.ts @@ -0,0 +1,11 @@ +import type { ICompanyPublicFinder } from "./company-public-finder"; + +export interface ICompanyPublicServices { + finder: ICompanyPublicFinder; +} + +export const buildCompanyPublicServices = ( + finder: ICompanyPublicFinder +): ICompanyPublicServices => ({ + finder, +}); diff --git a/modules/companies/src/api/application/companies/public/services/index.ts b/modules/companies/src/api/application/companies/public/services/index.ts new file mode 100644 index 00000000..b36e6fea --- /dev/null +++ b/modules/companies/src/api/application/companies/public/services/index.ts @@ -0,0 +1,2 @@ +export * from "./company-public-finder"; +export * from "./company-public-services"; diff --git a/modules/companies/src/api/application/companies/services/company-creator.ts b/modules/companies/src/api/application/companies/services/company-creator.ts new file mode 100644 index 00000000..3e5bb827 --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-creator.ts @@ -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>; +} + +export class CompanyCreator implements ICompanyCreator { + public constructor(private readonly repository: ICompanyRepository) {} + + public async create(params: { + id: Company["id"]; + props: ICompanyCreateProps; + transaction?: unknown; + }): Promise> { + 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); + } +} diff --git a/modules/companies/src/api/application/companies/services/company-finder.ts b/modules/companies/src/api/application/companies/services/company-finder.ts new file mode 100644 index 00000000..25f521af --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-finder.ts @@ -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>; +} + +export class CompanyFinder implements ICompanyFinder { + public constructor(private readonly repository: ICompanyRepository) {} + + public async findCompanyById( + companyId: Company["id"], + transaction?: unknown + ): Promise> { + 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.")) + ); + } +} diff --git a/modules/companies/src/api/application/companies/services/company-status-changer.ts b/modules/companies/src/api/application/companies/services/company-status-changer.ts new file mode 100644 index 00000000..3fed7507 --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-status-changer.ts @@ -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>; +} + +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> { + 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); + } +} diff --git a/modules/companies/src/api/application/companies/services/company-updater.ts b/modules/companies/src/api/application/companies/services/company-updater.ts new file mode 100644 index 00000000..bfbba137 --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-updater.ts @@ -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>; +} + +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> { + 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); + } +} diff --git a/modules/companies/src/api/application/companies/services/index.ts b/modules/companies/src/api/application/companies/services/index.ts new file mode 100644 index 00000000..c0a1b098 --- /dev/null +++ b/modules/companies/src/api/application/companies/services/index.ts @@ -0,0 +1,4 @@ +export * from "./company-creator"; +export * from "./company-finder"; +export * from "./company-status-changer"; +export * from "./company-updater"; diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts new file mode 100644 index 00000000..4e0cb7d0 --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts @@ -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> { + 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, + }; +} diff --git a/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts new file mode 100644 index 00000000..97a46871 --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts @@ -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> { + 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, + }; +} diff --git a/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts new file mode 100644 index 00000000..38f5fc4f --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts @@ -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> { + 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, + }; +} diff --git a/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts b/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts new file mode 100644 index 00000000..6b59b568 --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts @@ -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> { + 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); + } + }); + } +} diff --git a/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts b/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts new file mode 100644 index 00000000..1d0db461 --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts @@ -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> { + 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, + }; +} diff --git a/modules/companies/src/api/application/companies/use-cases/index.ts b/modules/companies/src/api/application/companies/use-cases/index.ts new file mode 100644 index 00000000..a94ddc38 --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/index.ts @@ -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"; diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts new file mode 100644 index 00000000..cd41b686 --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts @@ -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> { + 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 { + 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, + }; +} diff --git a/modules/companies/src/api/application/index.ts b/modules/companies/src/api/application/index.ts new file mode 100644 index 00000000..51c4a966 --- /dev/null +++ b/modules/companies/src/api/application/index.ts @@ -0,0 +1 @@ +export * from "./companies"; diff --git a/modules/companies/src/api/domain/companies/company-slug.vo.ts b/modules/companies/src/api/domain/companies/company-slug.vo.ts new file mode 100644 index 00000000..81a84d14 --- /dev/null +++ b/modules/companies/src/api/domain/companies/company-slug.vo.ts @@ -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 { + private static readonly MIN_LENGTH = 2; + private static readonly MAX_LENGTH = 100; + + static create(value: string): Result { + 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); + } +} diff --git a/modules/companies/src/api/domain/companies/company-status.vo.ts b/modules/companies/src/api/domain/companies/company-status.vo.ts new file mode 100644 index 00000000..f9cf90fc --- /dev/null +++ b/modules/companies/src/api/domain/companies/company-status.vo.ts @@ -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 { + private static readonly ALLOWED_STATUSES = [COMPANY_STATUS.ACTIVE, COMPANY_STATUS.DISABLED] as const; + + static create(value: string): Result { + 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); + } +} diff --git a/modules/companies/src/api/domain/companies/company.aggregate.ts b/modules/companies/src/api/domain/companies/company.aggregate.ts new file mode 100644 index 00000000..b12bb4b7 --- /dev/null +++ b/modules/companies/src/api/domain/companies/company.aggregate.ts @@ -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; + tin: Maybe; + slug: CompanySlug; + email: Maybe; + phone: Maybe; + website: Maybe; + status: CompanyStatus; + createdAt: UtcDate; + updatedAt: UtcDate; +} + +export type CompanyPatchProps = Partial<{ + legalName: Name; + tradeName: Maybe; + tin: Maybe; + slug: CompanySlug; + email: Maybe; + phone: Maybe; + website: Maybe; +}> & { + updatedAt: UtcDate; +}; + +export type CompanyInternalProps = ICompanyCreateProps; + +export class Company extends AggregateRoot { + protected constructor(props: CompanyInternalProps, id?: UniqueID) { + super(props, id); + } + + static create(props: ICompanyCreateProps, id?: UniqueID): Result { + 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 { + 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 { + return this.props.tradeName; + } + + public get tin(): Maybe { + return this.props.tin; + } + + public get slug(): CompanySlug { + return this.props.slug; + } + + public get email(): Maybe { + return this.props.email; + } + + public get phone(): Maybe { + return this.props.phone; + } + + public get website(): Maybe { + 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 { + 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 { + 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 { + 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( + current: Maybe, + next: Maybe + ): boolean { + return current.match( + (currentValue) => + next.match( + (nextValue) => currentValue.equals(nextValue), + () => false + ), + () => next.isNone() + ); + } +} diff --git a/modules/companies/src/api/domain/companies/errors.ts b/modules/companies/src/api/domain/companies/errors.ts new file mode 100644 index 00000000..41c989b9 --- /dev/null +++ b/modules/companies/src/api/domain/companies/errors.ts @@ -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; diff --git a/modules/companies/src/api/domain/companies/index.ts b/modules/companies/src/api/domain/companies/index.ts new file mode 100644 index 00000000..5ca9e4f2 --- /dev/null +++ b/modules/companies/src/api/domain/companies/index.ts @@ -0,0 +1,4 @@ +export * from "./company-status.vo"; +export * from "./company-slug.vo"; +export * from "./company.aggregate"; +export * from "./errors"; diff --git a/modules/companies/src/api/domain/index.ts b/modules/companies/src/api/domain/index.ts new file mode 100644 index 00000000..51c4a966 --- /dev/null +++ b/modules/companies/src/api/domain/index.ts @@ -0,0 +1 @@ +export * from "./companies"; diff --git a/modules/companies/src/api/index.ts b/modules/companies/src/api/index.ts new file mode 100644 index 00000000..84b12ffc --- /dev/null +++ b/modules/companies/src/api/index.ts @@ -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; diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts new file mode 100644 index 00000000..4ddf7a4b --- /dev/null +++ b/modules/companies/src/api/infrastructure/di/companies.di.ts @@ -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("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); +}; diff --git a/modules/companies/src/api/infrastructure/di/company-public-services.ts b/modules/companies/src/api/infrastructure/di/company-public-services.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/modules/companies/src/api/infrastructure/di/company-public-services.ts @@ -0,0 +1 @@ +export {}; diff --git a/modules/companies/src/api/infrastructure/di/index.ts b/modules/companies/src/api/infrastructure/di/index.ts new file mode 100644 index 00000000..858603e6 --- /dev/null +++ b/modules/companies/src/api/infrastructure/di/index.ts @@ -0,0 +1,2 @@ +export * from "./companies.di"; +export * from "./company-public-services"; diff --git a/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts b/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts new file mode 100644 index 00000000..1644aebd --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts @@ -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); diff --git a/modules/companies/src/api/infrastructure/express/companies.routes.ts b/modules/companies/src/api/infrastructure/express/companies.routes.ts new file mode 100644 index 00000000..aa569058 --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/companies.routes.ts @@ -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("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); +}; diff --git a/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts new file mode 100644 index 00000000..c176b3f5 --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/create-company.controller.ts @@ -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) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts new file mode 100644 index 00000000..a29069af --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts @@ -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) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts new file mode 100644 index 00000000..09878d70 --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts @@ -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) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/express/controllers/find-available-companies.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/find-available-companies.controller.ts new file mode 100644 index 00000000..324e3eab --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/find-available-companies.controller.ts @@ -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) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts new file mode 100644 index 00000000..60dd7f2a --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts @@ -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) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/express/controllers/index.ts b/modules/companies/src/api/infrastructure/express/controllers/index.ts new file mode 100644 index 00000000..890f4382 --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/index.ts @@ -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"; diff --git a/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts new file mode 100644 index 00000000..625fd459 --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts @@ -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) + ); + } +} diff --git a/modules/companies/src/api/infrastructure/express/index.ts b/modules/companies/src/api/infrastructure/express/index.ts new file mode 100644 index 00000000..42006c83 --- /dev/null +++ b/modules/companies/src/api/infrastructure/express/index.ts @@ -0,0 +1,3 @@ +export * from "./companies.routes"; +export * from "./companies-api-error-mapper"; +export * from "./controllers"; diff --git a/modules/companies/src/api/infrastructure/index.ts b/modules/companies/src/api/infrastructure/index.ts new file mode 100644 index 00000000..3e34d105 --- /dev/null +++ b/modules/companies/src/api/infrastructure/index.ts @@ -0,0 +1,3 @@ +export * from "./di"; +export * from "./express"; +export * from "./persistence"; diff --git a/modules/companies/src/api/infrastructure/persistence/index.ts b/modules/companies/src/api/infrastructure/persistence/index.ts new file mode 100644 index 00000000..62f8ac11 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/index.ts @@ -0,0 +1 @@ +export * from "./sequelize"; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/index.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/index.ts new file mode 100644 index 00000000..dd99c4c6 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/index.ts @@ -0,0 +1,7 @@ +import companyModelInit from "./models/sequelize-company.model"; + +export * from "./mappers"; +export * from "./models"; +export * from "./repositories"; + +export const models = [companyModelInit]; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/index.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..6db32f08 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-company-domain.mapper"; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts new file mode 100644 index 00000000..02f11d6d --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts @@ -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 { + 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 { + return Result.ok({ + 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(), + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/models/index.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/models/index.ts new file mode 100644 index 00000000..e6b56119 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/models/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-company.model"; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts new file mode 100644 index 00000000..d102009e --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts @@ -0,0 +1,119 @@ +import { + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type Sequelize, +} from "sequelize"; + +export type CompanyCreationAttributes = InferCreationAttributes; + +export class CompanyModel extends Model< + InferAttributes, + InferCreationAttributes +> { + 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; +}; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..ebd07fbe --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-company.repository"; diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts new file mode 100644 index 00000000..8af6d87a --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company.repository.ts @@ -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 + implements ICompanyRepository +{ + public constructor( + private readonly domainMapper: SequelizeCompanyDomainMapper, + database: Sequelize + ) { + super({ database }); + } + + public async save(company: Company, transaction?: unknown): Promise> { + 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> { + 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, Error>> { + try { + const row = await CompanyModel.findByPk(id.toPrimitive(), { + transaction: asTransaction(transaction), + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + 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, Error>> { + try { + const row = await CompanyModel.findOne({ + where: { slug: slug.toPrimitive() }, + transaction: asTransaction(transaction), + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + 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> { + 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; + transaction?: unknown; + }): Promise, 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)); + } + } +} diff --git a/modules/companies/src/common/dto/index.ts b/modules/companies/src/common/dto/index.ts new file mode 100644 index 00000000..346dac3b --- /dev/null +++ b/modules/companies/src/common/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./request"; +export * from "./response"; diff --git a/modules/companies/src/common/dto/request/change-company-status.request.dto.ts b/modules/companies/src/common/dto/request/change-company-status.request.dto.ts new file mode 100644 index 00000000..aa56f7ae --- /dev/null +++ b/modules/companies/src/common/dto/request/change-company-status.request.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const ChangeCompanyStatusRequestSchema = z.object({ + company_id: z.uuid(), +}); + +export type ChangeCompanyStatusRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/request/create-company.request.dto.ts b/modules/companies/src/common/dto/request/create-company.request.dto.ts new file mode 100644 index 00000000..70c84ebd --- /dev/null +++ b/modules/companies/src/common/dto/request/create-company.request.dto.ts @@ -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; diff --git a/modules/companies/src/common/dto/request/get-company-by-id.request.dto.ts b/modules/companies/src/common/dto/request/get-company-by-id.request.dto.ts new file mode 100644 index 00000000..2d29894f --- /dev/null +++ b/modules/companies/src/common/dto/request/get-company-by-id.request.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const GetCompanyByIdRequestSchema = z.object({ + company_id: z.uuid(), +}); + +export type GetCompanyByIdRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/request/index.ts b/modules/companies/src/common/dto/request/index.ts new file mode 100644 index 00000000..163df428 --- /dev/null +++ b/modules/companies/src/common/dto/request/index.ts @@ -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"; diff --git a/modules/companies/src/common/dto/request/update-company.request.dto.ts b/modules/companies/src/common/dto/request/update-company.request.dto.ts new file mode 100644 index 00000000..77f240e7 --- /dev/null +++ b/modules/companies/src/common/dto/request/update-company.request.dto.ts @@ -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; + +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; diff --git a/modules/companies/src/common/dto/response/available-companies.response.dto.ts b/modules/companies/src/common/dto/response/available-companies.response.dto.ts new file mode 100644 index 00000000..05dd22db --- /dev/null +++ b/modules/companies/src/common/dto/response/available-companies.response.dto.ts @@ -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; +export type AvailableCompaniesResponseDTO = z.infer; diff --git a/modules/companies/src/common/dto/response/company.response.dto.ts b/modules/companies/src/common/dto/response/company.response.dto.ts new file mode 100644 index 00000000..b9994d0d --- /dev/null +++ b/modules/companies/src/common/dto/response/company.response.dto.ts @@ -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; diff --git a/modules/companies/src/common/dto/response/index.ts b/modules/companies/src/common/dto/response/index.ts new file mode 100644 index 00000000..7fc31c76 --- /dev/null +++ b/modules/companies/src/common/dto/response/index.ts @@ -0,0 +1,2 @@ +export * from "./available-companies.response.dto"; +export * from "./company.response.dto"; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts new file mode 100644 index 00000000..0392b1b4 --- /dev/null +++ b/modules/companies/src/common/index.ts @@ -0,0 +1 @@ +export * from "./dto"; diff --git a/modules/companies/tsconfig.json b/modules/companies/tsconfig.json new file mode 100644 index 00000000..6d3984b5 --- /dev/null +++ b/modules/companies/tsconfig.json @@ -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"] +} diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts index b8cb78a2..50064f25 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts @@ -14,6 +14,7 @@ export interface AxiosFactoryConfig { * Debe devolver `null` si no hay token disponible. */ getAccessToken: () => string | null; + getActiveCompanyId: () => string | null; /** * Función opcional que se ejecuta cuando ocurre un error de autenticación (por ejemplo, 401). @@ -39,10 +40,11 @@ export const defaultAxiosRequestConfig: CreateAxiosDefaults = { export const createAxiosInstance = ({ baseURL, getAccessToken, + getActiveCompanyId, onAuthError, }: AxiosFactoryConfig): AxiosInstance => { const instance = axios.create(defaultAxiosRequestConfig); instance.defaults.baseURL = baseURL; - return setupInterceptors(instance, getAccessToken, onAuthError); + return setupInterceptors(instance, getAccessToken, getActiveCompanyId, onAuthError); }; diff --git a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts index b1d9f3d4..7af77da1 100644 --- a/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts +++ b/modules/core/src/web/lib/data-source/axios/setup-interceptors.ts @@ -1,5 +1,35 @@ import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; +function resolvePathname(config: InternalAxiosRequestConfig, baseURL?: string) { + const requestUrl = config.url; + + if (!requestUrl) { + return null; + } + + try { + return new URL(requestUrl, baseURL).pathname; + } catch { + return null; + } +} + +function shouldAttachCompanyHeader(pathname: string | null) { + if (!pathname) { + return false; + } + + const excludedPathSuffixes = [ + "/identity/auth/login", + "/identity/auth/refresh", + "/identity/auth/logout", + "/identity/auth/session", + "/companies/available", + ]; + + return !excludedPathSuffixes.some((suffix) => pathname.endsWith(suffix)); +} + /** * Configura interceptores para una instancia de Axios. * @@ -10,6 +40,7 @@ import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axio export const setupInterceptors = ( axiosInstance: AxiosInstance, getAccessToken: () => string | null, + getActiveCompanyId: () => string | null, onAuthError?: () => void ) => { axiosInstance.interceptors.request.use( @@ -18,6 +49,14 @@ export const setupInterceptors = ( if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } + + const activeCompanyId = getActiveCompanyId(); + const pathname = resolvePathname(config, axiosInstance.defaults.baseURL); + + if (activeCompanyId && config.headers && shouldAttachCompanyHeader(pathname)) { + config.headers["X-Company-Id"] = activeCompanyId; + } + return config; }, (error: unknown) => { diff --git a/modules/core/src/web/lib/modules/module-client.interface.ts b/modules/core/src/web/lib/modules/module-client.interface.ts index 0d93f771..5faad328 100644 --- a/modules/core/src/web/lib/modules/module-client.interface.ts +++ b/modules/core/src/web/lib/modules/module-client.interface.ts @@ -10,6 +10,7 @@ export type ModuleRouteLayout = "app-sidebar" | "auth" | "app-fullscreen"; export interface ModuleRouteHandle { layout?: ModuleRouteLayout; protected?: boolean; + requireCompany?: boolean; } export type ModuleRouteObject = Omit & { diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index 53330d83..da2ec2be 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -15,7 +15,7 @@ export type { IProformaPublicServices } from "./application"; export const customerInvoicesAPIModule: IModuleServer = { name: "customer-invoices", version: "1.0.0", - dependencies: ["catalogs", "customers", "identity"], + dependencies: ["catalogs", "customers", "identity", "companies"], /** * Fase de SETUP diff --git a/modules/customers/src/api/index.ts b/modules/customers/src/api/index.ts index 207b6e24..0bbdc276 100644 --- a/modules/customers/src/api/index.ts +++ b/modules/customers/src/api/index.ts @@ -10,7 +10,7 @@ export * from "./infrastructure/persistence/sequelize"; export const customersAPIModule: IModuleServer = { name: "customers", version: "1.0.0", - dependencies: ["catalogs", "identity"], + dependencies: ["catalogs", "identity", "companies"], /** * Fase de SETUP diff --git a/modules/factuges/src/api/index.ts b/modules/factuges/src/api/index.ts index 2c8d1493..044a7c81 100644 --- a/modules/factuges/src/api/index.ts +++ b/modules/factuges/src/api/index.ts @@ -6,7 +6,7 @@ import { buildFactugesDependencies } from "./infraestructure/di"; export const factugesAPIModule: IModuleServer = { name: "factuges", version: "1.0.0", - dependencies: ["catalogs", "customers", "customer-invoices", "identity"], + dependencies: ["catalogs", "customers", "customer-invoices", "identity", "companies"], /** * Fase de SETUP diff --git a/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts b/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts index 3280a9fa..22f8be1f 100644 --- a/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts +++ b/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts @@ -5,5 +5,5 @@ import type { Company } from "../../../domain"; export interface ICompanyRepository { save(company: Company, transaction?: unknown): Promise>; update(company: Company, transaction?: unknown): Promise>; - findById(id: Company["id"], transaction?: unknown): Promise, Error>>; + findById(id: UniqueID, transaction?: unknown): Promise, Error>>; } diff --git a/modules/identity/src/api/application/company-memberships/contracts/company-membership-access-repository.interface.ts b/modules/identity/src/api/application/company-memberships/contracts/company-membership-access-repository.interface.ts new file mode 100644 index 00000000..c0034b48 --- /dev/null +++ b/modules/identity/src/api/application/company-memberships/contracts/company-membership-access-repository.interface.ts @@ -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>; + findActiveCompanyIdsByAccount(params: { + accountId: UniqueID; + transaction?: unknown; + }): Promise, Error>>; +} diff --git a/modules/identity/src/api/application/company-memberships/contracts/index.ts b/modules/identity/src/api/application/company-memberships/contracts/index.ts index e21a455b..3866ef4c 100644 --- a/modules/identity/src/api/application/company-memberships/contracts/index.ts +++ b/modules/identity/src/api/application/company-memberships/contracts/index.ts @@ -1 +1,2 @@ +export * from "./company-membership-access-repository.interface"; export * from "./company-membership-repository.interface"; diff --git a/modules/identity/src/api/application/company-memberships/index.ts b/modules/identity/src/api/application/company-memberships/index.ts index 14371ab4..18e9e8cc 100644 --- a/modules/identity/src/api/application/company-memberships/index.ts +++ b/modules/identity/src/api/application/company-memberships/index.ts @@ -1 +1,2 @@ export * from "./contracts"; +export * from "./services"; diff --git a/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts b/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts new file mode 100644 index 00000000..85af2be9 --- /dev/null +++ b/modules/identity/src/api/application/company-memberships/services/company-access-checker.ts @@ -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>; + findAccessibleCompanyIds(params: { + accountId: UniqueID; + transaction?: unknown; + }): Promise, Error>>; +} + +export class IdentityCompanyAccessChecker implements IIdentityCompanyAccessChecker { + public constructor(private readonly repository: ICompanyMembershipAccessRepository) {} + + public async canAccessCompany(params: { + accountId: UniqueID; + companyId: UniqueID; + transaction?: unknown; + }): Promise> { + 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, Error>> { + const result = await this.repository.findActiveCompanyIdsByAccount(params); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data); + } +} diff --git a/modules/identity/src/api/application/company-memberships/services/index.ts b/modules/identity/src/api/application/company-memberships/services/index.ts new file mode 100644 index 00000000..a63a3fc2 --- /dev/null +++ b/modules/identity/src/api/application/company-memberships/services/index.ts @@ -0,0 +1 @@ +export * from "./company-access-checker"; diff --git a/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts b/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts index f818f197..ad41fdf0 100644 --- a/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts +++ b/modules/identity/src/api/application/services/identity-auth-public-services.interface.ts @@ -1,10 +1,23 @@ import type { RequestHandler } from "express"; +import type { Result } from "@repo/rdx-utils"; + +import type { IIdentityCompanyAccessChecker } from "../company-memberships"; export type AuthenticatedOnlyMiddlewares = readonly [RequestHandler, RequestHandler]; export type TenantRequiredMiddlewares = readonly [RequestHandler, RequestHandler, RequestHandler]; +export interface IIdentityTenantCompanyFinder { + existsActiveById(params: { + companyId: import("@repo/rdx-ddd").UniqueID; + transaction?: unknown; + }): Promise>; +} + export interface IIdentityAuthPublicServices { authenticatedOnly(): AuthenticatedOnlyMiddlewares; - tenantRequired(): TenantRequiredMiddlewares; + tenantRequired(params: { + companyAccessChecker: IIdentityCompanyAccessChecker; + companyFinder: IIdentityTenantCompanyFinder; + }): TenantRequiredMiddlewares; } diff --git a/modules/identity/src/api/application/services/identity-public-services.interface.ts b/modules/identity/src/api/application/services/identity-public-services.interface.ts index 67546e78..f227c603 100644 --- a/modules/identity/src/api/application/services/identity-public-services.interface.ts +++ b/modules/identity/src/api/application/services/identity-public-services.interface.ts @@ -1,5 +1,7 @@ import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface"; +import type { IIdentityCompanyAccessChecker } from "../company-memberships"; export interface IIdentityPublicServices { auth: IIdentityAuthPublicServices; + companyAccess: IIdentityCompanyAccessChecker; } diff --git a/modules/identity/src/api/domain/accounts/account.aggregate.ts b/modules/identity/src/api/domain/accounts/account.aggregate.ts index c7140486..1d288933 100644 --- a/modules/identity/src/api/domain/accounts/account.aggregate.ts +++ b/modules/identity/src/api/domain/accounts/account.aggregate.ts @@ -5,7 +5,7 @@ import { type Name, type URLAddress, type UniqueID, - type UtcDate, + type UtcDateTime, } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; @@ -20,8 +20,8 @@ export interface IAccountCreateProps { avatarUrl: Maybe; languageCode: LanguageCode; status: AccountStatus; - createdAt: UtcDate; - updatedAt: UtcDate; + createdAt: UtcDateTime; + updatedAt: UtcDateTime; } export type AccountProfilePatchProps = Partial<{ @@ -29,7 +29,7 @@ export type AccountProfilePatchProps = Partial<{ avatarUrl: Maybe; languageCode: LanguageCode; }> & { - updatedAt: UtcDate; + updatedAt: UtcDateTime; }; export type AccountInternalProps = IAccountCreateProps; @@ -104,11 +104,11 @@ export class Account extends AggregateRoot { return this.props.status; } - public get createdAt(): UtcDate { + public get createdAt(): UtcDateTime { return this.props.createdAt; } - public get updatedAt(): UtcDate { + public get updatedAt(): UtcDateTime { return this.props.updatedAt; } @@ -147,7 +147,7 @@ export class Account extends AggregateRoot { public changePasswordHash(params: { passwordHash: PasswordHash; - updatedAt: UtcDate; + updatedAt: UtcDateTime; }): Result { if (this.status.isDisabled()) { return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated.")); @@ -162,7 +162,7 @@ export class Account extends AggregateRoot { return Result.ok(true); } - public disable(updatedAt: UtcDate): Result { + public disable(updatedAt: UtcDateTime): Result { if (this.status.isDisabled()) { return Result.ok(false); } @@ -172,7 +172,7 @@ export class Account extends AggregateRoot { return Result.ok(true); } - public enable(updatedAt: UtcDate): Result { + public enable(updatedAt: UtcDateTime): Result { if (this.status.isActive()) { return Result.ok(false); } diff --git a/modules/identity/src/api/infrastructure/di/identity-public-services.ts b/modules/identity/src/api/infrastructure/di/identity-public-services.ts index fc0483e3..ddd819b7 100644 --- a/modules/identity/src/api/infrastructure/di/identity-public-services.ts +++ b/modules/identity/src/api/infrastructure/di/identity-public-services.ts @@ -1,5 +1,7 @@ import type { IIdentityAuthPublicServices, + IIdentityCompanyAccessChecker, + IIdentityTenantCompanyFinder, IIdentityPublicServices, } from "../../application"; import { @@ -19,11 +21,14 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices { return [authenticateUser(this.deps), requireAuthenticated()] as const; } - public tenantRequired() { + public tenantRequired(params: { + companyAccessChecker: IIdentityCompanyAccessChecker; + companyFinder: IIdentityTenantCompanyFinder; + }) { return [ authenticateUser(this.deps), requireAuthenticated(), - requireCompanyContext(), + requireCompanyContext(params), ] as const; } } @@ -31,5 +36,6 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices { export function buildIdentityPublicServices(deps: IdentityInternalDeps): IIdentityPublicServices { return { auth: new IdentityAuthPublicServices(deps.auth.authenticateUserDependencies), + companyAccess: deps.companyAccess.checker, }; } diff --git a/modules/identity/src/api/infrastructure/di/identity.di.ts b/modules/identity/src/api/infrastructure/di/identity.di.ts index f970cf7b..d430b9d1 100644 --- a/modules/identity/src/api/infrastructure/di/identity.di.ts +++ b/modules/identity/src/api/infrastructure/di/identity.di.ts @@ -1,10 +1,15 @@ import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; +import { + type IIdentityCompanyAccessChecker, + IdentityCompanyAccessChecker, +} from "../../application"; +import type { IAccountRepository } from "../../application/accounts"; import { AccountAuthenticator, - type IAccessTokenVerifier, type GetCurrentSessionUseCase, GetCurrentSessionUseCase as GetCurrentSessionUseCaseImpl, + type IAccessTokenVerifier, type LoginWithPasswordUseCase, LoginWithPasswordUseCase as LoginWithPasswordUseCaseImpl, type LogoutUseCase, @@ -13,11 +18,11 @@ import { RefreshSessionUseCase as RefreshSessionUseCaseImpl, SessionIssuer, } from "../../application/authentication"; -import type { IAccountRepository } from "../../application/accounts"; import { identityAuthenticateRequest } from "../express/middlewares"; import { SequelizeAccountDomainMapper, SequelizeAccountRepository, + SequelizeCompanyMembershipAccessRepository, SequelizeRefreshTokenDomainMapper, SequelizeRefreshTokenRepository, } from "../persistence"; @@ -44,6 +49,9 @@ export type IdentityInternalDeps = { getCurrentSession: () => GetCurrentSessionUseCase; }; }; + companyAccess: { + checker: IIdentityCompanyAccessChecker; + }; }; export const buildIdentityDependencies = (params: ModuleParams): IdentityInternalDeps => { @@ -59,6 +67,9 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna new SequelizeRefreshTokenDomainMapper(), database ); + const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository( + database + ); const passwordHasher = new BcryptPasswordHasher({ rounds: 10 }); const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({ @@ -120,5 +131,8 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna }), }, }, + companyAccess: { + checker: new IdentityCompanyAccessChecker(companyMembershipAccessRepository), + }, }; }; diff --git a/modules/identity/src/api/infrastructure/express/middlewares/authenticate-user.middleware.ts b/modules/identity/src/api/infrastructure/express/middlewares/authenticate-user.middleware.ts index 7aca2233..8fd6e68f 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/authenticate-user.middleware.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/authenticate-user.middleware.ts @@ -2,15 +2,12 @@ import { ExpressController, type RequestWithAuth, UnauthorizedApiError, - ValidationApiError, } from "@erp/core/api"; import { EmailAddress, UniqueID } from "@repo/rdx-ddd"; import type { NextFunction, Request, Response } from "express"; import type { IAccessTokenVerifier, IAccountRepository } from "../../../application"; -const COMPANY_HEADER_NAME = "X-Company-Id"; - function parseBearerToken(authorization?: string): string | undefined { if (!authorization) { return undefined; @@ -24,20 +21,6 @@ function parseBearerToken(authorization?: string): string | undefined { return token?.trim() || undefined; } -function getCompanyIdFromHeader(req: Request): UniqueID | undefined | Error { - const rawCompanyId = req.get(COMPANY_HEADER_NAME); - if (!rawCompanyId) { - return undefined; - } - - const companyIdResult = UniqueID.create(rawCompanyId); - if (companyIdResult.isFailure) { - return new Error(`Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`); - } - - return companyIdResult.data; -} - export function authenticateUser(params: { accessTokenVerifier: IAccessTokenVerifier; accountRepository: IAccountRepository; @@ -77,24 +60,9 @@ export function authenticateUser(params: { return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res); } - const companyIdFromHeader = getCompanyIdFromHeader(req); - if (companyIdFromHeader instanceof Error) { - return ExpressController.errorResponse( - new ValidationApiError(companyIdFromHeader.message, [ - { - field: COMPANY_HEADER_NAME, - message: companyIdFromHeader.message, - }, - ]), - req, - res - ); - } - (req as RequestWithAuth).user = { userId: account.id, email: emailResult.data, - companyId: companyIdFromHeader, roles: [], }; @@ -104,4 +72,3 @@ export function authenticateUser(params: { } }; } - diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts index 887fc44d..55e773be 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts @@ -1,12 +1,24 @@ -import { ExpressController, ForbiddenApiError, type RequestWithAuth } from "@erp/core/api"; +import { + ExpressController, + ForbiddenApiError, + InternalApiError, + type RequestWithAuth, + ValidationApiError, +} from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; import type { NextFunction, Request, Response } from "express"; -export function requireCompanyContext() { - return (req: Request, res: Response, next: NextFunction) => { - /** - * TODO: validar membership real cuando exista persistencia de CompanyMembership. - */ - if (!(req as RequestWithAuth).user?.companyId) { +import type { IIdentityCompanyAccessChecker, IIdentityTenantCompanyFinder } from "../../../application"; + +const COMPANY_HEADER_NAME = "X-Company-Id"; + +export function requireCompanyContext(params: { + companyAccessChecker: IIdentityCompanyAccessChecker; + companyFinder: IIdentityTenantCompanyFinder; +}) { + return async (req: Request, res: Response, next: NextFunction) => { + const user = (req as RequestWithAuth).user; + if (!user?.userId) { return ExpressController.errorResponse( new ForbiddenApiError("Company context required"), req, @@ -14,6 +26,72 @@ export function requireCompanyContext() { ); } + const rawCompanyId = req.get(COMPANY_HEADER_NAME); + if (!rawCompanyId) { + return ExpressController.errorResponse( + new ForbiddenApiError("Company context required"), + req, + res + ); + } + + const companyIdResult = UniqueID.create(rawCompanyId); + if (companyIdResult.isFailure) { + return ExpressController.errorResponse( + new ValidationApiError(`Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`, [ + { + field: COMPANY_HEADER_NAME, + message: `Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`, + }, + ]), + req, + res + ); + } + + const companyId = companyIdResult.data; + + const canAccessResult = await params.companyAccessChecker.canAccessCompany({ + accountId: user.userId, + companyId, + }); + if (canAccessResult.isFailure) { + return ExpressController.errorResponse( + new InternalApiError(canAccessResult.error.message), + req, + res + ); + } + + if (!canAccessResult.data) { + return ExpressController.errorResponse( + new ForbiddenApiError("Company context required"), + req, + res + ); + } + + const companyExistsResult = await params.companyFinder.existsActiveById({ + companyId, + }); + if (companyExistsResult.isFailure) { + return ExpressController.errorResponse( + new InternalApiError(companyExistsResult.error.message), + req, + res + ); + } + + if (!companyExistsResult.data) { + return ExpressController.errorResponse( + new ForbiddenApiError("Company context required"), + req, + res + ); + } + + user.companyId = companyId; + next(); }; } diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts index 54922b43..e1162563 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts @@ -3,6 +3,15 @@ import type { RequestHandler } from "express"; import type { IIdentityPublicServices } from "../../../application"; +type CompaniesGeneralServices = { + finder: { + existsActiveById(params: { + companyId: import("@repo/rdx-ddd").UniqueID; + transaction?: unknown; + }): Promise>; + }; +}; + export function requireIdentityAuthenticated(params: StartParams): RequestHandler[] { const identityServices = params.getService("identity:general"); return [...identityServices.auth.authenticatedOnly()]; @@ -10,5 +19,11 @@ export function requireIdentityAuthenticated(params: StartParams): RequestHandle export function requireIdentityTenant(params: StartParams): RequestHandler[] { const identityServices = params.getService("identity:general"); - return [...identityServices.auth.tenantRequired()]; + const companiesServices = params.getService("companies:general"); + return [ + ...identityServices.auth.tenantRequired({ + companyAccessChecker: identityServices.companyAccess, + companyFinder: companiesServices.finder, + }), + ]; } diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts index 3cd45b80..51b38afd 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts @@ -1,8 +1,9 @@ import accountModelInit from "./models/sequelize-account.model"; +import companyMembershipModelInit from "./models/sequelize-company-membership.model"; import refreshTokenModelInit from "./models/sequelize-refresh-token.model"; export * from "./mappers"; export * from "./models"; export * from "./repositories"; -export const models = [accountModelInit, refreshTokenModelInit]; +export const models = [accountModelInit, refreshTokenModelInit, companyMembershipModelInit]; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts index 05015406..6c5a361a 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts @@ -1,2 +1,3 @@ export * from "./sequelize-account.model"; +export * from "./sequelize-company-membership.model"; export * from "./sequelize-refresh-token.model"; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership.model.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership.model.ts new file mode 100644 index 00000000..66918217 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership.model.ts @@ -0,0 +1,90 @@ +import { + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type Sequelize, +} from "sequelize"; + +export type CompanyMembershipCreationAttributes = InferCreationAttributes; + +export class CompanyMembershipModel extends Model< + InferAttributes, + InferCreationAttributes +> { + 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; +}; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts index 69f8cc03..c79a169f 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts @@ -1,2 +1,3 @@ export * from "./sequelize-account.repository"; +export * from "./sequelize-company-membership-access.repository"; export * from "./sequelize-refresh-token.repository"; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership-access.repository.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership-access.repository.ts new file mode 100644 index 00000000..7006efb8 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership-access.repository.ts @@ -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 + 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> { + 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, 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)); + } + } +} diff --git a/modules/identity/src/web/auth-routes.tsx b/modules/identity/src/web/auth-routes.tsx index 71eaad12..c0ce060e 100644 --- a/modules/identity/src/web/auth-routes.tsx +++ b/modules/identity/src/web/auth-routes.tsx @@ -1,7 +1,9 @@ import type { ModuleClientParams } from "@erp/core/client"; import type { RouteObject } from "react-router-dom"; +import { CompanySelectionPage } from "./company-selection"; import { LoginPage } from "./login"; +import { NoCompaniesPage } from "./no-companies"; export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] { return [ @@ -13,6 +15,23 @@ export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] { }, element: , }, + { + path: "company-selection", + handle: { + layout: "app-fullscreen", + protected: true, + requireCompany: false, + }, + element: , + }, + { + path: "no-companies", + handle: { + layout: "app-fullscreen", + protected: true, + requireCompany: false, + }, + element: , + }, ]; } - diff --git a/modules/identity/src/web/company-selection/controllers/index.ts b/modules/identity/src/web/company-selection/controllers/index.ts new file mode 100644 index 00000000..9fabda46 --- /dev/null +++ b/modules/identity/src/web/company-selection/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-company-selection-page-controller"; diff --git a/modules/identity/src/web/company-selection/controllers/use-company-selection-page-controller.ts b/modules/identity/src/web/company-selection/controllers/use-company-selection-page-controller.ts new file mode 100644 index 00000000..4e4328da --- /dev/null +++ b/modules/identity/src/web/company-selection/controllers/use-company-selection-page-controller.ts @@ -0,0 +1,59 @@ +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import { useIdentityAuthSession } from "../../context"; + +function resolveSafeReturnTo(returnTo: string | null) { + if (!returnTo) { + return null; + } + + if (!returnTo.startsWith("/") || returnTo.startsWith("//")) { + return null; + } + + return returnTo; +} + +export function useCompanySelectionPageController() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { + availableCompanies, + activeCompany, + isAuthenticated, + isLoading, + logout, + selectActiveCompany, + getAuthenticatedRedirectPath, + } = useIdentityAuthSession(); + + const returnTo = useMemo(() => resolveSafeReturnTo(searchParams.get("returnTo")), [searchParams]); + + const handleSelectCompany = (companyId: string) => { + const selectedCompany = selectActiveCompany(companyId); + + if (!selectedCompany) { + return; + } + + navigate(returnTo ?? "/proformas", { replace: true }); + }; + + const handleLogout = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + return { + activeCompany, + availableCompanies, + isAuthenticated, + isLoading, + handleLogout, + handleSelectCompany, + redirectPath: getAuthenticatedRedirectPath({ + returnTo, + }), + }; +} diff --git a/modules/identity/src/web/company-selection/index.ts b/modules/identity/src/web/company-selection/index.ts new file mode 100644 index 00000000..0d88782e --- /dev/null +++ b/modules/identity/src/web/company-selection/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./ui"; diff --git a/modules/identity/src/web/company-selection/ui/company-selection-page.tsx b/modules/identity/src/web/company-selection/ui/company-selection-page.tsx new file mode 100644 index 00000000..3576f6e3 --- /dev/null +++ b/modules/identity/src/web/company-selection/ui/company-selection-page.tsx @@ -0,0 +1,90 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@repo/shadcn-ui/components"; +import { Navigate } from "react-router-dom"; + +import { useCompanySelectionPageController } from "../controllers"; + +export function CompanySelectionPage() { + const { + activeCompany, + availableCompanies, + isAuthenticated, + isLoading, + handleLogout, + handleSelectCompany, + redirectPath, + } = useCompanySelectionPageController(); + + if (!(isLoading || isAuthenticated)) { + return ; + } + + if (!isLoading && availableCompanies.length === 0) { + return ; + } + + if (!isLoading && activeCompany) { + return ; + } + + return ( +
+ + +

+ FactuGES +

+ + Selecciona una empresa + + + Tienes acceso a varias empresas activas. Elige con cuál quieres trabajar en esta sesión. + +
+ + +
+ {availableCompanies.map((company) => ( + + ))} +
+ +
+ +
+
+
+
+ ); +} diff --git a/modules/identity/src/web/company-selection/ui/index.ts b/modules/identity/src/web/company-selection/ui/index.ts new file mode 100644 index 00000000..4d9172f8 --- /dev/null +++ b/modules/identity/src/web/company-selection/ui/index.ts @@ -0,0 +1 @@ +export * from "./company-selection-page"; diff --git a/modules/identity/src/web/context/identity-auth-session.context.tsx b/modules/identity/src/web/context/identity-auth-session.context.tsx index efecb6e2..20371baa 100644 --- a/modules/identity/src/web/context/identity-auth-session.context.tsx +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -3,24 +3,38 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import type { LoginForm } from "../login/entities"; import { + AvailableCompaniesResponseDTOSchema, + activeCompanyStorage, authTokenStorage, + findAvailableCompanies, + mapAvailableCompany, mapCurrentSessionToAuthSession, + resolveAuthenticatedRedirectPath, + resolveCompanySession, useLoginMutation, useLogoutMutation, useRefreshSessionMutation, } from "../shared"; import { getCurrentSession } from "../shared/api"; -import type { AuthSession } from "../shared/entities"; +import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities"; export interface IdentityAuthSessionContextValue { session: AuthSession | null; account: AuthSession["account"] | null; + companySession: CompanySession; + availableCompanies: ActiveCompany[]; + activeCompany: ActiveCompany | null; isAuthenticated: boolean; isLoading: boolean; login: (params: LoginForm) => Promise; logout: () => Promise; restoreSession: () => Promise; refreshSession: () => Promise; + selectActiveCompany: (companyId: string) => ActiveCompany | null; + getAuthenticatedRedirectPath: (params?: { + returnTo?: string | null; + fallbackPath?: string; + }) => string; clearSession: () => void; } @@ -28,6 +42,11 @@ const IdentityAuthSessionContext = createContext(null); + const [companySession, setCompanySession] = useState(EMPTY_COMPANY_SESSION); const [isRestoringSession, setIsRestoringSession] = useState(authTokenStorage.hasAccessToken()); const clearSession = useCallback(() => { authTokenStorage.clear(); + activeCompanyStorage.clear(); setSession(null); + setCompanySession(EMPTY_COMPANY_SESSION); }, []); + const hydrateCompanySession = useCallback( + async (accessToken: string) => { + const response = await findAvailableCompanies(dataSource, accessToken); + const parsedResponse = AvailableCompaniesResponseDTOSchema.safeParse(response); + + if (!parsedResponse.success) { + throw new Error("Invalid available companies response"); + } + + const availableCompanies = parsedResponse.data.companies.map(mapAvailableCompany); + const nextCompanySession = resolveCompanySession({ + companies: availableCompanies, + persistedActiveCompanyId: activeCompanyStorage.getActiveCompanyId(), + }); + + if (nextCompanySession.activeCompany) { + activeCompanyStorage.setActiveCompanyId(nextCompanySession.activeCompany.id); + } else { + activeCompanyStorage.clear(); + } + + setCompanySession(nextCompanySession); + + return nextCompanySession; + }, + [dataSource] + ); + const restoreSession = useCallback(async () => { const tokens = authTokenStorage.getTokens(); @@ -60,6 +110,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshToken: tokens.refreshToken ?? "", }); + await hydrateCompanySession(tokens.accessToken); setSession(restoredSession); return restoredSession; @@ -69,7 +120,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac } finally { setIsRestoringSession(false); } - }, [clearSession, dataSource]); + }, [clearSession, dataSource, hydrateCompanySession]); const login = useCallback( async (params: LoginForm) => { @@ -80,11 +131,13 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshToken: nextSession.refreshToken, }); + activeCompanyStorage.clear(); + await hydrateCompanySession(nextSession.accessToken); setSession(nextSession); return nextSession; }, - [loginMutation] + [hydrateCompanySession, loginMutation] ); const logout = useCallback(async () => { @@ -126,6 +179,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshToken: currentRefreshToken, }); + await hydrateCompanySession(currentAccessToken); setSession(refreshedSession); return refreshedSession; @@ -133,16 +187,55 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac clearSession(); return null; } - }, [clearSession, dataSource, refreshSessionMutation]); + }, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation]); + + const selectActiveCompany = useCallback( + (companyId: string) => { + const nextActiveCompany = + companySession.availableCompanies.find((company) => company.id === companyId) ?? null; + + if (!nextActiveCompany) { + activeCompanyStorage.clear(); + setCompanySession((currentSession) => ({ + ...currentSession, + activeCompany: null, + })); + return null; + } + + activeCompanyStorage.setActiveCompanyId(nextActiveCompany.id); + setCompanySession((currentSession) => ({ + ...currentSession, + activeCompany: nextActiveCompany, + })); + + return nextActiveCompany; + }, + [companySession.availableCompanies] + ); + + const getAuthenticatedRedirectPath = useCallback( + (params?: { returnTo?: string | null; fallbackPath?: string }) => { + return resolveAuthenticatedRedirectPath({ + companySession, + fallbackPath: params?.fallbackPath ?? "/proformas", + returnTo: params?.returnTo, + }); + }, + [companySession] + ); useEffect(() => { - void restoreSession(); + restoreSession(); }, [restoreSession]); const value = useMemo( () => ({ session, account: session?.account ?? null, + companySession, + availableCompanies: companySession.availableCompanies, + activeCompany: companySession.activeCompany, isAuthenticated: Boolean(session), isLoading: isRestoringSession || @@ -153,10 +246,14 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac logout, restoreSession, refreshSession, + selectActiveCompany, + getAuthenticatedRedirectPath, clearSession, }), [ clearSession, + companySession, + getAuthenticatedRedirectPath, isRestoringSession, login, loginMutation.isPending, @@ -165,6 +262,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshSession, refreshSessionMutation.isPending, restoreSession, + selectActiveCompany, session, ] ); diff --git a/modules/identity/src/web/index.ts b/modules/identity/src/web/index.ts index c9df9425..43d25442 100644 --- a/modules/identity/src/web/index.ts +++ b/modules/identity/src/web/index.ts @@ -1,5 +1,6 @@ +export * from "./company-selection"; export * from "./context"; export * from "./hooks"; export * from "./manifest"; +export * from "./no-companies"; export * from "./shared"; - diff --git a/modules/identity/src/web/login/controllers/use-login-controller.ts b/modules/identity/src/web/login/controllers/use-login-controller.ts index 2f66fd31..339b0238 100644 --- a/modules/identity/src/web/login/controllers/use-login-controller.ts +++ b/modules/identity/src/web/login/controllers/use-login-controller.ts @@ -18,7 +18,7 @@ function getLoginErrorMessage(error: unknown) { return "No se ha podido iniciar sesión. Inténtalo de nuevo."; } -export function useLoginController({ onSuccess }: { onSuccess?: () => void }) { +export function useLoginController() { const { login } = useIdentityAuthSession(); const form = useHookForm({ @@ -34,7 +34,6 @@ export function useLoginController({ onSuccess }: { onSuccess?: () => void }) { try { await login(values); - onSuccess?.(); } catch (error) { form.setError("root", { message: getLoginErrorMessage(error), diff --git a/modules/identity/src/web/login/controllers/use-login-page-controller.ts b/modules/identity/src/web/login/controllers/use-login-page-controller.ts index 04adbf9c..adab96ed 100644 --- a/modules/identity/src/web/login/controllers/use-login-page-controller.ts +++ b/modules/identity/src/web/login/controllers/use-login-page-controller.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { useIdentityAuthSession } from "../../context"; @@ -22,8 +22,7 @@ function resolveSafeReturnTo(returnTo: string | null, fallbackPath: string) { } export function useLoginPageController() { - const navigate = useNavigate(); - const { isAuthenticated, isLoading } = useIdentityAuthSession(); + const { getAuthenticatedRedirectPath, isAuthenticated, isLoading } = useIdentityAuthSession(); const [searchParams] = useSearchParams(); const redirectTo = useMemo( @@ -31,15 +30,12 @@ export function useLoginPageController() { [searchParams] ); - const loginController = useLoginController({ - onSuccess: () => { - navigate(redirectTo, { replace: true }); - }, - }); + const loginController = useLoginController(); return { loginController, redirectTo, + getAuthenticatedRedirectPath, isAuthenticated, isLoading, }; diff --git a/modules/identity/src/web/login/ui/login-page.tsx b/modules/identity/src/web/login/ui/login-page.tsx index 2981e8e0..5f4e1ce1 100644 --- a/modules/identity/src/web/login/ui/login-page.tsx +++ b/modules/identity/src/web/login/ui/login-page.tsx @@ -5,10 +5,18 @@ import { useLoginPageController } from "../controllers"; import { LoginCard } from "./login-card"; export function LoginPage() { - const { loginController, redirectTo, isAuthenticated, isLoading } = useLoginPageController(); + const { getAuthenticatedRedirectPath, loginController, redirectTo, isAuthenticated, isLoading } = + useLoginPageController(); if (!isLoading && isAuthenticated) { - return ; + return ( + + ); } return ( @@ -21,7 +29,7 @@ export function LoginPage() {

- Identity + FactuGES

Inicio de sesión diff --git a/modules/identity/src/web/no-companies/controllers/index.ts b/modules/identity/src/web/no-companies/controllers/index.ts new file mode 100644 index 00000000..a2b0fb38 --- /dev/null +++ b/modules/identity/src/web/no-companies/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-no-companies-page-controller"; diff --git a/modules/identity/src/web/no-companies/controllers/use-no-companies-page-controller.ts b/modules/identity/src/web/no-companies/controllers/use-no-companies-page-controller.ts new file mode 100644 index 00000000..03cee35f --- /dev/null +++ b/modules/identity/src/web/no-companies/controllers/use-no-companies-page-controller.ts @@ -0,0 +1,22 @@ +import { useNavigate } from "react-router-dom"; + +import { useIdentityAuthSession } from "../../context"; + +export function useNoCompaniesPageController() { + const navigate = useNavigate(); + const { activeCompany, availableCompanies, isAuthenticated, isLoading, logout } = + useIdentityAuthSession(); + + const handleLogout = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + + return { + activeCompany, + availableCompanies, + isAuthenticated, + isLoading, + handleLogout, + }; +} diff --git a/modules/identity/src/web/no-companies/index.ts b/modules/identity/src/web/no-companies/index.ts new file mode 100644 index 00000000..0d88782e --- /dev/null +++ b/modules/identity/src/web/no-companies/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./ui"; diff --git a/modules/identity/src/web/no-companies/ui/index.ts b/modules/identity/src/web/no-companies/ui/index.ts new file mode 100644 index 00000000..bbb73040 --- /dev/null +++ b/modules/identity/src/web/no-companies/ui/index.ts @@ -0,0 +1 @@ +export * from "./no-companies-page"; diff --git a/modules/identity/src/web/no-companies/ui/no-companies-page.tsx b/modules/identity/src/web/no-companies/ui/no-companies-page.tsx new file mode 100644 index 00000000..c9261ab4 --- /dev/null +++ b/modules/identity/src/web/no-companies/ui/no-companies-page.tsx @@ -0,0 +1,53 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@repo/shadcn-ui/components"; +import { Navigate } from "react-router-dom"; + +import { useNoCompaniesPageController } from "../controllers"; + +export function NoCompaniesPage() { + const { activeCompany, availableCompanies, isAuthenticated, isLoading, handleLogout } = + useNoCompaniesPageController(); + + if (!(isLoading || isAuthenticated)) { + return ; + } + + if (!isLoading && activeCompany) { + return ; + } + + if (!isLoading && availableCompanies.length > 0) { + return ; + } + + return ( +
+ + +

+ FactuGES +

+ + No tienes empresas disponibles + + + Tu cuenta se ha autenticado correctamente, pero ahora mismo no tiene ninguna empresa + activa asociada para acceder al ERP. + +
+ + + + +
+
+ ); +} diff --git a/modules/identity/src/web/shared/adapters/active-company.adapter.ts b/modules/identity/src/web/shared/adapters/active-company.adapter.ts new file mode 100644 index 00000000..78898ba7 --- /dev/null +++ b/modules/identity/src/web/shared/adapters/active-company.adapter.ts @@ -0,0 +1,103 @@ +import type { AvailableCompanyDTO } from "../api/dto/available-companies.dto"; +import type { ActiveCompany, AuthSession } from "../entities"; + +export function mapAvailableCompany(dto: AvailableCompanyDTO): ActiveCompany { + return { + id: dto.id, + legalName: dto.legal_name, + tradeName: dto.trade_name, + tin: dto.tin, + slug: dto.slug, + email: dto.email, + phone: dto.phone, + website: dto.website, + status: dto.status, + }; +} + +export function resolveActiveCompany(companies: ActiveCompany[]) { + if (companies.length === 0) { + return { + kind: "none" as const, + companies, + }; + } + + if (companies.length === 1) { + return { + kind: "auto-selected" as const, + companies, + activeCompany: companies[0], + }; + } + + return { + kind: "selection-required" as const, + companies, + }; +} + +export function resolveCompanySession(params: { + companies: ActiveCompany[]; + persistedActiveCompanyId: string | null; +}) { + const persistedCompany = params.persistedActiveCompanyId + ? params.companies.find((company) => company.id === params.persistedActiveCompanyId) ?? null + : null; + + if (persistedCompany) { + return { + availableCompanies: params.companies, + activeCompany: persistedCompany, + }; + } + + const resolution = resolveActiveCompany(params.companies); + + if (resolution.kind === "auto-selected") { + return { + availableCompanies: resolution.companies, + activeCompany: resolution.activeCompany, + }; + } + + return { + availableCompanies: resolution.companies, + activeCompany: null, + }; +} + +export function resolveAuthenticatedRedirectPath(params: { + companySession: { + availableCompanies: ActiveCompany[]; + activeCompany: ActiveCompany | null; + }; + fallbackPath: string; + returnTo?: string | null; +}) { + if (params.companySession.availableCompanies.length === 0) { + return "/no-companies"; + } + + if (!params.companySession.activeCompany) { + const returnTo = params.returnTo; + + if (returnTo?.startsWith("/") && !returnTo.startsWith("//")) { + return `/company-selection?returnTo=${encodeURIComponent(returnTo)}`; + } + + return "/company-selection"; + } + + const returnTo = params.returnTo; + + if (!returnTo) { + return params.fallbackPath; + } + + if (!returnTo.startsWith("/") || returnTo.startsWith("//")) { + return params.fallbackPath; + } + + return returnTo; +} diff --git a/modules/identity/src/web/shared/adapters/index.ts b/modules/identity/src/web/shared/adapters/index.ts index e9a067d0..b8a6c301 100644 --- a/modules/identity/src/web/shared/adapters/index.ts +++ b/modules/identity/src/web/shared/adapters/index.ts @@ -1,2 +1,2 @@ +export * from "./active-company.adapter"; export * from "./auth-session.adapter"; - diff --git a/modules/identity/src/web/shared/api/dto/available-companies.dto.ts b/modules/identity/src/web/shared/api/dto/available-companies.dto.ts new file mode 100644 index 00000000..78f57271 --- /dev/null +++ b/modules/identity/src/web/shared/api/dto/available-companies.dto.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; + +export const AvailableCompanyDTOSchema = z.object({ + id: z.uuid(), + legal_name: z.string(), + trade_name: z.string().nullable(), + tin: z.string().nullable(), + slug: z.string(), + email: z.string().nullable(), + phone: z.string().nullable(), + website: z.string().nullable(), + status: z.literal("active"), +}); + +export const AvailableCompaniesResponseDTOSchema = z.object({ + companies: z.array(AvailableCompanyDTOSchema), +}); + +export type AvailableCompanyDTO = z.infer; +export type AvailableCompaniesResponseDTO = z.infer; diff --git a/modules/identity/src/web/shared/api/find-available-companies.api.ts b/modules/identity/src/web/shared/api/find-available-companies.api.ts new file mode 100644 index 00000000..d0a5c633 --- /dev/null +++ b/modules/identity/src/web/shared/api/find-available-companies.api.ts @@ -0,0 +1,17 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { AvailableCompaniesResponseDTO } from "./dto/available-companies.dto"; + +export function findAvailableCompanies( + dataSource: IDataSource, + accessToken: string +): Promise { + return dataSource.custom, AvailableCompaniesResponseDTO>({ + path: "companies/available", + method: "get", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + data: {}, + }); +} diff --git a/modules/identity/src/web/shared/api/index.ts b/modules/identity/src/web/shared/api/index.ts index 6b22afe5..69990338 100644 --- a/modules/identity/src/web/shared/api/index.ts +++ b/modules/identity/src/web/shared/api/index.ts @@ -1,6 +1,7 @@ +export * from "./dto/available-companies.dto"; +export * from "./find-available-companies.api"; export * from "./get-current-session.api"; export * from "./identity-auth-client"; export * from "./login.api"; export * from "./logout.api"; export * from "./refresh-session.api"; - diff --git a/modules/identity/src/web/shared/entities/active-company.entity.ts b/modules/identity/src/web/shared/entities/active-company.entity.ts new file mode 100644 index 00000000..9f8e8e5a --- /dev/null +++ b/modules/identity/src/web/shared/entities/active-company.entity.ts @@ -0,0 +1,31 @@ +export interface ActiveCompany { + id: string; + legalName: string; + tradeName: string | null; + tin: string | null; + slug: string; + email: string | null; + phone: string | null; + website: string | null; + status: "active"; +} + +export interface CompanySession { + availableCompanies: ActiveCompany[]; + activeCompany: ActiveCompany | null; +} + +export type ResolveActiveCompanyResult = + | { + kind: "none"; + companies: ActiveCompany[]; + } + | { + kind: "auto-selected"; + companies: ActiveCompany[]; + activeCompany: ActiveCompany; + } + | { + kind: "selection-required"; + companies: ActiveCompany[]; + }; diff --git a/modules/identity/src/web/shared/entities/index.ts b/modules/identity/src/web/shared/entities/index.ts index 2aae0af4..5374aebd 100644 --- a/modules/identity/src/web/shared/entities/index.ts +++ b/modules/identity/src/web/shared/entities/index.ts @@ -1,3 +1,3 @@ +export * from "./active-company.entity"; export * from "./auth-session.entity"; export * from "./identity-auth-error.entity"; - diff --git a/modules/identity/src/web/shared/storage/active-company-storage.ts b/modules/identity/src/web/shared/storage/active-company-storage.ts new file mode 100644 index 00000000..0d6bdce2 --- /dev/null +++ b/modules/identity/src/web/shared/storage/active-company-storage.ts @@ -0,0 +1,23 @@ +import secureLocalStorageImport from "react-secure-storage"; + +const secureLocalStorage = + (secureLocalStorageImport as { default?: typeof secureLocalStorageImport })?.default ?? + secureLocalStorageImport; + +const ACTIVE_COMPANY_STORAGE_KEY = "factuges.active-company-id"; + +export const activeCompanyStorage = { + getActiveCompanyId(): string | null { + const value = secureLocalStorage?.getItem?.(ACTIVE_COMPANY_STORAGE_KEY); + + return typeof value === "string" && value.length > 0 ? value : null; + }, + + setActiveCompanyId(companyId: string): void { + secureLocalStorage?.setItem?.(ACTIVE_COMPANY_STORAGE_KEY, companyId); + }, + + clear(): void { + secureLocalStorage?.removeItem?.(ACTIVE_COMPANY_STORAGE_KEY); + }, +}; diff --git a/modules/identity/src/web/shared/storage/index.ts b/modules/identity/src/web/shared/storage/index.ts index 7b42d97b..e8aaa50c 100644 --- a/modules/identity/src/web/shared/storage/index.ts +++ b/modules/identity/src/web/shared/storage/index.ts @@ -1,2 +1,2 @@ +export * from "./active-company-storage"; export * from "./auth-token-storage"; - diff --git a/packages/typescript-config/root.json b/packages/typescript-config/root.json index 7ad35c18..0edbc9d6 100644 --- a/packages/typescript-config/root.json +++ b/packages/typescript-config/root.json @@ -9,6 +9,9 @@ "@erp/auth/*": [ "modules/auth/src/*" ], + "@erp/companies/*": [ + "modules/companies/src/*" + ], "@erp/identity/*": [ "modules/identity/src/*" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c4a2184..165e2416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@erp/catalogs': specifier: workspace:* version: link:../../modules/catalogs + '@erp/companies': + specifier: workspace:* + version: link:../../modules/companies '@erp/core': specifier: workspace:* version: link:../../modules/core @@ -237,6 +240,9 @@ importers: '@erp/customers': specifier: workspace:* version: link:../../modules/customers + '@erp/identity': + specifier: workspace:* + version: link:../../modules/identity '@fontsource-variable/geist': specifier: ^5.2.8 version: 5.2.9 @@ -392,12 +398,12 @@ importers: modules/catalogs: dependencies: - '@erp/auth': - specifier: workspace:* - version: link:../auth '@erp/core': specifier: workspace:* version: link:../core + '@erp/identity': + specifier: workspace:* + version: link:../identity '@repo/i18next': specifier: workspace:* version: link:../../packages/i18n @@ -472,6 +478,40 @@ importers: specifier: ^6.0.2 version: 6.0.2 + modules/companies: + dependencies: + '@erp/core': + specifier: workspace:* + version: link:../core + '@erp/identity': + specifier: workspace:* + version: link:../identity + '@repo/rdx-ddd': + specifier: workspace:* + version: link:../../packages/rdx-ddd + '@repo/rdx-utils': + specifier: workspace:* + version: link:../../packages/rdx-utils + express: + specifier: ^4.22.1 + version: 4.22.2 + sequelize: + specifier: ^6.37.8 + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4) + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + modules/core: dependencies: '@hookform/resolvers': @@ -574,9 +614,6 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.6) - '@erp/auth': - specifier: workspace:* - version: link:../auth '@erp/catalogs': specifier: workspace:* version: link:../catalogs @@ -586,6 +623,9 @@ importers: '@erp/customers': specifier: workspace:* version: link:../customers + '@erp/identity': + specifier: workspace:* + version: link:../identity '@hookform/resolvers': specifier: ^5.2.2 version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) @@ -692,15 +732,15 @@ importers: modules/customers: dependencies: - '@erp/auth': - specifier: workspace:* - version: link:../auth '@erp/catalogs': specifier: workspace:* version: link:../catalogs '@erp/core': specifier: workspace:* version: link:../core + '@erp/identity': + specifier: workspace:* + version: link:../identity '@hookform/resolvers': specifier: ^5.2.2 version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) @@ -780,9 +820,6 @@ importers: modules/factuges: dependencies: - '@erp/auth': - specifier: workspace:* - version: link:../auth '@erp/catalogs': specifier: workspace:* version: link:../catalogs @@ -795,6 +832,9 @@ importers: '@erp/customers': specifier: workspace:* version: link:../customers + '@erp/identity': + specifier: workspace:* + version: link:../identity '@repo/i18next': specifier: workspace:* version: link:../../packages/i18n @@ -865,12 +905,18 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + react-hook-form: + specifier: ^7.72.1 + version: 7.76.1(react@19.2.6) react-i18next: specifier: ^17.0.2 version: 17.0.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.2) react-router-dom: specifier: ^7.14.0 version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-secure-storage: + specifier: ^1.3.2 + version: 1.3.2 sequelize: specifier: ^6.37.8 version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4) @@ -878,9 +924,15 @@ importers: specifier: ^4.3.6 version: 4.4.3 devDependencies: + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/express': specifier: ^4.17.21 version: 4.17.25 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/react': specifier: ^19.2.14 version: 19.2.15