This commit is contained in:
David Arranz 2026-07-02 17:48:39 +02:00
parent 7a161717c2
commit 6187a77dd9
44 changed files with 311 additions and 139 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/factuges-server", "name": "@erp/factuges-server",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsup src/index.ts --config tsup.config.ts", "build": "tsup src/index.ts --config tsup.config.ts",

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/factuges-web", "name": "@erp/factuges-web",
"private": true, "private": true,
"version": "0.7.8", "version": "0.8.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/auth", "name": "@erp/auth",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/catalogs", "name": "@erp/catalogs",
"description": "Catalogs module", "description": "Catalogs module",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/core", "name": "@erp/core",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/customer-invoices", "name": "@erp/customer-invoices",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -8,6 +8,7 @@ import {
InvoicePaymentMethod, InvoicePaymentMethod,
InvoiceStatus, InvoiceStatus,
IssuedInvoiceItem, IssuedInvoiceItem,
IssuedInvoiceRecipient,
IssuedInvoiceTax, IssuedInvoiceTax,
IssuedInvoiceTaxes, IssuedInvoiceTaxes,
type Proforma, type Proforma,
@ -29,6 +30,11 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
public toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error> { public toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error> {
const { proforma } = source; const { proforma } = source;
const recipientResult = this.resolveRecipient(proforma);
if (recipientResult.isFailure) {
return Result.fail(recipientResult.error);
}
const itemsResult = this.resolveItems(proforma); const itemsResult = this.resolveItems(proforma);
if (itemsResult.isFailure) { if (itemsResult.isFailure) {
@ -72,7 +78,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
paymentMethod: paymentResult.data, paymentMethod: paymentResult.data,
customerId: proforma.customerId, customerId: proforma.customerId,
recipient: proforma.recipient.getOrUndefined()!, recipient: recipientResult.data,
items: itemsResult.data, items: itemsResult.data,
@ -99,7 +105,31 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
totalAmount: proformaTotals.totalAmount, totalAmount: proformaTotals.totalAmount,
verifactu: Maybe.none(), verifactu: Maybe.none(),
} satisfies IIssuedInvoiceCreateProps);
}
private resolveRecipient(proforma: Proforma): Result<IssuedInvoiceRecipient, Error> {
const recipient = proforma.recipient;
if (recipient.isNone()) {
return Result.fail(new Error("Recipient is required"));
}
const recipientValue = recipient.unwrap();
const newRecipient = IssuedInvoiceRecipient.create({
name: recipientValue.name,
tin: recipientValue.tin.unwrap(),
street: recipientValue.street.unwrap(),
street2: recipientValue.street2,
city: recipientValue.city.unwrap(),
postalCode: recipientValue.postalCode.unwrap(),
province: recipientValue.province,
country: recipientValue.country,
}); });
return newRecipient;
} }
private resolveItems(proforma: Proforma): Result<IssuedInvoiceItem[], Error> { private resolveItems(proforma: Proforma): Result<IssuedInvoiceItem[], Error> {

View File

@ -1,5 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api"; import type { ISnapshotBuilder } from "@erp/core/api";
import { DomainValidationError, maybeToEmptyString, maybeToNullable } from "@repo/rdx-ddd"; import { DomainValidationError, maybeToNullable } from "@repo/rdx-ddd";
import type { IssuedInvoice } from "../../../../domain"; import type { IssuedInvoice } from "../../../../domain";
@ -22,12 +22,12 @@ export class IssuedInvoiceRecipientFullSnapshotBuilder
return { return {
id: invoice.customerId.toString(), id: invoice.customerId.toString(),
name: recipient.name.toString(), name: recipient.name.toString(),
tin: maybeToEmptyString(recipient.tin, (v) => v.toString()), tin: recipient.tin.toString(),
street: maybeToNullable(recipient.street, (v) => v.toString()), street: recipient.street.toString(),
street2: maybeToNullable(recipient.street2, (v) => v.toString()), street2: maybeToNullable(recipient.street2, (v) => v.toString()),
city: maybeToNullable(recipient.city, (v) => v.toString()), city: recipient.city.toString(),
province: maybeToNullable(recipient.province, (v) => v.toString()), province: maybeToNullable(recipient.province, (v) => v.toString()),
postal_code: maybeToNullable(recipient.postalCode, (v) => v.toString()), postal_code: recipient.postalCode.toString(),
country: maybeToNullable(recipient.country, (v) => v.toString()), country: maybeToNullable(recipient.country, (v) => v.toString()),
}; };
} }

