[TASK-2026-01-24-GAPS] feat: Add new entities for products/partners + health module

Changes:
- Add ProductAttribute, ProductAttributeValue, ProductVariant entities
- Add PartnerTaxInfo, PartnerSegment entities
- Add Health module (service, controller, module)
- Update index.ts and module.ts files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-24 07:26:04 -06:00
parent 8d201c5b58
commit 7d3ad15968
14 changed files with 574 additions and 4 deletions

View File

@ -1,3 +1,4 @@
// Core auth entities
export { Tenant, TenantStatus } from './tenant.entity.js';
export { Company } from './company.entity.js';
export { User, UserStatus } from './user.entity.js';
@ -18,3 +19,8 @@ export { ProfileTool } from './profile-tool.entity.js';
export { ProfileModule } from './profile-module.entity.js';
export { UserProfileAssignment } from './user-profile-assignment.entity.js';
export { Device } from './device.entity.js';
// NOTE: The following entities are also available in their specific modules:
// - UserProfile, ProfileTool, ProfileModule, UserProfileAssignment, Person -> profiles/entities/
// - Device, BiometricCredential, DeviceSession, DeviceActivityLog -> biometrics/entities/
// Import directly from those modules if needed.

View File

@ -0,0 +1,78 @@
import { Router, Request, Response } from 'express';
import { HealthService } from './health.service';
export class HealthController {
public router: Router;
constructor(private healthService: HealthService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
/**
* @swagger
* /health:
* get:
* summary: Get health status
* tags: [Health]
* responses:
* 200:
* description: Service is healthy
* 503:
* description: Service is unhealthy
*/
this.router.get('/', this.getHealth.bind(this));
/**
* @swagger
* /health/live:
* get:
* summary: Liveness probe
* tags: [Health]
* responses:
* 200:
* description: Service is alive
*/
this.router.get('/live', this.getLiveness.bind(this));
/**
* @swagger
* /health/ready:
* get:
* summary: Readiness probe
* tags: [Health]
* responses:
* 200:
* description: Service is ready
* 503:
* description: Service is not ready
*/
this.router.get('/ready', this.getReadiness.bind(this));
}
private async getHealth(req: Request, res: Response): Promise<void> {
try {
const health = await this.healthService.getHealth();
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
} catch (error) {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
private async getLiveness(req: Request, res: Response): Promise<void> {
const result = await this.healthService.getLiveness();
res.status(200).json(result);
}
private async getReadiness(req: Request, res: Response): Promise<void> {
const result = await this.healthService.getReadiness();
const statusCode = result.status === 'ready' ? 200 : 503;
res.status(statusCode).json(result);
}
}

View File

@ -0,0 +1,34 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { HealthService } from './health.service';
import { HealthController } from './health.controller';
export interface HealthModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class HealthModule {
public router: Router;
public healthService: HealthService;
private basePath: string;
constructor(options: HealthModuleOptions) {
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices(options.dataSource);
this.initializeRoutes();
}
private initializeServices(dataSource: DataSource): void {
this.healthService = new HealthService(dataSource);
}
private initializeRoutes(): void {
const healthController = new HealthController(this.healthService);
this.router.use(`${this.basePath}/health`, healthController.router);
}
}
export { HealthService } from './health.service';
export { HealthController } from './health.controller';

View File

@ -0,0 +1,100 @@
import { DataSource } from 'typeorm';
export interface HealthStatus {
status: 'healthy' | 'unhealthy' | 'degraded';
timestamp: string;
version: string;
uptime: number;
checks: {
database: HealthCheck;
memory: HealthCheck;
};
}
export interface HealthCheck {
status: 'up' | 'down';
responseTime?: number;
details?: Record<string, unknown>;
}
export class HealthService {
private startTime: number;
constructor(private dataSource: DataSource) {
this.startTime = Date.now();
}
async getHealth(): Promise<HealthStatus> {
const databaseCheck = await this.checkDatabase();
const memoryCheck = this.checkMemory();
const isHealthy = databaseCheck.status === 'up' && memoryCheck.status === 'up';
const isDegraded = databaseCheck.status === 'up' || memoryCheck.status === 'up';
return {
status: isHealthy ? 'healthy' : isDegraded ? 'degraded' : 'unhealthy',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || '1.0.0',
uptime: Math.floor((Date.now() - this.startTime) / 1000),
checks: {
database: databaseCheck,
memory: memoryCheck,
},
};
}
async checkDatabase(): Promise<HealthCheck> {
const startTime = Date.now();
try {
if (!this.dataSource.isInitialized) {
return {
status: 'down',
details: { error: 'Database not initialized' },
};
}
await this.dataSource.query('SELECT 1');
return {
status: 'up',
responseTime: Date.now() - startTime,
};
} catch (error) {
return {
status: 'down',
responseTime: Date.now() - startTime,
details: { error: error instanceof Error ? error.message : 'Unknown error' },
};
}
}
checkMemory(): HealthCheck {
const memoryUsage = process.memoryUsage();
const heapUsedPercent = (memoryUsage.heapUsed / memoryUsage.heapTotal) * 100;
return {
status: heapUsedPercent < 90 ? 'up' : 'down',
details: {
heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024),
external: Math.round(memoryUsage.external / 1024 / 1024),
rss: Math.round(memoryUsage.rss / 1024 / 1024),
heapUsedPercent: Math.round(heapUsedPercent * 100) / 100,
},
};
}
async getLiveness(): Promise<{ status: 'ok' }> {
return { status: 'ok' };
}
async getReadiness(): Promise<{ status: 'ready' | 'not_ready'; details?: Record<string, unknown> }> {
const databaseCheck = await this.checkDatabase();
if (databaseCheck.status !== 'up') {
return {
status: 'not_ready',
details: { database: databaseCheck },
};
}
return { status: 'ready' };
}
}

View File

@ -0,0 +1,3 @@
export { HealthModule, HealthModuleOptions } from './health.module';
export { HealthService, HealthStatus, HealthCheck } from './health.service';
export { HealthController } from './health.controller';

View File

@ -2,6 +2,8 @@ export { Partner } from './partner.entity';
export { PartnerAddress } from './partner-address.entity';
export { PartnerContact } from './partner-contact.entity';
export { PartnerBankAccount } from './partner-bank-account.entity';
export { PartnerTaxInfo } from './partner-tax-info.entity';
export { PartnerSegment } from './partner-segment.entity';
// Type aliases
export type PartnerType = 'customer' | 'supplier' | 'both';

View File

@ -0,0 +1,79 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
* Partner Segment Entity (schema: partners.partner_segments)
*
* Defines customer/supplier segments for grouping and analytics.
* Examples: VIP Customers, Wholesale Buyers, Local Suppliers, etc.
*/
@Entity({ name: 'partner_segments', schema: 'partners' })
export class PartnerSegment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ type: 'varchar', length: 30 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
// Segment type
@Column({ name: 'segment_type', type: 'varchar', length: 20, default: 'customer' })
segmentType: 'customer' | 'supplier' | 'both';
// Styling
@Column({ type: 'varchar', length: 20, nullable: true })
color: string;
@Column({ type: 'varchar', length: 50, nullable: true })
icon: string;
// Rules for auto-assignment (stored as JSON)
@Column({ type: 'jsonb', nullable: true })
rules: Record<string, unknown>;
// Benefits/conditions
@Column({ name: 'default_discount', type: 'decimal', precision: 5, scale: 2, default: 0 })
defaultDiscount: number;
@Column({ name: 'default_payment_terms', type: 'int', default: 0 })
defaultPaymentTerms: number;
@Column({ name: 'priority', type: 'int', default: 0 })
priority: number;
// State
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder: number;
// Metadata
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
}

