diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts index 446ce7a..27dcf9e 100644 --- a/src/modules/auth/entities/index.ts +++ b/src/modules/auth/entities/index.ts @@ -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. diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100644 index 0000000..6c4dad4 --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -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 { + 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 { + const result = await this.healthService.getLiveness(); + res.status(200).json(result); + } + + private async getReadiness(req: Request, res: Response): Promise { + const result = await this.healthService.getReadiness(); + const statusCode = result.status === 'ready' ? 200 : 503; + res.status(statusCode).json(result); + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..7bb0228 --- /dev/null +++ b/src/modules/health/health.module.ts @@ -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'; diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts new file mode 100644 index 0000000..d845127 --- /dev/null +++ b/src/modules/health/health.service.ts @@ -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; +} + +export class HealthService { + private startTime: number; + + constructor(private dataSource: DataSource) { + this.startTime = Date.now(); + } + + async getHealth(): Promise { + 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 { + 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 }> { + const databaseCheck = await this.checkDatabase(); + if (databaseCheck.status !== 'up') { + return { + status: 'not_ready', + details: { database: databaseCheck }, + }; + } + return { status: 'ready' }; + } +} diff --git a/src/modules/health/index.ts b/src/modules/health/index.ts new file mode 100644 index 0000000..958cab2 --- /dev/null +++ b/src/modules/health/index.ts @@ -0,0 +1,3 @@ +export { HealthModule, HealthModuleOptions } from './health.module'; +export { HealthService, HealthStatus, HealthCheck } from './health.service'; +export { HealthController } from './health.controller'; diff --git a/src/modules/partners/entities/index.ts b/src/modules/partners/entities/index.ts index ee4a606..82bc255 100644 --- a/src/modules/partners/entities/index.ts +++ b/src/modules/partners/entities/index.ts @@ -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'; diff --git a/src/modules/partners/entities/partner-segment.entity.ts b/src/modules/partners/entities/partner-segment.entity.ts new file mode 100644 index 0000000..97e489c --- /dev/null +++ b/src/modules/partners/entities/partner-segment.entity.ts @@ -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; + + // 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; +} diff --git a/src/modules/partners/entities/partner-tax-info.entity.ts b/src/modules/partners/entities/partner-tax-info.entity.ts new file mode 100644 index 0000000..b909c01 --- /dev/null +++ b/src/modules/partners/entities/partner-tax-info.entity.ts @@ -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; +} diff --git a/src/modules/partners/partners.module.ts b/src/modules/partners/partners.module.ts index 8e6e8c8..ebdf29e 100644 --- a/src/modules/partners/partners.module.ts +++ b/src/modules/partners/partners.module.ts @@ -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]; } } diff --git a/src/modules/products/entities/index.ts b/src/modules/products/entities/index.ts index 1471528..55118e7 100644 --- a/src/modules/products/entities/index.ts +++ b/src/modules/products/entities/index.ts @@ -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'; diff --git a/src/modules/products/entities/product-attribute-value.entity.ts b/src/modules/products/entities/product-attribute-value.entity.ts new file mode 100644 index 0000000..0a5f63b --- /dev/null +++ b/src/modules/products/entities/product-attribute-value.entity.ts @@ -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; +} diff --git a/src/modules/products/entities/product-attribute.entity.ts b/src/modules/products/entities/product-attribute.entity.ts new file mode 100644 index 0000000..2460ef0 --- /dev/null +++ b/src/modules/products/entities/product-attribute.entity.ts @@ -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[]; +} diff --git a/src/modules/products/entities/product-variant.entity.ts b/src/modules/products/entities/product-variant.entity.ts new file mode 100644 index 0000000..5c677fe --- /dev/null +++ b/src/modules/products/entities/product-variant.entity.ts @@ -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; +} diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts index a1d90d4..7e0a047 100644 --- a/src/modules/products/products.module.ts +++ b/src/modules/products/products.module.ts @@ -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]; } }