View File

@ -14,10 +14,10 @@ import { type Maybe, Result } from "@repo/rdx-utils";
export type IssuedInvoiceRecipientProps = { export type IssuedInvoiceRecipientProps = {
name: Name; name: Name;
tin: TINNumber; tin: TINNumber;
street: Maybe<Street>; street: Street;
street2: Maybe<Street>; street2: Maybe<Street>;
city: Maybe<City>; city: City;
postalCode: Maybe<PostalCode>; postalCode: PostalCode;
province: Maybe<Province>; province: Maybe<Province>;
country: Maybe<Country>; country: Maybe<Country>;
}; };
@ -56,7 +56,7 @@ export class IssuedInvoiceRecipient extends ValueObject<IssuedInvoiceRecipientPr
return this.props.tin; return this.props.tin;
} }
get street(): Maybe<Street> { get street(): Street {
return this.props.street; return this.props.street;
} }
@ -64,11 +64,11 @@ export class IssuedInvoiceRecipient extends ValueObject<IssuedInvoiceRecipientPr
return this.props.street2; return this.props.street2;
} }
get city(): Maybe<City> { get city(): City {
return this.props.city; return this.props.city;
} }
get postalCode(): Maybe<PostalCode> { get postalCode(): PostalCode {
return this.props.postalCode; return this.props.postalCode;
} }
@ -92,10 +92,10 @@ export class IssuedInvoiceRecipient extends ValueObject<IssuedInvoiceRecipientPr
return { return {
tin: this.tin.toString(), tin: this.tin.toString(),
name: this.name.toString(), name: this.name.toString(),
street: maybeToNullable(this.street, (value) => value.toString()), street: this.street.toString(),
street2: maybeToNullable(this.street2, (value) => value.toString()), street2: maybeToNullable(this.street2, (value) => value.toString()),
city: maybeToNullable(this.city, (value) => value.toString()), city: this.city.toString(),
postal_code: maybeToNullable(this.postalCode, (value) => value.toString()), postal_code: this.postalCode.toString(),
province: maybeToNullable(this.province, (value) => value.toString()), province: maybeToNullable(this.province, (value) => value.toString()),
country: maybeToNullable(this.country, (value) => value.toString()), country: maybeToNullable(this.country, (value) => value.toString()),
}; };

View File