View File

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Partner } from './partner.entity';
/**
* Partner Tax Info Entity (schema: partners.partner_tax_info)
*
* Extended tax/fiscal information for partners.
* Stores additional fiscal details required for invoicing and compliance.
*/
@Entity({ name: 'partner_tax_info', schema: 'partners' })
export class PartnerTaxInfo {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'partner_id', type: 'uuid' })
partnerId: string;
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'partner_id' })
partner: Partner;
// Fiscal identification
@Column({ name: 'tax_id_type', type: 'varchar', length: 20, nullable: true })
taxIdType: string; // RFC, CURP, EIN, VAT
@Column({ name: 'tax_id_country', type: 'varchar', length: 3, default: 'MEX' })
taxIdCountry: string;
// SAT (Mexico) specific
@Column({ name: 'sat_regime', type: 'varchar', length: 10, nullable: true })
satRegime: string; // 601, 603, 612, etc.
@Column({ name: 'sat_regime_name', type: 'varchar', length: 200, nullable: true })
satRegimeName: string;
@Column({ name: 'cfdi_use', type: 'varchar', length: 10, nullable: true })
cfdiUse: string; // G01, G02, G03, etc.
@Column({ name: 'cfdi_use_name', type: 'varchar', length: 200, nullable: true })
cfdiUseName: string;
@Column({ name: 'fiscal_zip_code', type: 'varchar', length: 10, nullable: true })
fiscalZipCode: string;
// Withholding taxes
@Column({ name: 'withholding_isr', type: 'decimal', precision: 5, scale: 2, default: 0 })
withholdingIsr: number;
@Column({ name: 'withholding_iva', type: 'decimal', precision: 5, scale: 2, default: 0 })
withholdingIva: number;
// Validation
@Column({ name: 'is_verified', type: 'boolean', default: false })
isVerified: boolean;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt: Date;
@Column({ name: 'verification_source', type: 'varchar', length: 50, nullable: true })
verificationSource: string; // SAT, MANUAL, API
// Metadata
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -2,7 +2,7 @@ import { Router } from 'express';
import { DataSource } from 'typeorm';
import { PartnersService } from './services';
import { PartnersController } from './controllers';
import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from './entities';
import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount, PartnerTaxInfo, PartnerSegment } from './entities';
export interface PartnersModuleOptions {
dataSource: DataSource;
@ -43,6 +43,6 @@ export class PartnersModule {
}
static getEntities(): Function[] {
return [Partner, PartnerAddress, PartnerContact, PartnerBankAccount];
return [Partner, PartnerAddress, PartnerContact, PartnerBankAccount, PartnerTaxInfo, PartnerSegment];
}
}

