Módulo Identity
This commit is contained in:
parent
cc90f64cb4
commit
7a161717c2
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/factuges-server",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --config tsup.config.ts",
|
||||
@ -39,6 +39,7 @@
|
||||
"@erp/catalogs": "workspace:*",
|
||||
"@erp/customer-invoices": "workspace:*",
|
||||
"@erp/customers": "workspace:*",
|
||||
"@erp/identity": "workspace:*",
|
||||
"@erp/factuges": "workspace:*",
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
|
||||
@ -2,6 +2,7 @@ import catalogsAPIModule from "@erp/catalogs/api";
|
||||
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
||||
import customersAPIModule from "@erp/customers/api";
|
||||
import factuGESAPIModule from "@erp/factuges/api";
|
||||
import identityAPIModule from "@erp/identity/api";
|
||||
|
||||
//import suppliersAPIModule from "@erp/suppliers/api";
|
||||
|
||||
@ -13,7 +14,7 @@ import { registerModule } from "./lib";
|
||||
// las rutas, los modelos y los servicios
|
||||
// públicos de ese módulo.
|
||||
export const registerModules = () => {
|
||||
//registerModule(authAPIModule);
|
||||
registerModule(identityAPIModule);
|
||||
registerModule(catalogsAPIModule);
|
||||
registerModule(customersAPIModule);
|
||||
registerModule(customerInvoicesAPIModule);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@erp/factuges-web",
|
||||
"private": true,
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/auth",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@erp/catalogs",
|
||||
"description": "Catalogs module",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/core",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./dto.d";
|
||||
export * from "./module-metadata.d";
|
||||
export * from "./permissions";
|
||||
|
||||
7
modules/core/src/common/types/permissions.ts
Normal file
7
modules/core/src/common/types/permissions.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type PermissionCode = string;
|
||||
|
||||
export type PermissionDefinition = {
|
||||
code: PermissionCode;
|
||||
module: string;
|
||||
description: string;
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/customer-invoices",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
export * from "../entities/invoice-tax-regime.entity";
|
||||
|
||||
export * from "./invoice-address-type.vo";
|
||||
export * from "./invoice-amount.vo";
|
||||
export * from "./invoice-number.vo";
|
||||
export * from "./invoice-recipient/invoice-recipient.vo";
|
||||
export * from "./invoice-serie.vo";
|
||||
export * from "./invoice-status.vo";
|
||||
export * from "./item-amount.vo";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoice-recipient.vo";
|
||||
@ -15,7 +15,6 @@ import type {
|
||||
InvoiceAmount,
|
||||
InvoiceNumber,
|
||||
InvoicePaymentMethod,
|
||||
InvoiceRecipient,
|
||||
InvoiceSerie,
|
||||
InvoiceStatus,
|
||||
} from "../../common";
|
||||
@ -27,6 +26,7 @@ import {
|
||||
type VerifactuRecord,
|
||||
} from "../entities";
|
||||
import { IssuedInvoiceItemMismatch } from "../errors";
|
||||
import type { IssuedInvoiceRecipient } from "../value-objects";
|
||||
|
||||
export interface IIssuedInvoiceCreateProps {
|
||||
companyId: UniqueID;
|
||||
@ -41,7 +41,7 @@ export interface IIssuedInvoiceCreateProps {
|
||||
operationDate: Maybe<UtcDate>;
|
||||
|
||||
customerId: UniqueID;
|
||||
recipient: InvoiceRecipient;
|
||||
recipient: IssuedInvoiceRecipient;
|
||||
|
||||
reference: Maybe<string>;
|
||||
description: string;
|
||||
@ -214,7 +214,7 @@ export class IssuedInvoice
|
||||
return this.props.notes;
|
||||
}
|
||||
|
||||
public get recipient(): InvoiceRecipient {
|
||||
public get recipient(): IssuedInvoiceRecipient {
|
||||
return this.props.recipient;
|
||||
}
|
||||
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./issued-invoice-recipient.vo";
|
||||
export * from "./issued-invoice-tax-group.vo";
|
||||
export * from "./verifactu-status.vo";
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import {
|
||||
type City,
|
||||
type Country,
|
||||
type Name,
|
||||
type PostalCode,
|
||||
type Province,
|
||||
type Street,
|
||||
type TINNumber,
|
||||
ValueObject,
|
||||
maybeToNullable,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export type IssuedInvoiceRecipientProps = {
|
||||
name: Name;
|
||||
tin: TINNumber;
|
||||
street: Maybe<Street>;
|
||||
street2: Maybe<Street>;
|
||||
city: Maybe<City>;
|
||||
postalCode: Maybe<PostalCode>;
|
||||
province: Maybe<Province>;
|
||||
country: Maybe<Country>;
|
||||
};
|
||||
|
||||
export class IssuedInvoiceRecipient extends ValueObject<IssuedInvoiceRecipientProps> {
|
||||
protected static validate(values: IssuedInvoiceRecipientProps) {
|
||||
return Result.ok(values);
|
||||
}
|
||||
|
||||
static create(values: IssuedInvoiceRecipientProps): Result<IssuedInvoiceRecipient, Error> {
|
||||
const valueIsValid = IssuedInvoiceRecipient.validate(values);
|
||||
|
||||
if (valueIsValid.isFailure) {
|
||||
return Result.fail(valueIsValid.error);
|
||||
}
|
||||
|
||||
return Result.ok(new IssuedInvoiceRecipient(values));
|
||||
}
|
||||
|
||||
public update(
|
||||
partial: Partial<IssuedInvoiceRecipientProps>
|
||||
): Result<IssuedInvoiceRecipient, Error> {
|
||||
const updatedProps = {
|
||||
...this.props,
|
||||
...partial,
|
||||
} as IssuedInvoiceRecipientProps;
|
||||
|
||||
return IssuedInvoiceRecipient.create(updatedProps);
|
||||
}
|
||||
|
||||
public get name(): Name {
|
||||
return this.props.name;
|
||||
}
|
||||
|
||||
public get tin(): TINNumber {
|
||||
return this.props.tin;
|
||||
}
|
||||
|
||||
get street(): Maybe<Street> {
|
||||
return this.props.street;
|
||||
}
|
||||
|
||||
get street2(): Maybe<Street> {
|
||||
return this.props.street2;
|
||||
}
|
||||
|
||||
get city(): Maybe<City> {
|
||||
return this.props.city;
|
||||
}
|
||||
|
||||
get postalCode(): Maybe<PostalCode> {
|
||||
return this.props.postalCode;
|
||||
}
|
||||
|
||||
get province(): Maybe<Province> {
|
||||
return this.props.province;
|
||||
}
|
||||
|
||||
get country(): Maybe<Country> {
|
||||
return this.props.country;
|
||||
}
|
||||
|
||||
getProps(): IssuedInvoiceRecipientProps {
|
||||
return this.props;
|
||||
}
|
||||
|
||||
toPrimitive() {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toObjectString() {
|
||||
return {
|
||||
tin: this.tin.toString(),
|
||||
name: this.name.toString(),
|
||||
street: maybeToNullable(this.street, (value) => value.toString()),
|
||||
street2: maybeToNullable(this.street2, (value) => value.toString()),
|
||||
city: maybeToNullable(this.city, (value) => value.toString()),
|
||||
postal_code: maybeToNullable(this.postalCode, (value) => value.toString()),
|
||||
province: maybeToNullable(this.province, (value) => value.toString()),
|
||||
country: maybeToNullable(this.country, (value) => value.toString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,6 @@ import {
|
||||
INVOICE_STATUS,
|
||||
InvoiceAmount,
|
||||
type InvoiceNumber,
|
||||
type InvoiceRecipient,
|
||||
type InvoiceSerie,
|
||||
InvoiceStatus,
|
||||
type ItemAmount,
|
||||
@ -30,7 +29,7 @@ import {
|
||||
import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors";
|
||||
import type { IProformaTaxTotals, ProformaCalculationContext } from "../services";
|
||||
import { canManuallyTransitionProformaStatus } from "../services";
|
||||
import { ProformaItemTaxes } from "../value-objects";
|
||||
import { ProformaItemTaxes, type ProformaRecipient } from "../value-objects";
|
||||
|
||||
export interface IProformaCreateProps {
|
||||
companyId: UniqueID;
|
||||
@ -43,7 +42,7 @@ export interface IProformaCreateProps {
|
||||
operationDate: Maybe<UtcDate>;
|
||||
|
||||
customerId: UniqueID;
|
||||
recipient: Maybe<InvoiceRecipient>;
|
||||
recipient: Maybe<ProformaRecipient>;
|
||||
|
||||
reference: Maybe<string>;
|
||||
description: Maybe<string>;
|
||||
@ -93,7 +92,7 @@ export interface IProforma {
|
||||
operationDate: Maybe<UtcDate>;
|
||||
|
||||
customerId: UniqueID;
|
||||
recipient: Maybe<InvoiceRecipient>;
|
||||
recipient: Maybe<ProformaRecipient>;
|
||||
|
||||
reference: Maybe<string>;
|
||||
description: Maybe<string>;
|
||||
@ -258,7 +257,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
|
||||
return this.props.notes;
|
||||
}
|
||||
|
||||
public get recipient(): Maybe<InvoiceRecipient> {
|
||||
public get recipient(): Maybe<ProformaRecipient> {
|
||||
return this.props.recipient;
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./proforma-item-taxes.vo";
|
||||
export * from "./proforma-recipient.vo";
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
} from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export type InvoiceRecipientProps = {
|
||||
export type ProformaRecipientProps = {
|
||||
name: Name;
|
||||
tin: Maybe<TINNumber>;
|
||||
street: Maybe<Street>;
|
||||
@ -22,28 +22,28 @@ export type InvoiceRecipientProps = {
|
||||
country: Maybe<Country>;
|
||||
};
|
||||
|
||||
export class InvoiceRecipient extends ValueObject<InvoiceRecipientProps> {
|
||||
protected static validate(values: InvoiceRecipientProps) {
|
||||
export class ProformaRecipient extends ValueObject<ProformaRecipientProps> {
|
||||
protected static validate(values: ProformaRecipientProps) {
|
||||
return Result.ok(values);
|
||||
}
|
||||
|
||||
static create(values: InvoiceRecipientProps): Result<InvoiceRecipient, Error> {
|
||||
const valueIsValid = InvoiceRecipient.validate(values);
|
||||
static create(values: ProformaRecipientProps): Result<ProformaRecipient, Error> {
|
||||
const valueIsValid = ProformaRecipient.validate(values);
|
||||
|
||||
if (valueIsValid.isFailure) {
|
||||
return Result.fail(valueIsValid.error);
|
||||
}
|
||||
|
||||
return Result.ok(new InvoiceRecipient(values));
|
||||
return Result.ok(new ProformaRecipient(values));
|
||||
}
|
||||
|
||||
public update(partial: Partial<InvoiceRecipientProps>): Result<InvoiceRecipient, Error> {
|
||||
public update(partial: Partial<ProformaRecipientProps>): Result<ProformaRecipient, Error> {
|
||||
const updatedProps = {
|
||||
...this.props,
|
||||
...partial,
|
||||
} as InvoiceRecipientProps;
|
||||
} as ProformaRecipientProps;
|
||||
|
||||
return InvoiceRecipient.create(updatedProps);
|
||||
return ProformaRecipient.create(updatedProps);
|
||||
}
|
||||
|
||||
public get name(): Name {
|
||||
@ -78,7 +78,7 @@ export class InvoiceRecipient extends ValueObject<InvoiceRecipientProps> {
|
||||
return this.props.country;
|
||||
}
|
||||
|
||||
getProps(): InvoiceRecipientProps {
|
||||
getProps(): ProformaRecipientProps {
|
||||
return this.props;
|
||||
}
|
||||
|
||||
@ -15,14 +15,14 @@ import {
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { type IIssuedInvoiceCreateProps, InvoiceRecipient } from "../../../../../../domain";
|
||||
import { type IIssuedInvoiceCreateProps, IssuedInvoiceRecipient } from "../../../../../../domain";
|
||||
import type { CustomerInvoiceModel } from "../../../../../common";
|
||||
|
||||
export class SequelizeIssuedInvoiceRecipientDomainMapper {
|
||||
public mapToDomain(
|
||||
source: CustomerInvoiceModel,
|
||||
params?: MapperParamsType
|
||||
): Result<InvoiceRecipient, Error> {
|
||||
): Result<IssuedInvoiceRecipient, Error> {
|
||||
/**
|
||||
* - Issued invoice -> snapshot de los datos (campos customer_*)
|
||||
*/
|
||||
@ -45,11 +45,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
|
||||
const customerName = extractOrPushError(Name.create(_name!), "customer_name", errors);
|
||||
|
||||
//const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors);
|
||||
const customerTin = extractOrPushError(
|
||||
maybeFromNullableResult(_tin, (value) => TINNumber.create(value)),
|
||||
"customer_tin",
|
||||
errors
|
||||
);
|
||||
const customerTin = extractOrPushError(TINNumber.create(_tin), "customer_tin", errors);
|
||||
|
||||
const customerStreet = extractOrPushError(
|
||||
maybeFromNullableResult(_street, (value) => Street.create(value)),
|
||||
@ -87,7 +83,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
|
||||
errors
|
||||
);
|
||||
|
||||
const createResult = InvoiceRecipient.create({
|
||||
const createResult = IssuedInvoiceRecipient.create({
|
||||
name: customerName!,
|
||||
tin: customerTin!,
|
||||
street: customerStreet!,
|
||||
@ -118,7 +114,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
|
||||
* - Si la factura no es proforma (`isProforma === false`), debe existir `recipient`.
|
||||
* En caso contrario, se agrega un error de validación.
|
||||
*/
|
||||
mapToPersistence(source: InvoiceRecipient, params?: MapperParamsType) {
|
||||
mapToPersistence(source: IssuedInvoiceRecipient, params?: MapperParamsType) {
|
||||
/*const { errors, parent } = params as {
|
||||
parent: IssuedInvoice;
|
||||
errors: ValidationErrorDetail[];
|
||||
@ -127,7 +123,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
|
||||
const recipient = source;
|
||||
|
||||
return {
|
||||
customer_tin: maybeToNullable(recipient.tin, (value) => value.toString()),
|
||||
customer_tin: recipient.tin.toString(),
|
||||
customer_name: recipient.name.toPrimitive(),
|
||||
customer_street: maybeToNullable(recipient.street, (v) => v.toPrimitive()),
|
||||
customer_street2: maybeToNullable(recipient.street2, (v) => v.toPrimitive()),
|
||||
|
||||
@ -15,17 +15,17 @@ import {
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { IssuedInvoiceSummary } from "../../../../../../application";
|
||||
import { InvoiceRecipient } from "../../../../../../domain";
|
||||
import { IssuedInvoiceRecipient } from "../../../../../../domain";
|
||||
import type { CustomerInvoiceModel } from "../../../../../common";
|
||||
|
||||
export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMapper<
|
||||
CustomerInvoiceModel,
|
||||
InvoiceRecipient
|
||||
IssuedInvoiceRecipient
|
||||
> {
|
||||
public mapToReadModel(
|
||||
raw: CustomerInvoiceModel,
|
||||
params?: MapperParamsType
|
||||
): Result<InvoiceRecipient, Error> {
|
||||
): Result<IssuedInvoiceRecipient, Error> {
|
||||
/**
|
||||
* - Issued invoice => snapshot de los datos (campos customer_*)
|
||||
*/
|
||||
@ -94,7 +94,7 @@ export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMap
|
||||
errors
|
||||
);
|
||||
|
||||
const createResult = InvoiceRecipient.create({
|
||||
const createResult = IssuedInvoiceRecipient.create({
|
||||
name: customerName!,
|
||||
tin: customerTin!,
|
||||
street: customerStreet!,
|
||||
|
||||
@ -14,14 +14,14 @@ import {
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { type IProformaCreateProps, InvoiceRecipient } from "../../../../../../domain";
|
||||
import { type IProformaCreateProps, ProformaRecipient } from "../../../../../../domain";
|
||||
import type { CustomerInvoiceModel } from "../../../../../common";
|
||||
|
||||
export class SequelizeProformaRecipientDomainMapper {
|
||||
public mapToDomain(
|
||||
source: CustomerInvoiceModel,
|
||||
params?: MapperParamsType
|
||||
): Result<Maybe<InvoiceRecipient>, Error> {
|
||||
): Result<Maybe<ProformaRecipient>, Error> {
|
||||
/**
|
||||
* En proforma -> datos de "current_customer"
|
||||
*/
|
||||
@ -85,7 +85,7 @@ export class SequelizeProformaRecipientDomainMapper {
|
||||
errors
|
||||
);
|
||||
|
||||
const createResult = InvoiceRecipient.create({
|
||||
const createResult = ProformaRecipient.create({
|
||||
name: customerName!,
|
||||
tin: customerTin!,
|
||||
street: customerStreet!,
|
||||
@ -110,7 +110,7 @@ export class SequelizeProformaRecipientDomainMapper {
|
||||
/**
|
||||
* Para una proforma, el recipient no se persiste (todos los campos son null).
|
||||
*/
|
||||
mapToPersistence(source: Maybe<InvoiceRecipient>, params?: MapperParamsType) {
|
||||
mapToPersistence(source: Maybe<ProformaRecipient>, params?: MapperParamsType) {
|
||||
return {
|
||||
customer_tin: null,
|
||||
customer_name: null,
|
||||
|
||||
@ -15,17 +15,17 @@ import {
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { ProformaSummary } from "../../../../../../application";
|
||||
import { InvoiceRecipient } from "../../../../../../domain";
|
||||
import { ProformaRecipient } from "../../../../../../domain";
|
||||
import type { CustomerInvoiceModel } from "../../../../../common";
|
||||
|
||||
export class SequelizeInvoiceRecipientSummaryMapper extends SequelizeQueryMapper<
|
||||
CustomerInvoiceModel,
|
||||
InvoiceRecipient
|
||||
ProformaRecipient
|
||||
> {
|
||||
public mapToReadModel(
|
||||
raw: CustomerInvoiceModel,
|
||||
params?: MapperParamsType
|
||||
): Result<InvoiceRecipient, Error> {
|
||||
): Result<ProformaRecipient, Error> {
|
||||
/**
|
||||
* En proforma -> datos de "current_customer"
|
||||
*/
|
||||
@ -43,6 +43,7 @@ export class SequelizeInvoiceRecipientSummaryMapper extends SequelizeQueryMapper
|
||||
message: "Current customer not included in query (SequelizeInvoiceRecipientListMapper)",
|
||||
});
|
||||
}
|
||||
|
||||
const _name = raw.current_customer!.name;
|
||||
const _tin = raw.current_customer!.tin;
|
||||
const _street = raw.current_customer!.street;
|
||||
@ -98,7 +99,7 @@ export class SequelizeInvoiceRecipientSummaryMapper extends SequelizeQueryMapper
|
||||
errors
|
||||
);
|
||||
|
||||
const createResult = InvoiceRecipient.create({
|
||||
const createResult = ProformaRecipient.create({
|
||||
name: customerName!,
|
||||
tin: customerTin!,
|
||||
street: customerStreet!,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||
import { AppContent, AppHeader, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components";
|
||||
import { AppContent, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@ -57,25 +57,23 @@ export const ListIssuedInvoicesPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<AppHeader>
|
||||
<PageHeader
|
||||
description={t("pages.issued_invoices.list.description")}
|
||||
rightSlot={
|
||||
<Button
|
||||
aria-label={t("pages.issued_invoices.create.title")}
|
||||
className="hidden"
|
||||
onClick={() => navigate("/issued-invoices/create")}
|
||||
>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("pages.issued_invoices.create.title")}
|
||||
</Button>
|
||||
}
|
||||
title={t("pages.issued_invoices.list.title")}
|
||||
/>
|
||||
</AppHeader>
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
description={t("pages.issued_invoices.list.description")}
|
||||
rightSlot={
|
||||
<Button
|
||||
aria-label={t("pages.issued_invoices.create.title")}
|
||||
className="hidden"
|
||||
onClick={() => navigate("/issued-invoices/create")}
|
||||
>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("pages.issued_invoices.create.title")}
|
||||
</Button>
|
||||
}
|
||||
title={t("pages.issued_invoices.list.title")}
|
||||
/>
|
||||
|
||||
<AppContent className="space-y-6">
|
||||
<div className="mx-auto w-full space-y-4">
|
||||
<Alert className="bg-green-50 text-green-800 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle>
|
||||
@ -154,7 +152,7 @@ export const ListIssuedInvoicesPage = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AppContent>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./list";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./proforma-context";
|
||||
@ -1,118 +0,0 @@
|
||||
import type { TaxCatalogProvider } from "@erp/core";
|
||||
import type { ModuleTFunction } from "@repo/rdx-ui/locales/i18n.js";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
|
||||
export type ProformaContextValue = {
|
||||
company_id: string;
|
||||
proforma_id: string;
|
||||
status: string;
|
||||
currency_code: string;
|
||||
language_code: string;
|
||||
is_proforma: boolean;
|
||||
|
||||
readOnly: boolean;
|
||||
taxCatalog: TaxCatalogProvider;
|
||||
|
||||
t: ModuleTFunction;
|
||||
|
||||
changeLanguage: (lang: string) => void;
|
||||
changeCurrency: (currency: string) => void;
|
||||
changeIsProforma: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const ProformaContext = createContext<ProformaContextValue | null>(null);
|
||||
|
||||
export interface ProformaProviderParams {
|
||||
taxCatalog: TaxCatalogProvider;
|
||||
proforma_id: string;
|
||||
company_id: string;
|
||||
status: string; // default "draft"
|
||||
language_code?: string; // default "es"
|
||||
currency_code?: string; // default "EUR"
|
||||
is_proforma?: boolean; // default 'true'
|
||||
readOnly?: boolean; // default 'false'
|
||||
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProformaProvider = ({
|
||||
taxCatalog: initialTaxCatalog,
|
||||
proforma_id,
|
||||
company_id,
|
||||
status: initialStatus = "draft",
|
||||
language_code: initialLang = "es",
|
||||
currency_code: initialCurrency = "EUR",
|
||||
readOnly: initialReadOnly = false,
|
||||
is_proforma: initialProforma = true,
|
||||
children,
|
||||
}: PropsWithChildren<ProformaProviderParams>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Estado interno local para campos dinámicos
|
||||
const [language_code, setLanguage] = useState(initialLang);
|
||||
const [currency_code, setCurrency] = useState(initialCurrency);
|
||||
const [is_proforma, setIsProforma] = useState(initialProforma);
|
||||
const [readOnly, setReadOnly] = useState(initialReadOnly);
|
||||
const [status] = useState(initialStatus);
|
||||
const [taxCatalog] = useState(initialTaxCatalog);
|
||||
|
||||
// Callbacks memoizados
|
||||
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
|
||||
const setCurrencyMemo = useCallback((currency_code: string) => setCurrency(currency_code), []);
|
||||
const setIsProformaMemo = useCallback((is_proforma: boolean) => setIsProforma(is_proforma), []);
|
||||
const setReadOnlyMemo = useCallback((readOnly: boolean) => setReadOnly(readOnly), []);
|
||||
|
||||
const value = useMemo<ProformaContextValue>(() => {
|
||||
return {
|
||||
t,
|
||||
|
||||
proforma_id,
|
||||
company_id,
|
||||
status,
|
||||
language_code,
|
||||
currency_code,
|
||||
is_proforma,
|
||||
|
||||
taxCatalog,
|
||||
readOnly,
|
||||
|
||||
changeLanguage: setLanguageMemo,
|
||||
changeCurrency: setCurrencyMemo,
|
||||
changeIsProforma: setIsProformaMemo,
|
||||
setReadOnly: setReadOnlyMemo,
|
||||
};
|
||||
}, [
|
||||
t,
|
||||
readOnly,
|
||||
company_id,
|
||||
proforma_id,
|
||||
status,
|
||||
language_code,
|
||||
currency_code,
|
||||
is_proforma,
|
||||
taxCatalog,
|
||||
setLanguageMemo,
|
||||
setCurrencyMemo,
|
||||
setIsProformaMemo,
|
||||
setReadOnlyMemo,
|
||||
]);
|
||||
|
||||
return <ProformaContext.Provider value={value}>{children}</ProformaContext.Provider>;
|
||||
};
|
||||
|
||||
export function useProformaContext(): ProformaContextValue {
|
||||
const context = useContext(ProformaContext);
|
||||
if (!context) {
|
||||
throw new Error("useProformaContext must be used within <ProformaProvider>");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./proforma-update-page";
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./proforma-basic-info-fields";
|
||||
export * from "./proforma-totals";
|
||||
@ -1,75 +0,0 @@
|
||||
import { DatePickerInputField, TextField } from "@repo/rdx-ui/components";
|
||||
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
import type { ProformaFormData } from "../../../../types";
|
||||
|
||||
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<ProformaFormData>();
|
||||
|
||||
return (
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend className="hidden text-foreground" variant="label">
|
||||
{t("form_groups.basic_info.title")}
|
||||
</FieldLegend>
|
||||
<FieldDescription className="hidden">
|
||||
{t("form_groups.basic_info.description")}
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="flex flex-row flex-wrap gap-6 xl:flex-nowrap">
|
||||
<DatePickerInputField
|
||||
className="min-w-44 flex-1 sm:max-w-44"
|
||||
control={control}
|
||||
description={t("form_fields.invoice_date.description")}
|
||||
label={t("form_fields.invoice_date.label")}
|
||||
name="invoice_date"
|
||||
numberOfMonths={2}
|
||||
placeholder={t("form_fields.invoice_date.placeholder")}
|
||||
required
|
||||
/>
|
||||
|
||||
<DatePickerInputField
|
||||
className="min-w-44 flex-1 sm:max-w-44"
|
||||
control={control}
|
||||
description={t("form_fields.operation_date.description")}
|
||||
label={t("form_fields.operation_date.label")}
|
||||
name="operation_date"
|
||||
numberOfMonths={2}
|
||||
placeholder={t("form_fields.operation_date.placeholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="min-w-16 flex-1 sm:max-w-16"
|
||||
control={control}
|
||||
description={t("form_fields.series.description")}
|
||||
label={t("form_fields.series.label")}
|
||||
name="series"
|
||||
placeholder={t("form_fields.series.placeholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="min-w-32 flex-1 sm:max-w-44"
|
||||
control={control}
|
||||
description={t("form_fields.reference.description")}
|
||||
label={t("form_fields.reference.label")}
|
||||
maxLength={256}
|
||||
name="reference"
|
||||
placeholder={t("form_fields.reference.placeholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="min-w-32 flex-1 xs:max-w-full"
|
||||
control={control}
|
||||
description={t("form_fields.description.description")}
|
||||
label={t("form_fields.description.label")}
|
||||
maxLength={256}
|
||||
name="description"
|
||||
placeholder={t("form_fields.description.placeholder")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
@ -1,153 +0,0 @@
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
Separator,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { ReceiptIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
import { useProformaContext } from "../../context";
|
||||
import { PercentageInputField } from "../components";
|
||||
|
||||
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<ProformaFormData>();
|
||||
const { currency_code, language_code, readOnly, taxCatalog } = useProformaContext();
|
||||
|
||||
const displayTaxes = useWatch({ control, name: "taxes", defaultValue: [] });
|
||||
const subtotal_amount = useWatch({ control, name: "subtotal_amount", defaultValue: 0 });
|
||||
const items_discount_amount = useWatch({
|
||||
control,
|
||||
name: "items_discount_amount",
|
||||
defaultValue: 0,
|
||||
});
|
||||
const discount_amount = useWatch({ control, name: "discount_amount", defaultValue: 0 });
|
||||
const taxable_amount = useWatch({ control, name: "taxable_amount", defaultValue: 0 });
|
||||
const taxes_amount = useWatch({ control, name: "taxes_amount", defaultValue: 0 });
|
||||
const total_amount = useWatch({ control, name: "total_amount", defaultValue: 0 });
|
||||
|
||||
return (
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend className="hidden">
|
||||
<ReceiptIcon className="size-6 text-muted-foreground" />
|
||||
{t("form_groups.totals.title")}
|
||||
</FieldLegend>
|
||||
|
||||
<FieldDescription className="hidden">{t("form_groups.totals.description")}</FieldDescription>
|
||||
<FieldGroup className="grid grid-cols-1 border rounded-lg bg-muted/10 p-4 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
{/* Sección: Subtotal y Descuentos */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal sin descuentos</span>
|
||||
<span className="font-medium tabular-nums text-muted-foreground">
|
||||
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">Descuento en líneas</span>
|
||||
</div>
|
||||
<span className="font-medium text-destructive tabular-nums">
|
||||
-{formatCurrency(items_discount_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">Descuento global</span>
|
||||
<PercentageInputField
|
||||
className={cn(
|
||||
"w-20 text-right tabular-nums bg-background",
|
||||
"border-input border text-sm shadow-xs"
|
||||
)}
|
||||
control={control}
|
||||
inputId={"discount-percentage"}
|
||||
name={"discount_percentage"}
|
||||
readOnly={readOnly}
|
||||
showSuffix={true}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-destructive tabular-nums">
|
||||
-{formatCurrency(discount_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sección: Base Imponible */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-foreground">Base imponible</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCurrency(taxable_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Sección: Impuestos */}
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Impuestos y retenciones
|
||||
</h3>
|
||||
|
||||
{taxCatalog.groups().map((group) => {
|
||||
// Filtra impuestos de ese grupo
|
||||
const taxesInGroup = displayTaxes?.filter((item) => {
|
||||
const tax = taxCatalog.findByCode(item.tax_code).match(
|
||||
(t) => t,
|
||||
() => undefined
|
||||
);
|
||||
return tax?.group === group;
|
||||
});
|
||||
|
||||
// Si el grupo no tiene impuestos, no renderiza nada
|
||||
if (taxesInGroup?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 leading-3" key={`tax-group-${group}`}>
|
||||
{taxesInGroup?.map((item) => {
|
||||
const tax = taxCatalog.findByCode(item.tax_code).match(
|
||||
(t) => t,
|
||||
() => undefined
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between text-sm"
|
||||
key={`${group}:${item.tax_code}`}
|
||||
>
|
||||
<span className="text-muted-foreground text-sm">{tax?.name}</span>
|
||||
<span className="font-medium tabular-nums text-sm text-muted-foreground">
|
||||
{formatCurrency(item.taxes_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-between text-sm mt-3">
|
||||
<span className="text-foreground">Total de impuestos</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCurrency(taxes_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between text-sm ">
|
||||
<span className="font-bold text-foreground">Total de la factura</span>
|
||||
<span className="font-bold tabular-nums">
|
||||
{formatCurrency(total_amount, 2, currency_code, language_code)}
|
||||
</span>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./items-editor";
|
||||
export * from "./proforma-editor-skeleton";
|
||||
@ -1,31 +0,0 @@
|
||||
// components/CustomerSkeleton.tsx
|
||||
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../../../i18n";
|
||||
|
||||
export const ProformaEditorSkeleton = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AppContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div aria-hidden="true" className="space-y-2">
|
||||
<div className="h-7 w-64 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-5 w-96 rounded-md bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackHistoryButton />
|
||||
<Button aria-busy disabled>
|
||||
{t("pages.update.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="mt-6 grid gap-4">
|
||||
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-28 w-full rounded-md bg-muted animate-pulse" />
|
||||
</div>
|
||||
<span className="sr-only">{t("pages.update.loading", "Cargando factura de cliente...")}</span>
|
||||
</AppContent>
|
||||
);
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./blocks";
|
||||
export * from "./components";
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@erp/customers",
|
||||
"description": "Customers",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/factuges",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
14
modules/identity/README.md
Normal file
14
modules/identity/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Identity Module
|
||||
|
||||
`identity` convivirá temporalmente con `@erp/auth`.
|
||||
|
||||
`@erp/auth` sigue siendo el runtime de autenticación actual.
|
||||
|
||||
`identity` incorporará progresivamente la implementación V1 aprobada:
|
||||
- `email/password`
|
||||
- `access token`
|
||||
- `refresh token` persistido hasheado
|
||||
- tenant activo mediante `X-Company-Id`
|
||||
- roles y permisos company-scoped
|
||||
|
||||
No se deben registrar middlewares ni reemplazar dependencias de `@erp/auth` hasta que `identity` tenga implementación funcional validada.
|
||||
46
modules/identity/package.json
Normal file
46
modules/identity/package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@erp/identity",
|
||||
"description": "Identity module",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"check": "biome check .",
|
||||
"lint": "biome lint .",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/common/index.ts",
|
||||
"./common": "./src/common/index.ts",
|
||||
"./api": "./src/api/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@erp/core": "workspace:*",
|
||||
"@repo/rdx-criteria": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-ui": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"@repo/shadcn-ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.98.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"express": "^4.22.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"rimraf": "^6.1.3",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import type { EmailAddress } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Account } from "../../../domain";
|
||||
|
||||
export interface IAccountRepository {
|
||||
save(account: Account, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(account: Account, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
findById(id: Account["id"], transaction?: unknown): Promise<Result<Maybe<Account>, Error>>;
|
||||
findByEmail(
|
||||
email: EmailAddress,
|
||||
transaction?: unknown
|
||||
): Promise<Result<Maybe<Account>, Error>>;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./account-repository.interface";
|
||||
1
modules/identity/src/api/application/accounts/index.ts
Normal file
1
modules/identity/src/api/application/accounts/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./contracts";
|
||||
@ -0,0 +1,10 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
export type AccessTokenPayload = {
|
||||
accountId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export interface IAccessTokenIssuer {
|
||||
issue(payload: AccessTokenPayload): Promise<Result<string, Error>>;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
export type VerifiedAccessTokenPayload = {
|
||||
accountId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export interface IAccessTokenVerifier {
|
||||
verify(token: string): Promise<Result<VerifiedAccessTokenPayload, Error>>;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export * from "./access-token-issuer.interface";
|
||||
export * from "./access-token-verifier.interface";
|
||||
export * from "./password-hasher.interface";
|
||||
export * from "./refresh-token-generator.interface";
|
||||
export * from "./refresh-token-repository.interface";
|
||||
export * from "./refresh-token-hasher.interface";
|
||||
@ -0,0 +1,12 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { PasswordHash } from "../../../domain";
|
||||
|
||||
export interface IPasswordHasher {
|
||||
hash(password: string): Promise<Result<PasswordHash, Error>>;
|
||||
|
||||
verify(params: {
|
||||
plainPassword: string;
|
||||
passwordHash: PasswordHash;
|
||||
}): Promise<Result<boolean, Error>>;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
export interface IRefreshTokenGenerator {
|
||||
generate(): Result<string, Error>;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { RefreshTokenHash } from "../../../domain";
|
||||
|
||||
export interface IRefreshTokenHasher {
|
||||
hash(token: string): Result<RefreshTokenHash, Error>;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import type { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { RefreshToken, RefreshTokenHash } from "../../../domain";
|
||||
|
||||
export interface IRefreshTokenRepository {
|
||||
save(refreshToken: RefreshToken, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(refreshToken: RefreshToken, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
findByHash(
|
||||
tokenHash: RefreshTokenHash,
|
||||
transaction?: unknown
|
||||
): Promise<Result<Maybe<RefreshToken>, Error>>;
|
||||
revokeAllByAccountId(params: {
|
||||
accountId: UniqueID;
|
||||
revokedAt: UtcDateTime;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<number, Error>>;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./contracts";
|
||||
export * from "./services";
|
||||
export * from "./use-cases";
|
||||
@ -0,0 +1,70 @@
|
||||
import type { EmailAddress } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Account } from "../../../domain";
|
||||
import type { IAccountRepository } from "../../accounts";
|
||||
import type { IPasswordHasher } from "../contracts";
|
||||
|
||||
import { AccountDisabledError } from "./account-disabled.error";
|
||||
import { AccountNotFoundError } from "./account-not-found.error";
|
||||
import { InvalidCredentialsError } from "./invalid-credentials.error";
|
||||
|
||||
export type AuthenticateAccountWithPasswordParams = {
|
||||
email: EmailAddress;
|
||||
password: string;
|
||||
transaction?: unknown;
|
||||
};
|
||||
|
||||
export class AccountAuthenticator {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accountRepository: IAccountRepository;
|
||||
passwordHasher: IPasswordHasher;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async authenticateWithPassword(
|
||||
params: AuthenticateAccountWithPasswordParams
|
||||
): Promise<Result<Account, Error>> {
|
||||
const accountResult = await this.deps.accountRepository.findByEmail(
|
||||
params.email,
|
||||
params.transaction
|
||||
);
|
||||
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
if (accountResult.data.isNone()) {
|
||||
return Result.fail(
|
||||
new AccountNotFoundError(`Account not found for email "${params.email.toPrimitive()}".`)
|
||||
);
|
||||
}
|
||||
|
||||
const account = accountResult.data.unwrap();
|
||||
|
||||
const authenticationResult = account.canAuthenticate();
|
||||
if (authenticationResult.isFailure) {
|
||||
return Result.fail(
|
||||
new AccountDisabledError("Account cannot authenticate in its current state.", {
|
||||
cause: authenticationResult.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const passwordVerificationResult = await this.deps.passwordHasher.verify({
|
||||
plainPassword: params.password,
|
||||
passwordHash: account.passwordHash,
|
||||
});
|
||||
|
||||
if (passwordVerificationResult.isFailure) {
|
||||
return Result.fail(passwordVerificationResult.error);
|
||||
}
|
||||
|
||||
if (!passwordVerificationResult.data) {
|
||||
return Result.fail(new InvalidCredentialsError("Invalid credentials."));
|
||||
}
|
||||
|
||||
return Result.ok(account);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export class AccountDisabledError extends Error {
|
||||
public constructor(message = "Account is disabled.", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AccountDisabledError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export class AccountNotFoundError extends Error {
|
||||
public constructor(message = "Account not found.", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AccountNotFoundError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export * from "./account-authenticator";
|
||||
export * from "./account-disabled.error";
|
||||
export * from "./account-not-found.error";
|
||||
export * from "./invalid-credentials.error";
|
||||
export * from "./issued-session";
|
||||
export * from "./logout-requires-refresh-token.error";
|
||||
export * from "./refresh-token-account-mismatch.error";
|
||||
export * from "./refresh-token-not-found.error";
|
||||
export * from "./session-issuer";
|
||||
@ -0,0 +1,6 @@
|
||||
export class InvalidCredentialsError extends Error {
|
||||
public constructor(message = "Invalid credentials.", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InvalidCredentialsError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export type IssuedSession = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshTokenId: string;
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
export class LogoutRequiresRefreshTokenError extends Error {
|
||||
public constructor(
|
||||
message = "Logout requires a refresh token or allSessions=true.",
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "LogoutRequiresRefreshTokenError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export class RefreshTokenAccountMismatchError extends Error {
|
||||
public constructor(message = "Refresh token does not belong to the requested account.") {
|
||||
super(message);
|
||||
this.name = "RefreshTokenAccountMismatchError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export class RefreshTokenNotFoundError extends Error {
|
||||
public constructor(message = "Refresh token not found.", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "RefreshTokenNotFoundError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { type Account, RefreshToken } from "../../../domain";
|
||||
import type {
|
||||
IAccessTokenIssuer,
|
||||
IRefreshTokenGenerator,
|
||||
IRefreshTokenHasher,
|
||||
IRefreshTokenRepository,
|
||||
} from "../contracts";
|
||||
|
||||
import type { IssuedSession } from "./issued-session";
|
||||
|
||||
export type IssueSessionParams = {
|
||||
account: Account;
|
||||
refreshTokenExpiresAt: UtcDateTime;
|
||||
now: UtcDateTime;
|
||||
transaction?: unknown;
|
||||
};
|
||||
|
||||
export class SessionIssuer {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accessTokenIssuer: IAccessTokenIssuer;
|
||||
refreshTokenGenerator: IRefreshTokenGenerator;
|
||||
refreshTokenHasher: IRefreshTokenHasher;
|
||||
refreshTokenRepository: IRefreshTokenRepository;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async issue(params: IssueSessionParams): Promise<Result<IssuedSession, Error>> {
|
||||
const accessTokenResult = await this.deps.accessTokenIssuer.issue({
|
||||
accountId: params.account.id.toPrimitive(),
|
||||
email: params.account.email.toPrimitive(),
|
||||
});
|
||||
|
||||
if (accessTokenResult.isFailure) {
|
||||
return Result.fail(accessTokenResult.error);
|
||||
}
|
||||
|
||||
const plainRefreshTokenResult = this.deps.refreshTokenGenerator.generate();
|
||||
if (plainRefreshTokenResult.isFailure) {
|
||||
return Result.fail(plainRefreshTokenResult.error);
|
||||
}
|
||||
|
||||
const refreshTokenHashResult = this.deps.refreshTokenHasher.hash(plainRefreshTokenResult.data);
|
||||
if (refreshTokenHashResult.isFailure) {
|
||||
return Result.fail(refreshTokenHashResult.error);
|
||||
}
|
||||
|
||||
const refreshTokenId = UniqueID.generateNewID();
|
||||
const refreshTokenCreateResult = RefreshToken.create(
|
||||
{
|
||||
accountId: params.account.id,
|
||||
tokenHash: refreshTokenHashResult.data,
|
||||
expiresAt: params.refreshTokenExpiresAt,
|
||||
revokedAt: Maybe.none(),
|
||||
replacedByTokenId: Maybe.none(),
|
||||
createdAt: params.now,
|
||||
},
|
||||
refreshTokenId
|
||||
);
|
||||
|
||||
if (refreshTokenCreateResult.isFailure) {
|
||||
return Result.fail(refreshTokenCreateResult.error);
|
||||
}
|
||||
|
||||
const saveResult = await this.deps.refreshTokenRepository.save(
|
||||
refreshTokenCreateResult.data,
|
||||
params.transaction
|
||||
);
|
||||
|
||||
if (saveResult.isFailure) {
|
||||
return Result.fail(saveResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
accessToken: accessTokenResult.data,
|
||||
refreshToken: plainRefreshTokenResult.data,
|
||||
refreshTokenId: refreshTokenId.toPrimitive(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { IAccountRepository } from "../../accounts";
|
||||
import { AccountDisabledError, AccountNotFoundError } from "../services";
|
||||
|
||||
export type GetCurrentSessionInput = {
|
||||
accountId: UniqueID;
|
||||
};
|
||||
|
||||
export type GetCurrentSessionOutput = {
|
||||
account: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
languageCode: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class GetCurrentSessionUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accountRepository: IAccountRepository;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(
|
||||
input: GetCurrentSessionInput
|
||||
): Promise<Result<GetCurrentSessionOutput, Error>> {
|
||||
try {
|
||||
const accountResult = await this.deps.accountRepository.findById(input.accountId);
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
if (accountResult.data.isNone()) {
|
||||
return Result.fail(
|
||||
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
|
||||
);
|
||||
}
|
||||
|
||||
const account = accountResult.data.unwrap();
|
||||
|
||||
const canAuthenticateResult = account.canAuthenticate();
|
||||
if (canAuthenticateResult.isFailure) {
|
||||
return Result.fail(
|
||||
new AccountDisabledError("Account cannot authenticate in its current state.", {
|
||||
cause: canAuthenticateResult.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
account: {
|
||||
id: account.id.toPrimitive(),
|
||||
email: account.email.toPrimitive(),
|
||||
name: account.name.toPrimitive(),
|
||||
avatarUrl: account.avatarUrl.match(
|
||||
(avatarUrl) => avatarUrl.toPrimitive(),
|
||||
() => null
|
||||
),
|
||||
languageCode: account.languageCode.toPrimitive(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./get-current-session.use-case";
|
||||
export * from "./login-with-password.use-case";
|
||||
export * from "./logout.use-case";
|
||||
export * from "./refresh-session.use-case";
|
||||
@ -0,0 +1,85 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { EmailAddress, type UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { AccountAuthenticator, SessionIssuer } from "../services";
|
||||
|
||||
export type LoginWithPasswordInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
now: UtcDateTime;
|
||||
refreshTokenExpiresAt: UtcDateTime;
|
||||
};
|
||||
|
||||
export type LoginWithPasswordOutput = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
account: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
languageCode: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class LoginWithPasswordUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
accountAuthenticator: AccountAuthenticator;
|
||||
sessionIssuer: SessionIssuer;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(
|
||||
input: LoginWithPasswordInput
|
||||
): Promise<Result<LoginWithPasswordOutput, Error>> {
|
||||
const emailResult = EmailAddress.create(input.email);
|
||||
if (emailResult.isFailure) {
|
||||
return Result.fail(emailResult.error);
|
||||
}
|
||||
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
const accountResult = await this.deps.accountAuthenticator.authenticateWithPassword({
|
||||
email: emailResult.data,
|
||||
password: input.password,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
const sessionResult = await this.deps.sessionIssuer.issue({
|
||||
account: accountResult.data,
|
||||
refreshTokenExpiresAt: input.refreshTokenExpiresAt,
|
||||
now: input.now,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (sessionResult.isFailure) {
|
||||
return Result.fail(sessionResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
accessToken: sessionResult.data.accessToken,
|
||||
refreshToken: sessionResult.data.refreshToken,
|
||||
account: {
|
||||
id: accountResult.data.id.toPrimitive(),
|
||||
email: accountResult.data.email.toPrimitive(),
|
||||
name: accountResult.data.name.toPrimitive(),
|
||||
avatarUrl: accountResult.data.avatarUrl.match(
|
||||
(avatarUrl) => avatarUrl.toPrimitive(),
|
||||
() => null
|
||||
),
|
||||
languageCode: accountResult.data.languageCode.toPrimitive(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import type { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { IRefreshTokenHasher, IRefreshTokenRepository } from "../contracts";
|
||||
import {
|
||||
LogoutRequiresRefreshTokenError,
|
||||
RefreshTokenAccountMismatchError,
|
||||
RefreshTokenNotFoundError,
|
||||
} from "../services";
|
||||
|
||||
export type LogoutInput = {
|
||||
accountId: UniqueID;
|
||||
refreshToken?: string;
|
||||
allSessions?: boolean;
|
||||
now: UtcDateTime;
|
||||
};
|
||||
|
||||
export type LogoutOutput = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export class LogoutUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
refreshTokenHasher: IRefreshTokenHasher;
|
||||
refreshTokenRepository: IRefreshTokenRepository;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(input: LogoutInput): Promise<Result<LogoutOutput, Error>> {
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
if (input.allSessions === true) {
|
||||
const revokeAllResult = await this.deps.refreshTokenRepository.revokeAllByAccountId({
|
||||
accountId: input.accountId,
|
||||
revokedAt: input.now,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (revokeAllResult.isFailure) {
|
||||
return Result.fail(revokeAllResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({ success: true as const });
|
||||
}
|
||||
|
||||
if (!input.refreshToken) {
|
||||
return Result.fail(new LogoutRequiresRefreshTokenError());
|
||||
}
|
||||
|
||||
const refreshTokenHashResult = this.deps.refreshTokenHasher.hash(input.refreshToken);
|
||||
if (refreshTokenHashResult.isFailure) {
|
||||
return Result.fail(refreshTokenHashResult.error);
|
||||
}
|
||||
|
||||
const persistedRefreshTokenResult = await this.deps.refreshTokenRepository.findByHash(
|
||||
refreshTokenHashResult.data,
|
||||
transaction
|
||||
);
|
||||
if (persistedRefreshTokenResult.isFailure) {
|
||||
return Result.fail(persistedRefreshTokenResult.error);
|
||||
}
|
||||
|
||||
if (persistedRefreshTokenResult.data.isNone()) {
|
||||
return Result.fail(new RefreshTokenNotFoundError());
|
||||
}
|
||||
|
||||
const persistedRefreshToken = persistedRefreshTokenResult.data.unwrap();
|
||||
if (persistedRefreshToken.accountId.toPrimitive() !== input.accountId.toPrimitive()) {
|
||||
return Result.fail(new RefreshTokenAccountMismatchError());
|
||||
}
|
||||
|
||||
const revokeResult = persistedRefreshToken.revoke({
|
||||
revokedAt: input.now,
|
||||
replacedByTokenId: Maybe.none(),
|
||||
});
|
||||
if (revokeResult.isFailure) {
|
||||
return Result.fail(revokeResult.error);
|
||||
}
|
||||
|
||||
const updateResult = await this.deps.refreshTokenRepository.update(
|
||||
persistedRefreshToken,
|
||||
transaction
|
||||
);
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({ success: true as const });
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { IAccountRepository } from "../../accounts";
|
||||
import type { IRefreshTokenHasher, IRefreshTokenRepository } from "../contracts";
|
||||
import type { SessionIssuer } from "../services";
|
||||
import { AccountDisabledError, AccountNotFoundError, RefreshTokenNotFoundError } from "../services";
|
||||
|
||||
export type RefreshSessionInput = {
|
||||
refreshToken: string;
|
||||
now: UtcDateTime;
|
||||
refreshTokenExpiresAt: UtcDateTime;
|
||||
};
|
||||
|
||||
export type RefreshSessionOutput = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export class RefreshSessionUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
refreshTokenHasher: IRefreshTokenHasher;
|
||||
refreshTokenRepository: IRefreshTokenRepository;
|
||||
accountRepository: IAccountRepository;
|
||||
sessionIssuer: SessionIssuer;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async execute(input: RefreshSessionInput): Promise<Result<RefreshSessionOutput, Error>> {
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
try {
|
||||
const refreshTokenHashResult = this.deps.refreshTokenHasher.hash(input.refreshToken);
|
||||
if (refreshTokenHashResult.isFailure) {
|
||||
return Result.fail(refreshTokenHashResult.error);
|
||||
}
|
||||
|
||||
const persistedRefreshTokenResult = await this.deps.refreshTokenRepository.findByHash(
|
||||
refreshTokenHashResult.data,
|
||||
transaction
|
||||
);
|
||||
if (persistedRefreshTokenResult.isFailure) {
|
||||
return Result.fail(persistedRefreshTokenResult.error);
|
||||
}
|
||||
|
||||
if (persistedRefreshTokenResult.data.isNone()) {
|
||||
return Result.fail(new RefreshTokenNotFoundError());
|
||||
}
|
||||
|
||||
const persistedRefreshToken = persistedRefreshTokenResult.data.unwrap();
|
||||
|
||||
const canBeUsedResult = persistedRefreshToken.canBeUsed(input.now);
|
||||
if (canBeUsedResult.isFailure) {
|
||||
return Result.fail(canBeUsedResult.error);
|
||||
}
|
||||
|
||||
const accountResult = await this.deps.accountRepository.findById(
|
||||
persistedRefreshToken.accountId,
|
||||
transaction
|
||||
);
|
||||
if (accountResult.isFailure) {
|
||||
return Result.fail(accountResult.error);
|
||||
}
|
||||
|
||||
if (accountResult.data.isNone()) {
|
||||
return Result.fail(
|
||||
new AccountNotFoundError(
|
||||
`Account not found for id "${persistedRefreshToken.accountId.toPrimitive()}".`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const account = accountResult.data.unwrap();
|
||||
const canAuthenticateResult = account.canAuthenticate();
|
||||
if (canAuthenticateResult.isFailure) {
|
||||
return Result.fail(
|
||||
new AccountDisabledError("Account cannot authenticate in its current state.", {
|
||||
cause: canAuthenticateResult.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const newSessionResult = await this.deps.sessionIssuer.issue({
|
||||
account,
|
||||
refreshTokenExpiresAt: input.refreshTokenExpiresAt,
|
||||
now: input.now,
|
||||
transaction,
|
||||
});
|
||||
if (newSessionResult.isFailure) {
|
||||
return Result.fail(newSessionResult.error);
|
||||
}
|
||||
|
||||
const rotatedTokenIdResult = UniqueID.create(newSessionResult.data.refreshTokenId);
|
||||
if (rotatedTokenIdResult.isFailure) {
|
||||
return Result.fail(rotatedTokenIdResult.error);
|
||||
}
|
||||
|
||||
const rotateResult = persistedRefreshToken.rotate({
|
||||
revokedAt: input.now,
|
||||
replacedByTokenId: rotatedTokenIdResult.data,
|
||||
now: input.now,
|
||||
});
|
||||
if (rotateResult.isFailure) {
|
||||
return Result.fail(rotateResult.error);
|
||||
}
|
||||
|
||||
const updateResult = await this.deps.refreshTokenRepository.update(
|
||||
persistedRefreshToken,
|
||||
transaction
|
||||
);
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
accessToken: newSessionResult.data.accessToken,
|
||||
refreshToken: newSessionResult.data.refreshToken,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Company } from "../../../domain";
|
||||
|
||||
export interface ICompanyRepository {
|
||||
save(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
findById(id: Company["id"], transaction?: unknown): Promise<Result<Maybe<Company>, Error>>;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-repository.interface";
|
||||
1
modules/identity/src/api/application/companies/index.ts
Normal file
1
modules/identity/src/api/application/companies/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./contracts";
|
||||
@ -0,0 +1,21 @@
|
||||
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CompanyMembership } from "../../../domain";
|
||||
|
||||
export interface ICompanyMembershipRepository {
|
||||
save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
findByAccountIdAndCompanyId(params: {
|
||||
accountId: CompanyMembership["accountId"];
|
||||
companyId: CompanyMembership["companyId"];
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Maybe<CompanyMembership>, Error>>;
|
||||
findByCompanyId(params: {
|
||||
companyId: CompanyMembership["companyId"];
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<CompanyMembership>, Error>>;
|
||||
findByAccountId(params: {
|
||||
accountId: CompanyMembership["accountId"];
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<CompanyMembership>, Error>>;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-membership-repository.interface";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./contracts";
|
||||
6
modules/identity/src/api/application/index.ts
Normal file
6
modules/identity/src/api/application/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./accounts";
|
||||
export * from "./authentication";
|
||||
export * from "./companies";
|
||||
export * from "./company-memberships";
|
||||
export * from "./permissions";
|
||||
export * from "./roles";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./permission-catalog.interface";
|
||||
@ -0,0 +1,10 @@
|
||||
import type { PermissionCode, PermissionDefinition } from "@erp/core";
|
||||
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export interface IPermissionCatalog {
|
||||
findAll(): Result<Collection<PermissionDefinition>, Error>;
|
||||
|
||||
exists(permissionCode: PermissionCode): Result<boolean, Error>;
|
||||
|
||||
findByCode(permissionCode: PermissionCode): Result<Maybe<PermissionDefinition>, Error>;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./contracts";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./role-repository.interface";
|
||||
export * from "./role-permission-validator.interface";
|
||||
@ -0,0 +1,6 @@
|
||||
import type { PermissionCode } from "@erp/core";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
export interface IRolePermissionValidator {
|
||||
validate(permissionCodes: PermissionCode[]): Result<void, Error>;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Role } from "../../../domain";
|
||||
|
||||
export interface IRoleRepository {
|
||||
save(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
update(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
delete(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
findByIdInCompany(params: {
|
||||
roleId: UniqueID;
|
||||
companyId: Role["companyId"];
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Maybe<Role>, Error>>;
|
||||
findByIdsInCompany(params: {
|
||||
roleIds: UniqueID[];
|
||||
companyId: Role["companyId"];
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<Role>, Error>>;
|
||||
findByCompanyId(params: {
|
||||
companyId: Role["companyId"];
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<Role>, Error>>;
|
||||
}
|
||||
2
modules/identity/src/api/application/roles/index.ts
Normal file
2
modules/identity/src/api/application/roles/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./contracts";
|
||||
export * from "./services";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./role-permission-validator";
|
||||
export * from "./unknown-permission-code.error";
|
||||
@ -0,0 +1,27 @@
|
||||
import type { PermissionCode } from "@erp/core";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { IPermissionCatalog } from "../../permissions";
|
||||
import type { IRolePermissionValidator } from "../contracts";
|
||||
|
||||
import { UnknownPermissionCodeError } from "./unknown-permission-code.error";
|
||||
|
||||
export class RolePermissionValidator implements IRolePermissionValidator {
|
||||
public constructor(private readonly permissionCatalog: IPermissionCatalog) {}
|
||||
|
||||
public validate(permissionCodes: PermissionCode[]): Result<void, Error> {
|
||||
for (const permissionCode of permissionCodes) {
|
||||
const existsResult = this.permissionCatalog.exists(permissionCode);
|
||||
|
||||
if (existsResult.isFailure) {
|
||||
return Result.fail(existsResult.error);
|
||||
}
|
||||
|
||||
if (!existsResult.data) {
|
||||
return Result.fail(new UnknownPermissionCodeError(permissionCode));
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import type { PermissionCode } from "@erp/core";
|
||||
import { DomainError } from "@repo/rdx-ddd";
|
||||
|
||||
export class UnknownPermissionCodeError extends DomainError {
|
||||
public readonly code = "IDENTITY_UNKNOWN_PERMISSION_CODE" as const;
|
||||
|
||||
public constructor(public readonly permissionCode: PermissionCode) {
|
||||
super(`Unknown permission code: ${permissionCode}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
interface AccountStatusProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum ACCOUNT_STATUS {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled",
|
||||
PENDING_VERIFICATION = "pending_verification",
|
||||
}
|
||||
|
||||
export class AccountStatus extends ValueObject<AccountStatusProps> {
|
||||
private static readonly ALLOWED_STATUSES = [
|
||||
ACCOUNT_STATUS.ACTIVE,
|
||||
ACCOUNT_STATUS.DISABLED,
|
||||
ACCOUNT_STATUS.PENDING_VERIFICATION,
|
||||
] as const;
|
||||
|
||||
static create(value: string): Result<AccountStatus, Error> {
|
||||
if (!AccountStatus.ALLOWED_STATUSES.includes(value as (typeof AccountStatus.ALLOWED_STATUSES)[number])) {
|
||||
return Result.fail(
|
||||
new DomainValidationError("INVALID_ACCOUNT_STATUS", "status", `Status value not valid: ${value}`)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(new AccountStatus({ value }));
|
||||
}
|
||||
|
||||
static createActive(): AccountStatus {
|
||||
return new AccountStatus({ value: ACCOUNT_STATUS.ACTIVE });
|
||||
}
|
||||
|
||||
static createDisabled(): AccountStatus {
|
||||
return new AccountStatus({ value: ACCOUNT_STATUS.DISABLED });
|
||||
}
|
||||
|
||||
static createPendingVerification(): AccountStatus {
|
||||
return new AccountStatus({ value: ACCOUNT_STATUS.PENDING_VERIFICATION });
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.props.value === ACCOUNT_STATUS.ACTIVE;
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
return this.props.value === ACCOUNT_STATUS.DISABLED;
|
||||
}
|
||||
|
||||
isPendingVerification(): boolean {
|
||||
return this.props.value === ACCOUNT_STATUS.PENDING_VERIFICATION;
|
||||
}
|
||||
|
||||
getProps(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return String(this.props.value);
|
||||
}
|
||||
}
|
||||
210
modules/identity/src/api/domain/accounts/account.aggregate.ts
Normal file
210
modules/identity/src/api/domain/accounts/account.aggregate.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import {
|
||||
AggregateRoot,
|
||||
type EmailAddress,
|
||||
type LanguageCode,
|
||||
type Name,
|
||||
type URLAddress,
|
||||
type UniqueID,
|
||||
type UtcDate,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AccountStatus } from "./account-status.vo";
|
||||
import { AccountCannotAuthenticateError, AccountCannotBeUpdatedError } from "./errors";
|
||||
import type { PasswordHash } from "./password-hash";
|
||||
|
||||
export interface IAccountCreateProps {
|
||||
email: EmailAddress;
|
||||
passwordHash: PasswordHash;
|
||||
name: Name;
|
||||
avatarUrl: Maybe<URLAddress>;
|
||||
languageCode: LanguageCode;
|
||||
status: AccountStatus;
|
||||
createdAt: UtcDate;
|
||||
updatedAt: UtcDate;
|
||||
}
|
||||
|
||||
export type AccountProfilePatchProps = Partial<{
|
||||
name: Name;
|
||||
avatarUrl: Maybe<URLAddress>;
|
||||
languageCode: LanguageCode;
|
||||
}> & {
|
||||
updatedAt: UtcDate;
|
||||
};
|
||||
|
||||
export type AccountInternalProps = IAccountCreateProps;
|
||||
|
||||
export class Account extends AggregateRoot<AccountInternalProps> {
|
||||
protected constructor(props: AccountInternalProps, id?: UniqueID) {
|
||||
super(props, id);
|
||||
}
|
||||
|
||||
static create(props: IAccountCreateProps, id?: UniqueID): Result<Account, Error> {
|
||||
const validationResult = Account.validateCreateProps(props);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(new Account(props, id));
|
||||
}
|
||||
|
||||
static rehydrate(props: AccountInternalProps, id: UniqueID): Account {
|
||||
return new Account(props, id);
|
||||
}
|
||||
|
||||
private static validateCreateProps(props: IAccountCreateProps): Result<void, Error> {
|
||||
if (!props.email) {
|
||||
return Result.fail(new Error("Account email is required."));
|
||||
}
|
||||
|
||||
if (!props.passwordHash) {
|
||||
return Result.fail(new Error("Account password hash is required."));
|
||||
}
|
||||
|
||||
if (!props.name) {
|
||||
return Result.fail(new Error("Account name is required."));
|
||||
}
|
||||
|
||||
if (!props.languageCode) {
|
||||
return Result.fail(new Error("Account language code is required."));
|
||||
}
|
||||
|
||||
if (!props.status) {
|
||||
return Result.fail(new Error("Account status is required."));
|
||||
}
|
||||
|
||||
if (!(props.createdAt && props.updatedAt)) {
|
||||
return Result.fail(new Error("Account timestamps are required."));
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
public get email(): EmailAddress {
|
||||
return this.props.email;
|
||||
}
|
||||
|
||||
public get passwordHash(): PasswordHash {
|
||||
return this.props.passwordHash;
|
||||
}
|
||||
|
||||
public get name(): Name {
|
||||
return this.props.name;
|
||||
}
|
||||
|
||||
public get avatarUrl(): Maybe<URLAddress> {
|
||||
return this.props.avatarUrl;
|
||||
}
|
||||
|
||||
public get languageCode(): LanguageCode {
|
||||
return this.props.languageCode;
|
||||
}
|
||||
|
||||
public get status(): AccountStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
public get createdAt(): UtcDate {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
public get updatedAt(): UtcDate {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
public updateProfile(patch: AccountProfilePatchProps): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
if (patch.name && !this.props.name.equals(patch.name)) {
|
||||
this.props.name = patch.name;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (
|
||||
patch.avatarUrl !== undefined &&
|
||||
!Account.sameMaybeValueObject(this.props.avatarUrl, patch.avatarUrl)
|
||||
) {
|
||||
this.props.avatarUrl = patch.avatarUrl;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (patch.languageCode && !this.props.languageCode.equals(patch.languageCode)) {
|
||||
this.props.languageCode = patch.languageCode;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.updatedAt = patch.updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public changePasswordHash(params: {
|
||||
passwordHash: PasswordHash;
|
||||
updatedAt: UtcDate;
|
||||
}): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
|
||||
}
|
||||
|
||||
if (this.props.passwordHash.equals(params.passwordHash)) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.passwordHash = params.passwordHash;
|
||||
this.props.updatedAt = params.updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public disable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.status = AccountStatus.createDisabled();
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public enable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
if (this.status.isActive()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.status = AccountStatus.createActive();
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public canAuthenticate(): Result<boolean, Error> {
|
||||
if (!this.status.isActive()) {
|
||||
return Result.fail(
|
||||
new AccountCannotAuthenticateError(
|
||||
`Account cannot authenticate while status is "${this.status.toPrimitive()}".`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
private static sameMaybeValueObject<T extends { equals(other: T): boolean }>(
|
||||
current: Maybe<T>,
|
||||
next: Maybe<T>
|
||||
): boolean {
|
||||
return current.match(
|
||||
(currentValue) =>
|
||||
next.match(
|
||||
(nextValue) => currentValue.equals(nextValue),
|
||||
() => false
|
||||
),
|
||||
() => next.isNone()
|
||||
);
|
||||
}
|
||||
}
|
||||
13
modules/identity/src/api/domain/accounts/errors.ts
Normal file
13
modules/identity/src/api/domain/accounts/errors.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { DomainError } from "@repo/rdx-ddd";
|
||||
|
||||
export class InvalidPasswordHashError extends DomainError {
|
||||
public readonly code = "IDENTITY_INVALID_PASSWORD_HASH" as const;
|
||||
}
|
||||
|
||||
export class AccountCannotAuthenticateError extends DomainError {
|
||||
public readonly code = "IDENTITY_ACCOUNT_CANNOT_AUTHENTICATE" as const;
|
||||
}
|
||||
|
||||
export class AccountCannotBeUpdatedError extends DomainError {
|
||||
public readonly code = "IDENTITY_ACCOUNT_CANNOT_BE_UPDATED" as const;
|
||||
}
|
||||
4
modules/identity/src/api/domain/accounts/index.ts
Normal file
4
modules/identity/src/api/domain/accounts/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./account.aggregate";
|
||||
export * from "./account-status.vo";
|
||||
export * from "./errors";
|
||||
export * from "./password-hash";
|
||||
55
modules/identity/src/api/domain/accounts/password-hash.ts
Normal file
55
modules/identity/src/api/domain/accounts/password-hash.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { InvalidPasswordHashError } from "./errors";
|
||||
|
||||
interface PasswordHashProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class PasswordHash extends ValueObject<PasswordHashProps> {
|
||||
private static readonly MIN_LENGTH = 20;
|
||||
private static readonly MAX_LENGTH = 512;
|
||||
|
||||
static create(value: string): Result<PasswordHash, Error> {
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return Result.fail(new InvalidPasswordHashError("Password hash is required."));
|
||||
}
|
||||
|
||||
if (normalized.length < PasswordHash.MIN_LENGTH) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_PASSWORD_HASH",
|
||||
"passwordHash",
|
||||
`Password hash must be at least ${PasswordHash.MIN_LENGTH} characters long.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.length > PasswordHash.MAX_LENGTH) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_PASSWORD_HASH",
|
||||
"passwordHash",
|
||||
`Password hash must be at most ${PasswordHash.MAX_LENGTH} characters long.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(new PasswordHash({ value: normalized }));
|
||||
}
|
||||
|
||||
getProps(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return String(this.props.value);
|
||||
}
|
||||
}
|
||||
17
modules/identity/src/api/domain/authentication/errors.ts
Normal file
17
modules/identity/src/api/domain/authentication/errors.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { DomainError } from "@repo/rdx-ddd";
|
||||
|
||||
export class InvalidRefreshTokenHashError extends DomainError {
|
||||
public readonly code = "IDENTITY_INVALID_REFRESH_TOKEN_HASH" as const;
|
||||
}
|
||||
|
||||
export class RefreshTokenExpiredError extends DomainError {
|
||||
public readonly code = "IDENTITY_REFRESH_TOKEN_EXPIRED" as const;
|
||||
}
|
||||
|
||||
export class RefreshTokenRevokedError extends DomainError {
|
||||
public readonly code = "IDENTITY_REFRESH_TOKEN_REVOKED" as const;
|
||||
}
|
||||
|
||||
export class RefreshTokenCannotBeRotatedError extends DomainError {
|
||||
public readonly code = "IDENTITY_REFRESH_TOKEN_CANNOT_BE_ROTATED" as const;
|
||||
}
|
||||
3
modules/identity/src/api/domain/authentication/index.ts
Normal file
3
modules/identity/src/api/domain/authentication/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./errors";
|
||||
export * from "./refresh-token.aggregate";
|
||||
export * from "./refresh-token-hash";
|
||||
@ -0,0 +1,55 @@
|
||||
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { InvalidRefreshTokenHashError } from "./errors";
|
||||
|
||||
interface RefreshTokenHashProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenHash extends ValueObject<RefreshTokenHashProps> {
|
||||
private static readonly MIN_LENGTH = 20;
|
||||
private static readonly MAX_LENGTH = 512;
|
||||
|
||||
static create(value: string): Result<RefreshTokenHash, Error> {
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return Result.fail(new InvalidRefreshTokenHashError("Refresh token hash is required."));
|
||||
}
|
||||
|
||||
if (normalized.length < RefreshTokenHash.MIN_LENGTH) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_REFRESH_TOKEN_HASH",
|
||||
"tokenHash",
|
||||
`Refresh token hash must be at least ${RefreshTokenHash.MIN_LENGTH} characters long.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.length > RefreshTokenHash.MAX_LENGTH) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_REFRESH_TOKEN_HASH",
|
||||
"tokenHash",
|
||||
`Refresh token hash must be at most ${RefreshTokenHash.MAX_LENGTH} characters long.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(new RefreshTokenHash({ value: normalized }));
|
||||
}
|
||||
|
||||
getProps(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return String(this.props.value);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
import { AggregateRoot, type UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, type Maybe as MaybeType, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
RefreshTokenCannotBeRotatedError,
|
||||
RefreshTokenExpiredError,
|
||||
RefreshTokenRevokedError,
|
||||
} from "./errors";
|
||||
import type { RefreshTokenHash } from "./refresh-token-hash";
|
||||
|
||||
export interface IRefreshTokenCreateProps {
|
||||
accountId: UniqueID;
|
||||
tokenHash: RefreshTokenHash;
|
||||
expiresAt: UtcDateTime;
|
||||
revokedAt: MaybeType<UtcDateTime>;
|
||||
replacedByTokenId: MaybeType<UniqueID>;
|
||||
createdAt: UtcDateTime;
|
||||
}
|
||||
|
||||
export type RefreshTokenInternalProps = IRefreshTokenCreateProps;
|
||||
|
||||
export class RefreshToken extends AggregateRoot<RefreshTokenInternalProps> {
|
||||
protected constructor(props: RefreshTokenInternalProps, id?: UniqueID) {
|
||||
super(props, id);
|
||||
}
|
||||
|
||||
static create(props: IRefreshTokenCreateProps, id?: UniqueID): Result<RefreshToken, Error> {
|
||||
const validationResult = RefreshToken.validateCreateProps(props);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(new RefreshToken(props, id));
|
||||
}
|
||||
|
||||
static rehydrate(props: RefreshTokenInternalProps, id: UniqueID): RefreshToken {
|
||||
return new RefreshToken(props, id);
|
||||
}
|
||||
|
||||
private static validateCreateProps(props: IRefreshTokenCreateProps): Result<void, Error> {
|
||||
if (!props.accountId) {
|
||||
return Result.fail(new Error("Refresh token account ID is required."));
|
||||
}
|
||||
|
||||
if (!props.tokenHash) {
|
||||
return Result.fail(new Error("Refresh token hash is required."));
|
||||
}
|
||||
|
||||
if (!(props.expiresAt && props.createdAt)) {
|
||||
return Result.fail(new Error("Refresh token timestamps are required."));
|
||||
}
|
||||
|
||||
if (
|
||||
RefreshToken.toEpochMillis(props.expiresAt) <= RefreshToken.toEpochMillis(props.createdAt)
|
||||
) {
|
||||
return Result.fail(new Error("Refresh token expiration must be later than creation."));
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
public get accountId(): UniqueID {
|
||||
return this.props.accountId;
|
||||
}
|
||||
|
||||
public get tokenHash(): RefreshTokenHash {
|
||||
return this.props.tokenHash;
|
||||
}
|
||||
|
||||
public get expiresAt(): UtcDateTime {
|
||||
return this.props.expiresAt;
|
||||
}
|
||||
|
||||
public get revokedAt(): Maybe<UtcDateTime> {
|
||||
return this.props.revokedAt;
|
||||
}
|
||||
|
||||
public get replacedByTokenId(): MaybeType<UniqueID> {
|
||||
return this.props.replacedByTokenId;
|
||||
}
|
||||
|
||||
public get createdAt(): UtcDateTime {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
public revoke(params: {
|
||||
revokedAt: UtcDateTime;
|
||||
replacedByTokenId?: Maybe<UniqueID>;
|
||||
}): Result<boolean, Error> {
|
||||
if (this.props.revokedAt.isSome()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.revokedAt = Maybe.some(params.revokedAt);
|
||||
this.props.replacedByTokenId = params.replacedByTokenId ?? Maybe.none<UniqueID>();
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public rotate(params: {
|
||||
revokedAt: UtcDateTime;
|
||||
replacedByTokenId: UniqueID;
|
||||
now: UtcDateTime;
|
||||
}): Result<boolean, Error> {
|
||||
const usableResult = this.canBeUsed(params.now);
|
||||
if (usableResult.isFailure) {
|
||||
return Result.fail(
|
||||
new RefreshTokenCannotBeRotatedError("Refresh token cannot be rotated.", {
|
||||
cause: usableResult.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.props.revokedAt = Maybe.some(params.revokedAt);
|
||||
this.props.replacedByTokenId = Maybe.some(params.replacedByTokenId);
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public canBeUsed(now: UtcDateTime): Result<boolean, Error> {
|
||||
if (this.props.revokedAt.isSome()) {
|
||||
return Result.fail(new RefreshTokenRevokedError("Refresh token has been revoked."));
|
||||
}
|
||||
|
||||
if (RefreshToken.toEpochMillis(this.props.expiresAt) <= RefreshToken.toEpochMillis(now)) {
|
||||
return Result.fail(new RefreshTokenExpiredError("Refresh token has expired."));
|
||||
}
|
||||
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
private static toEpochMillis(value: UtcDateTime): number {
|
||||
return new Date(value.toISOString()).getTime();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
interface CompanyStatusProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum COMPANY_STATUS {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled",
|
||||
}
|
||||
|
||||
export class CompanyStatus extends ValueObject<CompanyStatusProps> {
|
||||
private static readonly ALLOWED_STATUSES = [COMPANY_STATUS.ACTIVE, COMPANY_STATUS.DISABLED] as const;
|
||||
|
||||
static create(value: string): Result<CompanyStatus, Error> {
|
||||
if (!CompanyStatus.ALLOWED_STATUSES.includes(value as (typeof CompanyStatus.ALLOWED_STATUSES)[number])) {
|
||||
return Result.fail(
|
||||
new DomainValidationError("INVALID_COMPANY_STATUS", "status", `Status value not valid: ${value}`)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(new CompanyStatus({ value }));
|
||||
}
|
||||
|
||||
static createActive(): CompanyStatus {
|
||||
return new CompanyStatus({ value: COMPANY_STATUS.ACTIVE });
|
||||
}
|
||||
|
||||
static createDisabled(): CompanyStatus {
|
||||
return new CompanyStatus({ value: COMPANY_STATUS.DISABLED });
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.props.value === COMPANY_STATUS.ACTIVE;
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
return this.props.value === COMPANY_STATUS.DISABLED;
|
||||
}
|
||||
|
||||
getProps(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return String(this.props.value);
|
||||
}
|
||||
}
|
||||
169
modules/identity/src/api/domain/companies/company.aggregate.ts
Normal file
169
modules/identity/src/api/domain/companies/company.aggregate.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import {
|
||||
AggregateRoot,
|
||||
type Name,
|
||||
type TextValue,
|
||||
type UniqueID,
|
||||
type UtcDate,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyStatus } from "./company-status.vo";
|
||||
import { CompanyCannotBeUpdatedError } from "./errors";
|
||||
|
||||
export interface ICompanyCreateProps {
|
||||
ownerAccountId: UniqueID;
|
||||
legalName: Name;
|
||||
tradeName: Maybe<Name>;
|
||||
tin: Maybe<TextValue>;
|
||||
status: CompanyStatus;
|
||||
createdAt: UtcDate;
|
||||
updatedAt: UtcDate;
|
||||
}
|
||||
|
||||
export type CompanyPatchProps = Partial<{
|
||||
legalName: Name;
|
||||
tradeName: Maybe<Name>;
|
||||
tin: Maybe<TextValue>;
|
||||
}> & {
|
||||
updatedAt: UtcDate;
|
||||
};
|
||||
|
||||
export type CompanyInternalProps = ICompanyCreateProps;
|
||||
|
||||
export class Company extends AggregateRoot<CompanyInternalProps> {
|
||||
protected constructor(props: CompanyInternalProps, id?: UniqueID) {
|
||||
super(props, id);
|
||||
}
|
||||
|
||||
static create(props: ICompanyCreateProps, id?: UniqueID): Result<Company, Error> {
|
||||
const validationResult = Company.validateCreateProps(props);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(new Company(props, id));
|
||||
}
|
||||
|
||||
static rehydrate(props: CompanyInternalProps, id: UniqueID): Company {
|
||||
return new Company(props, id);
|
||||
}
|
||||
|
||||
private static validateCreateProps(props: ICompanyCreateProps): Result<void, Error> {
|
||||
if (!props.ownerAccountId) {
|
||||
return Result.fail(new Error("Company owner account ID is required."));
|
||||
}
|
||||
|
||||
if (!props.legalName) {
|
||||
return Result.fail(new Error("Company legal name 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 ownerAccountId(): UniqueID {
|
||||
return this.props.ownerAccountId;
|
||||
}
|
||||
|
||||
public get legalName(): Name {
|
||||
return this.props.legalName;
|
||||
}
|
||||
|
||||
public get tradeName(): Maybe<Name> {
|
||||
return this.props.tradeName;
|
||||
}
|
||||
|
||||
public get tin(): Maybe<TextValue> {
|
||||
return this.props.tin;
|
||||
}
|
||||
|
||||
public get status(): CompanyStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
public get createdAt(): UtcDate {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
public get updatedAt(): UtcDate {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
public update(patch: CompanyPatchProps): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.fail(new CompanyCannotBeUpdatedError("Disabled companies cannot be updated."));
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
if (patch.legalName && !this.props.legalName.equals(patch.legalName)) {
|
||||
this.props.legalName = patch.legalName;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (
|
||||
patch.tradeName !== undefined &&
|
||||
!Company.sameMaybeValueObject(this.props.tradeName, patch.tradeName)
|
||||
) {
|
||||
this.props.tradeName = patch.tradeName;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (patch.tin !== undefined && !Company.sameMaybeValueObject(this.props.tin, patch.tin)) {
|
||||
this.props.tin = patch.tin;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.updatedAt = patch.updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public disable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.status = CompanyStatus.createDisabled();
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public enable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
if (this.status.isActive()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.status = CompanyStatus.createActive();
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.status.isActive();
|
||||
}
|
||||
|
||||
private static sameMaybeValueObject<T extends { equals(other: T): boolean }>(
|
||||
current: Maybe<T>,
|
||||
next: Maybe<T>
|
||||
): boolean {
|
||||
return current.match(
|
||||
(currentValue) =>
|
||||
next.match(
|
||||
(nextValue) => currentValue.equals(nextValue),
|
||||
() => false
|
||||
),
|
||||
() => next.isNone()
|
||||
);
|
||||
}
|
||||
}
|
||||
5
modules/identity/src/api/domain/companies/errors.ts
Normal file
5
modules/identity/src/api/domain/companies/errors.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { DomainError } from "@repo/rdx-ddd";
|
||||
|
||||
export class CompanyCannotBeUpdatedError extends DomainError {
|
||||
public readonly code = "IDENTITY_COMPANY_CANNOT_BE_UPDATED" as const;
|
||||
}
|
||||
3
modules/identity/src/api/domain/companies/index.ts
Normal file
3
modules/identity/src/api/domain/companies/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./company.aggregate";
|
||||
export * from "./company-status.vo";
|
||||
export * from "./errors";
|
||||
@ -0,0 +1,74 @@
|
||||
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
interface CompanyMembershipStatusProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum COMPANY_MEMBERSHIP_STATUS {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled",
|
||||
INVITED = "invited",
|
||||
}
|
||||
|
||||
export class CompanyMembershipStatus extends ValueObject<CompanyMembershipStatusProps> {
|
||||
private static readonly ALLOWED_STATUSES = [
|
||||
COMPANY_MEMBERSHIP_STATUS.ACTIVE,
|
||||
COMPANY_MEMBERSHIP_STATUS.DISABLED,
|
||||
COMPANY_MEMBERSHIP_STATUS.INVITED,
|
||||
] as const;
|
||||
|
||||
static create(value: string): Result<CompanyMembershipStatus, Error> {
|
||||
if (
|
||||
!CompanyMembershipStatus.ALLOWED_STATUSES.includes(
|
||||
value as (typeof CompanyMembershipStatus.ALLOWED_STATUSES)[number]
|
||||
)
|
||||
) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_COMPANY_MEMBERSHIP_STATUS",
|
||||
"status",
|
||||
`Status value not valid: ${value}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(new CompanyMembershipStatus({ value }));
|
||||
}
|
||||
|
||||
static createActive(): CompanyMembershipStatus {
|
||||
return new CompanyMembershipStatus({ value: COMPANY_MEMBERSHIP_STATUS.ACTIVE });
|
||||
}
|
||||
|
||||
static createDisabled(): CompanyMembershipStatus {
|
||||
return new CompanyMembershipStatus({ value: COMPANY_MEMBERSHIP_STATUS.DISABLED });
|
||||
}
|
||||
|
||||
static createInvited(): CompanyMembershipStatus {
|
||||
return new CompanyMembershipStatus({ value: COMPANY_MEMBERSHIP_STATUS.INVITED });
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.props.value === COMPANY_MEMBERSHIP_STATUS.ACTIVE;
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
return this.props.value === COMPANY_MEMBERSHIP_STATUS.DISABLED;
|
||||
}
|
||||
|
||||
isInvited(): boolean {
|
||||
return this.props.value === COMPANY_MEMBERSHIP_STATUS.INVITED;
|
||||
}
|
||||
|
||||
getProps(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return String(this.props.value);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
import { AggregateRoot, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanyMembershipStatus } from "./company-membership-status.vo";
|
||||
import {
|
||||
CompanyMembershipCannotAccessCompanyError,
|
||||
CompanyMembershipDuplicatedRoleError,
|
||||
CompanyMembershipWithoutRolesError,
|
||||
} from "./errors";
|
||||
|
||||
export interface ICompanyMembershipCreateProps {
|
||||
accountId: UniqueID;
|
||||
companyId: UniqueID;
|
||||
roleIds: UniqueID[];
|
||||
status: CompanyMembershipStatus;
|
||||
createdAt: UtcDate;
|
||||
updatedAt: UtcDate;
|
||||
}
|
||||
|
||||
export type CompanyMembershipInternalProps = ICompanyMembershipCreateProps;
|
||||
|
||||
export class CompanyMembership extends AggregateRoot<CompanyMembershipInternalProps> {
|
||||
protected constructor(props: CompanyMembershipInternalProps, id?: UniqueID) {
|
||||
super(props, id);
|
||||
}
|
||||
|
||||
static create(
|
||||
props: ICompanyMembershipCreateProps,
|
||||
id?: UniqueID
|
||||
): Result<CompanyMembership, Error> {
|
||||
const validationResult = CompanyMembership.validateState(props.status, props.roleIds);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
if (!(props.accountId && props.companyId)) {
|
||||
return Result.fail(new Error("Company membership accountId and companyId are required."));
|
||||
}
|
||||
|
||||
if (!(props.createdAt && props.updatedAt)) {
|
||||
return Result.fail(new Error("Company membership timestamps are required."));
|
||||
}
|
||||
|
||||
return Result.ok(new CompanyMembership(props, id));
|
||||
}
|
||||
|
||||
static rehydrate(props: CompanyMembershipInternalProps, id: UniqueID): CompanyMembership {
|
||||
return new CompanyMembership(props, id);
|
||||
}
|
||||
|
||||
public get accountId(): UniqueID {
|
||||
return this.props.accountId;
|
||||
}
|
||||
|
||||
public get companyId(): UniqueID {
|
||||
return this.props.companyId;
|
||||
}
|
||||
|
||||
public get roleIds(): UniqueID[] {
|
||||
return [...this.props.roleIds];
|
||||
}
|
||||
|
||||
public get status(): CompanyMembershipStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
public get createdAt(): UtcDate {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
public get updatedAt(): UtcDate {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
public replaceRoles(params: { roleIds: UniqueID[]; updatedAt: UtcDate }): Result<boolean, Error> {
|
||||
const validationResult = CompanyMembership.validateRoleIds(params.roleIds);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
if (CompanyMembership.sameRoleIds(this.props.roleIds, params.roleIds)) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.roleIds = [...params.roleIds];
|
||||
this.props.updatedAt = params.updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public disable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.status = CompanyMembershipStatus.createDisabled();
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public enable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
const validationResult = CompanyMembership.validateRoleIds(this.props.roleIds);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
if (this.status.isActive()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.status = CompanyMembershipStatus.createActive();
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public acceptInvitation(params: {
|
||||
updatedAt: UtcDate;
|
||||
roleIds?: UniqueID[];
|
||||
}): Result<boolean, Error> {
|
||||
const nextRoleIds = params.roleIds ? [...params.roleIds] : [...this.props.roleIds];
|
||||
const validationResult = CompanyMembership.validateRoleIds(nextRoleIds);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
const sameStatus = this.status.isActive();
|
||||
const sameRoles = CompanyMembership.sameRoleIds(this.props.roleIds, nextRoleIds);
|
||||
|
||||
if (sameStatus && sameRoles) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
this.props.roleIds = nextRoleIds;
|
||||
this.props.status = CompanyMembershipStatus.createActive();
|
||||
this.props.updatedAt = params.updatedAt;
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public canAccessCompany(): Result<boolean, Error> {
|
||||
const validationResult = CompanyMembership.validateState(this.status, this.props.roleIds);
|
||||
if (validationResult.isFailure) {
|
||||
return Result.fail(validationResult.error);
|
||||
}
|
||||
|
||||
if (!this.status.isActive()) {
|
||||
return Result.fail(
|
||||
new CompanyMembershipCannotAccessCompanyError(
|
||||
`Membership cannot access company while status is "${this.status.toPrimitive()}".`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
private static validateState(
|
||||
status: CompanyMembershipStatus,
|
||||
roleIds: UniqueID[]
|
||||
): Result<void, Error> {
|
||||
const rolesValidation = CompanyMembership.validateRoleIds(roleIds, !status.isActive());
|
||||
if (rolesValidation.isFailure) {
|
||||
return Result.fail(rolesValidation.error);
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private static validateRoleIds(roleIds: UniqueID[], allowEmpty = false): Result<void, Error> {
|
||||
if (!allowEmpty && (!roleIds || roleIds.length === 0)) {
|
||||
return Result.fail(
|
||||
new CompanyMembershipWithoutRolesError("Active memberships must have at least one role.")
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueCount = new Set((roleIds ?? []).map((roleId) => roleId.toPrimitive())).size;
|
||||
if ((roleIds ?? []).length !== uniqueCount) {
|
||||
return Result.fail(
|
||||
new CompanyMembershipDuplicatedRoleError("Membership role IDs cannot contain duplicates.")
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private static sameRoleIds(current: UniqueID[], next: UniqueID[]): boolean {
|
||||
if (current.length !== next.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentValues = current.map((roleId) => roleId.toPrimitive()).sort();
|
||||
const nextValues = next.map((roleId) => roleId.toPrimitive()).sort();
|
||||
|
||||
return currentValues.every((value, index) => value === nextValues[index]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { DomainError } from "@repo/rdx-ddd";
|
||||
|
||||
export class CompanyMembershipWithoutRolesError extends DomainError {
|
||||
public readonly code = "IDENTITY_COMPANY_MEMBERSHIP_WITHOUT_ROLES" as const;
|
||||
}
|
||||
|
||||
export class CompanyMembershipDuplicatedRoleError extends DomainError {
|
||||
public readonly code = "IDENTITY_COMPANY_MEMBERSHIP_DUPLICATED_ROLE" as const;
|
||||
}
|
||||
|
||||
export class CompanyMembershipCannotAccessCompanyError extends DomainError {
|
||||
public readonly code = "IDENTITY_COMPANY_MEMBERSHIP_CANNOT_ACCESS_COMPANY" as const;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./company-membership.aggregate";
|
||||
export * from "./company-membership-status.vo";
|
||||
export * from "./errors";
|
||||
5
modules/identity/src/api/domain/index.ts
Normal file
5
modules/identity/src/api/domain/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./accounts";
|
||||
export * from "./authentication";
|
||||
export * from "./companies";
|
||||
export * from "./company-memberships";
|
||||
export * from "./roles";
|
||||
25
modules/identity/src/api/domain/roles/errors.ts
Normal file
25
modules/identity/src/api/domain/roles/errors.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { DomainError } from "@repo/rdx-ddd";
|
||||
|
||||
export class InvalidRoleCodeError extends DomainError {
|
||||
public readonly code = "IDENTITY_INVALID_ROLE_CODE" as const;
|
||||
}
|
||||
|
||||
export class InvalidPermissionCodeError extends DomainError {
|
||||
public readonly code = "IDENTITY_INVALID_PERMISSION_CODE" as const;
|
||||
}
|
||||
|
||||
export class RoleWithoutPermissionsError extends DomainError {
|
||||
public readonly code = "IDENTITY_ROLE_WITHOUT_PERMISSIONS" as const;
|
||||
}
|
||||
|
||||
export class RoleDuplicatedPermissionError extends DomainError {
|
||||
public readonly code = "IDENTITY_ROLE_DUPLICATED_PERMISSION" as const;
|
||||
}
|
||||
|
||||
export class RoleCannotBeUpdatedError extends DomainError {
|
||||
public readonly code = "IDENTITY_ROLE_CANNOT_BE_UPDATED" as const;
|
||||
}
|
||||
|
||||
export class RoleCannotBeDeletedError extends DomainError {
|
||||
public readonly code = "IDENTITY_ROLE_CANNOT_BE_DELETED" as const;
|
||||
}
|
||||
3
modules/identity/src/api/domain/roles/index.ts
Normal file
3
modules/identity/src/api/domain/roles/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./errors";
|
||||
export * from "./role.aggregate";
|
||||
export * from "./role-code";
|
||||
53
modules/identity/src/api/domain/roles/role-code.ts
Normal file
53
modules/identity/src/api/domain/roles/role-code.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { ValueObject, translateZodValidationError } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InvalidRoleCodeError } from "./errors";
|
||||
|
||||
interface RoleCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class RoleCode extends ValueObject<RoleCodeProps> {
|
||||
private static readonly MIN_LENGTH = 2;
|
||||
private static readonly MAX_LENGTH = 100;
|
||||
|
||||
static create(value: string): Result<RoleCode, Error> {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return Result.fail(new InvalidRoleCodeError("Role code is required."));
|
||||
}
|
||||
|
||||
const schema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: "Role code must contain only lowercase letters, numbers, and hyphens.",
|
||||
})
|
||||
.min(RoleCode.MIN_LENGTH, {
|
||||
message: `Role code must be at least ${RoleCode.MIN_LENGTH} characters long.`,
|
||||
})
|
||||
.max(RoleCode.MAX_LENGTH, {
|
||||
message: `Role code must be at most ${RoleCode.MAX_LENGTH} characters long.`,
|
||||
});
|
||||
|
||||
const result = schema.safeParse(normalized);
|
||||
if (!result.success) {
|
||||
return Result.fail(translateZodValidationError("RoleCode creation failed", result.error));
|
||||
}
|
||||
|
||||
return Result.ok(new RoleCode({ value: result.data }));
|
||||
}
|
||||
|
||||
getProps(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getProps();
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return String(this.props.value);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user