@ -314,7 +314,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok(false); return Result.ok(false);
} }
if (!canManuallyTransitionProformaStatus({ currentStatus, nextStatus })) { if (!canManuallyTransitionProformaStatus({ currentStatus, nextStatus, proforma: this })) {
return Result.fail( return Result.fail(
new InvalidProformaTransitionError( new InvalidProformaTransitionError(
currentStatus.toPrimitive(), currentStatus.toPrimitive(),
@ -392,6 +392,26 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
); );
} }
const recipientValue = this.recipient.unwrap();
if (
!recipientValue.name ||
recipientValue.tin.isNone() ||
recipientValue.street.isNone() ||
recipientValue.city.isNone() ||
recipientValue.postalCode.isNone() ||
recipientValue.province.isNone() ||
recipientValue.country.isNone()
) {
return Result.fail(
new DomainValidationError(
"MISSING_RECIPIENT",
"recipient",
"Recipient is required to issue the proforma"
)
);
}
if (this.items.size() === 0) { if (this.items.size() === 0) {
return Result.fail( return Result.fail(
new DomainValidationError( new DomainValidationError(

View File

@ -1,4 +1,4 @@
import { INVOICE_STATUS, type InvoiceStatus } from "../.."; import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../..";
/** /**
* Transiciones manuales permitidas para una proforma. * Transiciones manuales permitidas para una proforma.
@ -17,11 +17,38 @@ const PROFORMA_MANUAL_STATUS_TRANSITIONS: Readonly<
}; };
export function canManuallyTransitionProformaStatus(params: { export function canManuallyTransitionProformaStatus(params: {
proforma: Proforma;
currentStatus: InvoiceStatus; currentStatus: InvoiceStatus;
nextStatus: InvoiceStatus; nextStatus: InvoiceStatus;
}): boolean { }): boolean {
const current = params.currentStatus.toPrimitive() as INVOICE_STATUS; const current = params.currentStatus.toPrimitive() as INVOICE_STATUS;
const next = params.nextStatus.toPrimitive() as INVOICE_STATUS; const next = params.nextStatus.toPrimitive() as INVOICE_STATUS;
return PROFORMA_MANUAL_STATUS_TRANSITIONS[current].includes(next); let possible = PROFORMA_MANUAL_STATUS_TRANSITIONS[current].includes(next);
// Validación adicional: si la transición es diferente a 'draft', debe existir
// un destinatario obligatoriamente con todos sus datos rellenos.
if (next !== INVOICE_STATUS.DRAFT) {
const { recipient } = params.proforma;
if (recipient.isNone()) {
possible = false;
} else {
const recipientValue = recipient.unwrap();
if (
!recipientValue.name ||
recipientValue.tin.isNone() ||
recipientValue.street.isNone() ||
recipientValue.city.isNone() ||
recipientValue.postalCode.isNone() ||
recipientValue.province.isNone() ||
recipientValue.country.isNone()
) {
possible = false;
}
}
}
return possible;
} }

View File

@ -47,11 +47,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
//const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors); //const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors);
const customerTin = extractOrPushError(TINNumber.create(_tin), "customer_tin", errors); const customerTin = extractOrPushError(TINNumber.create(_tin), "customer_tin", errors);
const customerStreet = extractOrPushError( const customerStreet = extractOrPushError(Street.create(_street), "customer_street", errors);
maybeFromNullableResult(_street, (value) => Street.create(value)),
"customer_street",
errors
);
const customerStreet2 = extractOrPushError( const customerStreet2 = extractOrPushError(
maybeFromNullableResult(_street2, (value) => Street.create(value)), maybeFromNullableResult(_street2, (value) => Street.create(value)),
@ -59,11 +55,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
errors errors
); );
const customerCity = extractOrPushError( const customerCity = extractOrPushError(City.create(_city), "customer_city", errors);
maybeFromNullableResult(_city, (value) => City.create(value)),
"customer_city",
errors
);
const customerProvince = extractOrPushError( const customerProvince = extractOrPushError(
maybeFromNullableResult(_province, (value) => Province.create(value)), maybeFromNullableResult(_province, (value) => Province.create(value)),
@ -72,7 +64,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
); );
const customerPostalCode = extractOrPushError( const customerPostalCode = extractOrPushError(
maybeFromNullableResult(_postal_code, (value) => PostalCode.create(value)), PostalCode.create(_postal_code),
"customer_postal_code", "customer_postal_code",
errors errors
); );
@ -125,11 +117,11 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
return { return {
customer_tin: recipient.tin.toString(), customer_tin: recipient.tin.toString(),
customer_name: recipient.name.toPrimitive(), customer_name: recipient.name.toPrimitive(),
customer_street: maybeToNullable(recipient.street, (v) => v.toPrimitive()), customer_street: recipient.street.toPrimitive(),
customer_street2: maybeToNullable(recipient.street2, (v) => v.toPrimitive()), customer_street2: maybeToNullable(recipient.street2, (v) => v.toPrimitive()),
customer_city: maybeToNullable(recipient.city, (v) => v.toPrimitive()), customer_city: recipient.city.toPrimitive(),
customer_province: maybeToNullable(recipient.province, (v) => v.toPrimitive()), customer_province: maybeToNullable(recipient.province, (v) => v.toPrimitive()),
customer_postal_code: maybeToNullable(recipient.postalCode, (v) => v.toPrimitive()), customer_postal_code: recipient.postalCode.toPrimitive(),
customer_country: maybeToNullable(recipient.country, (v) => v.toPrimitive()), customer_country: maybeToNullable(recipient.country, (v) => v.toPrimitive()),
}; };
} }

View File

@ -58,11 +58,7 @@ export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMap
const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors); const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors);
const customerStreet = extractOrPushError( const customerStreet = extractOrPushError(Street.create(_street), "customer_street", errors);
maybeFromNullableResult(_street, (value) => Street.create(value)),
"customer_street",
errors
);
const customerStreet2 = extractOrPushError( const customerStreet2 = extractOrPushError(
maybeFromNullableResult(_street2, (value) => Street.create(value)), maybeFromNullableResult(_street2, (value) => Street.create(value)),
@ -70,11 +66,7 @@ export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMap
errors errors
); );
const customerCity = extractOrPushError( const customerCity = extractOrPushError(City.create(_city), "customer_city", errors);
maybeFromNullableResult(_city, (value) => City.create(value)),
"customer_city",
errors
);
const customerProvince = extractOrPushError( const customerProvince = extractOrPushError(
maybeFromNullableResult(_province, (value) => Province.create(value)), maybeFromNullableResult(_province, (value) => Province.create(value)),
@ -83,7 +75,7 @@ export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMap
); );
const customerPostalCode = extractOrPushError( const customerPostalCode = extractOrPushError(
maybeFromNullableResult(_postal_code, (value) => PostalCode.create(value)), PostalCode.create(_postal_code),
"customer_postal_code", "customer_postal_code",
errors errors
); );

View File

@ -259,9 +259,8 @@ export class IssuedInvoiceRepository
column: "description", column: "description",
}, },
recipient_name: { recipient_name: {
type: "association", type: "root",
association: "current_customer", column: "recipient_name",
column: "name",
}, },
}, },
sortableFields: [ sortableFields: [
@ -272,6 +271,7 @@ export class IssuedInvoiceRepository
"created_at", "created_at",
"serie", "serie",
], ],
fullTextTableAlias: "CustomerInvoiceModel",
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,
strictMode: true, // fuerza error si ORDER BY no permitido strictMode: true, // fuerza error si ORDER BY no permitido
@ -280,9 +280,9 @@ export class IssuedInvoiceRepository
/** /**
* Restricciones propias del repositorio. * Restricciones propias del repositorio.
* *
* El dominio `Proforma` no conoce `is_proforma`; este discriminador * El dominio `IssuedInvoice` no conoce `is_proforma`; este discriminador
* pertenece a la infraestructura porque actualmente se comparte tabla * pertenece a la infraestructura porque actualmente se comparte tabla
* con facturas emitidas. * con proformas.
* *
* No se añade `deleted_at: null` porque el modelo usa `paranoid: true`; * No se añade `deleted_at: null` porque el modelo usa `paranoid: true`;
* Sequelize aplica automáticamente `deleted_at IS NULL` en las consultas. * Sequelize aplica automáticamente `deleted_at IS NULL` en las consultas.

View File

@ -455,6 +455,7 @@ export class ProformaRepository
"created_at", "created_at",
"serie", "serie",
], ],
fullTextTableAlias: "CustomerInvoiceModel",
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,
strictMode: true, // fuerza error si ORDER BY no permitido strictMode: true, // fuerza error si ORDER BY no permitido

View File

@ -89,7 +89,7 @@ export const ProformaLineEditor = ({
{ {
id: "quantity", id: "quantity",
header: t("form_fields.items.quantity.label", "Cantidad"), header: t("form_fields.items.quantity.label", "Cantidad"),
headClassName: "w-[100px] text-right", headClassName: "w-[75px] text-right",
cell: ({ index }) => ( cell: ({ index }) => (
<QuantityField <QuantityField
inputClassName="border-none" inputClassName="border-none"
@ -102,7 +102,7 @@ export const ProformaLineEditor = ({
{ {
id: "unitAmount", id: "unitAmount",
header: t("form_fields.items.unit_amount.label", "Importe unitario"), header: t("form_fields.items.unit_amount.label", "Importe unitario"),
headClassName: "w-[200px] text-right", headClassName: "w-[100px] text-right",
cell: ({ index }) => ( cell: ({ index }) => (
<AmountField <AmountField
inputClassName="border-none" inputClassName="border-none"

View File

@ -95,7 +95,7 @@ export const ProformaUpdateHeaderEditor = ({
readOnly={readOnly} readOnly={readOnly}
/> />
<TextAreaField <TextAreaField
className="md:col-span-12 hidden" className="md:col-span-12"
disabled={disabled} disabled={disabled}
label={t("form_fields.proformas.notes.label")} label={t("form_fields.proformas.notes.label")}
maxLength={256} maxLength={256}

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/customers", "name": "@erp/customers",
"description": "Customers", "description": "Customers",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -14,6 +14,11 @@ export interface ICustomerPublicServices {
context: ICustomerServicesContext context: ICustomerServicesContext
) => Promise<Result<Customer, Error>>; ) => Promise<Result<Customer, Error>>;
getCustomerById: (
id: UniqueID,
context: ICustomerServicesContext
) => Promise<Result<Customer, Error>>;
createCustomer: ( createCustomer: (
id: UniqueID, id: UniqueID,
props: ICustomerCreateProps, props: ICustomerCreateProps,

View File

@ -42,6 +42,18 @@ export function buildCustomerPublicServices(
return Result.ok(customerResult.data); return Result.ok(customerResult.data);
}, },
getCustomerById: async (id: UniqueID, context: ICustomerServicesContext) => {
const { companyId, transaction } = context;
const customerResult = await finder.findCustomerById(companyId, id, transaction);
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
return Result.ok(customerResult.data);
},
createCustomer: async ( createCustomer: async (
id: UniqueID, id: UniqueID,
props: ICustomerCreateProps, props: ICustomerCreateProps,

View File

@ -1,5 +1,5 @@
import { useUrlParamId } from "@erp/core/hooks"; import { useUrlParamId } from "@erp/core/hooks";
import { useSearchParams } from "react-router-dom"; import { createSearchParams, useNavigate, useSearchParams } from "react-router-dom";
import { useCustomerUpdateController } from "./use-customer-update.controller"; import { useCustomerUpdateController } from "./use-customer-update.controller";
@ -11,12 +11,21 @@ import { useCustomerUpdateController } from "./use-customer-update.controller";
export const useCustomerUpdatePageController = () => { export const useCustomerUpdatePageController = () => {
const customerId = useUrlParamId(); const customerId = useUrlParamId();
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const updateCtrl = useCustomerUpdateController(customerId);
const returnTo = searchParams.get("returnTo") ?? "/customers"; const returnTo = searchParams.get("returnTo") ?? "/customers";
const updateCtrl = useCustomerUpdateController(customerId, {
onUpdated: (updated) => {
navigate({
pathname: `/customers/${updated.id}/`,
search: createSearchParams({
returnTo,
}).toString(),
});
},
});
return { return {
updateCtrl, updateCtrl,
returnTo, returnTo,

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/factuges", "name": "@erp/factuges",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/identity", "name": "@erp/identity",
"description": "Identity module", "description": "Identity module",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -22,7 +22,7 @@ export const identityAPIModule: IModuleServer = {
* - NO conecta infraestructura * - NO conecta infraestructura
*/ */
async setup(params) { async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; const { logger } = params;
// 1) Dominio interno // 1) Dominio interno
const internal = buildIdentityDependencies(params); const internal = buildIdentityDependencies(params);

View File

@ -0,0 +1,107 @@
import {
ExpressController,
type RequestWithAuth,
UnauthorizedApiError,
ValidationApiError,
} from "@erp/core/api";
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import type { NextFunction, Request, Response } from "express";
import type { IAccessTokenVerifier, IAccountRepository } from "../../../application";
const COMPANY_HEADER_NAME = "X-Company-Id";
function parseBearerToken(authorization?: string): string | undefined {
if (!authorization) {
return undefined;
}
const [scheme, token] = authorization.split(" ");
if (scheme?.toLowerCase() !== "bearer") {
return undefined;
}
return token?.trim() || undefined;
}
function getCompanyIdFromHeader(req: Request): UniqueID | undefined | Error {
const rawCompanyId = req.get(COMPANY_HEADER_NAME);
if (!rawCompanyId) {
return undefined;
}
const companyIdResult = UniqueID.create(rawCompanyId);
if (companyIdResult.isFailure) {
return new Error(`Header "${COMPANY_HEADER_NAME}" must be a valid UUID.`);
}
return companyIdResult.data;
}
export function authenticateUser(params: {
accessTokenVerifier: IAccessTokenVerifier;
accountRepository: IAccountRepository;
}) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const authorization = req.get("authorization");
const token = parseBearerToken(authorization);
if (!token) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const verifiedTokenResult = await params.accessTokenVerifier.verify(token);
if (verifiedTokenResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const accountIdResult = UniqueID.create(verifiedTokenResult.data.accountId);
if (accountIdResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const accountResult = await params.accountRepository.findById(accountIdResult.data);
if (accountResult.isFailure || accountResult.data.isNone()) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const account = accountResult.data.unwrap();
const canAuthenticateResult = account.canAuthenticate();
if (canAuthenticateResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const emailResult = EmailAddress.create(verifiedTokenResult.data.email);
if (emailResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const companyIdFromHeader = getCompanyIdFromHeader(req);
if (companyIdFromHeader instanceof Error) {
return ExpressController.errorResponse(
new ValidationApiError(companyIdFromHeader.message, [
{
field: COMPANY_HEADER_NAME,
message: companyIdFromHeader.message,
},
]),
req,
res
);
}
(req as RequestWithAuth).user = {
userId: account.id,
email: emailResult.data,
companyId: companyIdFromHeader,
roles: [],
};
return next();
} catch {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
};
}

View File

@ -1,69 +1,9 @@
import { ExpressController, type RequestWithAuth, UnauthorizedApiError } from "@erp/core/api";
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import type { NextFunction, Request, Response } from "express";
import type { IAccessTokenVerifier, IAccountRepository } from "../../../application"; import type { IAccessTokenVerifier, IAccountRepository } from "../../../application";
import { authenticateUser } from "./authenticate-user.middleware";
function parseBearerToken(authorization?: string): string | undefined {
if (!authorization) {
return undefined;
}
const [scheme, token] = authorization.split(" ");
if (scheme?.toLowerCase() !== "bearer") {
return undefined;
}
return token?.trim() || undefined;
}
export function identityAuthenticateRequest(params: { export function identityAuthenticateRequest(params: {
accessTokenVerifier: IAccessTokenVerifier; accessTokenVerifier: IAccessTokenVerifier;
accountRepository: IAccountRepository; accountRepository: IAccountRepository;
}) { }) {
return async (req: Request, res: Response, next: NextFunction) => { return authenticateUser(params);
try {
const authorization = req.get("authorization");
const token = parseBearerToken(authorization);
if (!token) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const verifiedTokenResult = await params.accessTokenVerifier.verify(token);
if (verifiedTokenResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const accountIdResult = UniqueID.create(verifiedTokenResult.data.accountId);
if (accountIdResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const accountResult = await params.accountRepository.findById(accountIdResult.data);
if (accountResult.isFailure || accountResult.data.isNone()) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const account = accountResult.data.unwrap();
const canAuthenticateResult = account.canAuthenticate();
if (canAuthenticateResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
const emailResult = EmailAddress.create(verifiedTokenResult.data.email);
if (emailResult.isFailure) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
(req as RequestWithAuth).user = {
userId: account.id,
email: emailResult.data,
};
return next();
} catch {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
};
} }

View File

@ -1 +1,4 @@
export * from "./authenticate-user.middleware";
export * from "./require-authenticated.middleware";
export * from "./require-company-context.middleware";
export * from "./identity-authenticate-request"; export * from "./identity-authenticate-request";

View File

@ -0,0 +1,13 @@
import { ExpressController, UnauthorizedApiError, type RequestWithAuth } from "@erp/core/api";
import type { NextFunction, Response } from "express";
export function requireAuthenticated() {
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
if (!req.user) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
next();
};
}

View File

@ -0,0 +1,20 @@
import { ExpressController, ForbiddenApiError, type RequestWithAuth } from "@erp/core/api";
import type { NextFunction, Response } from "express";
export function requireCompanyContext() {
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
/**
* TODO: validar membership real cuando exista persistencia de CompanyMembership.
*/
if (!req.user?.companyId) {
return ExpressController.errorResponse(
new ForbiddenApiError("Company context required"),
req,
res
);
}
next();
};
}

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/supplier-invoices", "name": "@erp/supplier-invoices",
"description": "Supplier invoices", "description": "Supplier invoices",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -326,6 +326,7 @@ export class SupplierInvoiceRepository
}, },
}, },
sortableFields: ["invoice_date", "due_date", "id", "created_at"], sortableFields: ["invoice_date", "due_date", "id", "created_at"],
fullTextTableAlias: "SupplierInvoiceModel",
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,
strictMode: true, strictMode: true,

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/suppliers", "name": "@erp/suppliers",
"description": "Suppliers", "description": "Suppliers",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,7 +1,7 @@
{ {
"name": "uecko-erp-2025", "name": "uecko-erp-2025",
"private": true, "private": true,
"version": "0.7.8", "version": "0.8.2",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"modules/*", "modules/*",

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/i18next", "name": "@repo/i18next",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-criteria", "name": "@repo/rdx-criteria",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-ddd", "name": "@repo/rdx-ddd",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-logger", "name": "@repo/rdx-logger",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-ui", "name": "@repo/rdx-ui",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -77,7 +77,7 @@ export const LineDescriptionField = <TFormValues extends FieldValues>({
React.useEffect(() => { React.useEffect(() => {
resizeTextarea(); resizeTextarea();
}, [resizeTextarea]); }, []);
return ( return (
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}> <Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/rdx-utils", "name": "@repo/rdx-utils",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/shadcn-ui", "name": "@repo/shadcn-ui",
"version": "0.7.8", "version": "0.8.2",
"type": "module", "type": "module",
"private": true, "private": true,
"exports": { "exports": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@repo/typescript-config", "name": "@repo/typescript-config",
"version": "0.7.8", "version": "0.8.2",
"private": true, "private": true,
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"