View File

@ -2,3 +2,6 @@ export { ProductCategory } from './product-category.entity';
export { Product } from './product.entity';
export { ProductPrice } from './product-price.entity';
export { ProductSupplier } from './product-supplier.entity';
export { ProductAttribute } from './product-attribute.entity';
export { ProductAttributeValue } from './product-attribute-value.entity';
export { ProductVariant } from './product-variant.entity';

View File

@ -0,0 +1,55 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ProductAttribute } from './product-attribute.entity';
/**
* Product Attribute Value Entity (schema: products.product_attribute_values)
*
* Represents specific values for product attributes.
* Example: For attribute "Color", values could be "Red", "Blue", "Green".
*/
@Entity({ name: 'product_attribute_values', schema: 'products' })
export class ProductAttributeValue {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'attribute_id', type: 'uuid' })
attributeId: string;
@ManyToOne(() => ProductAttribute, (attribute) => attribute.values, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'attribute_id' })
attribute: ProductAttribute;
@Column({ type: 'varchar', length: 50, nullable: true })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ name: 'html_color', type: 'varchar', length: 20, nullable: true })
htmlColor: string;
@Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true })
imageUrl: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { ProductAttributeValue } from './product-attribute-value.entity';
/**
* Product Attribute Entity (schema: products.product_attributes)
*
* Represents configurable attributes for products like color, size, material.
* Each attribute can have multiple values (e.g., Color: Red, Blue, Green).
*/
@Entity({ name: 'product_attributes', schema: 'products' })
export class ProductAttribute {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'display_type', type: 'varchar', length: 20, default: 'radio' })
displayType: 'radio' | 'select' | 'color' | 'pills';
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@OneToMany(() => ProductAttributeValue, (value) => value.attribute)
values: ProductAttributeValue[];
}

View File

@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Product } from './product.entity';
/**
* Product Variant Entity (schema: products.product_variants)
*
* Represents product variants generated from attribute combinations.
* Example: "Blue T-Shirt - Size M" is a variant of product "T-Shirt".
*/
@Entity({ name: 'product_variants', schema: 'products' })
export class ProductVariant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@ManyToOne(() => Product, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'product_id' })
product: Product;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ type: 'varchar', length: 50 })
sku: string;
@Column({ type: 'varchar', length: 50, nullable: true })
barcode: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ name: 'price_extra', type: 'decimal', precision: 15, scale: 4, default: 0 })
priceExtra: number;
@Column({ name: 'cost_extra', type: 'decimal', precision: 15, scale: 4, default: 0 })
costExtra: number;
@Column({ name: 'stock_qty', type: 'decimal', precision: 15, scale: 4, default: 0 })
stockQty: number;
@Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true })
imageUrl: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
}

View File

@ -2,7 +2,7 @@ import { Router } from 'express';
import { DataSource } from 'typeorm';
import { ProductsService } from './services';
import { ProductsController, CategoriesController } from './controllers';
import { Product, ProductCategory } from './entities';
import { Product, ProductCategory, ProductAttribute, ProductAttributeValue, ProductVariant } from './entities';
export interface ProductsModuleOptions {
dataSource: DataSource;
@ -39,6 +39,6 @@ export class ProductsModule {
}
static getEntities(): Function[] {
return [Product, ProductCategory];
return [Product, ProductCategory, ProductAttribute, ProductAttributeValue, ProductVariant];
}
}