From ca07b4268dd27a1d14d92e97f24803744a7bb8b4 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 16 Jan 2026 00:40:54 -0600 Subject: [PATCH] feat: Add complete module structure for ERP backend - Add modules: ai, audit, billing-usage, biometrics, branches, dashboard, feature-flags, invoices, mcp, mobile, notifications, partners, payment-terminals, products, profiles, purchases, reports, sales, storage, warehouses, webhooks, whatsapp - Add controllers, DTOs, entities, and services for each module - Add shared services and utilities Co-Authored-By: Claude Opus 4.5 --- src/app.integration.ts | 503 +++++++++++++++++ src/modules/ai/ai.module.ts | 66 +++ src/modules/ai/controllers/ai.controller.ts | 381 +++++++++++++ src/modules/ai/controllers/index.ts | 1 + src/modules/ai/dto/ai.dto.ts | 343 ++++++++++++ src/modules/ai/dto/index.ts | 9 + src/modules/ai/entities/completion.entity.ts | 92 +++ .../ai/entities/conversation.entity.ts | 160 ++++++ src/modules/ai/entities/embedding.entity.ts | 77 +++ src/modules/ai/entities/index.ts | 7 + .../ai/entities/knowledge-base.entity.ts | 98 ++++ src/modules/ai/entities/model.entity.ts | 78 +++ src/modules/ai/entities/prompt.entity.ts | 110 ++++ src/modules/ai/entities/usage.entity.ts | 120 ++++ src/modules/ai/index.ts | 5 + src/modules/ai/services/ai.service.ts | 384 +++++++++++++ src/modules/ai/services/index.ts | 1 + src/modules/audit/audit.module.ts | 70 +++ .../audit/controllers/audit.controller.ts | 342 ++++++++++++ src/modules/audit/controllers/index.ts | 1 + src/modules/audit/dto/audit.dto.ts | 346 ++++++++++++ src/modules/audit/dto/index.ts | 10 + .../audit/entities/audit-log.entity.ts | 108 ++++ .../audit/entities/config-change.entity.ts | 47 ++ .../audit/entities/data-export.entity.ts | 80 +++ .../audit/entities/entity-change.entity.ts | 55 ++ src/modules/audit/entities/index.ts | 7 + .../audit/entities/login-history.entity.ts | 106 ++++ .../entities/permission-change.entity.ts | 63 +++ .../entities/sensitive-data-access.entity.ts | 62 +++ src/modules/audit/index.ts | 5 + src/modules/audit/services/audit.service.ts | 303 ++++++++++ src/modules/audit/services/index.ts | 1 + .../billing-usage/billing-usage.module.ts | 60 ++ .../billing-usage/controllers/index.ts | 8 + .../controllers/invoices.controller.ts | 258 +++++++++ .../subscription-plans.controller.ts | 168 ++++++ .../controllers/subscriptions.controller.ts | 232 ++++++++ .../controllers/usage.controller.ts | 173 ++++++ .../billing-usage/dto/create-invoice.dto.ts | 75 +++ .../dto/create-subscription-plan.dto.ts | 41 ++ .../dto/create-subscription.dto.ts | 57 ++ src/modules/billing-usage/dto/index.ts | 8 + .../billing-usage/dto/usage-tracking.dto.ts | 90 +++ .../entities/billing-alert.entity.ts | 72 +++ src/modules/billing-usage/entities/index.ts | 8 + .../entities/invoice-item.entity.ts | 65 +++ .../billing-usage/entities/invoice.entity.ts | 121 ++++ .../entities/payment-method.entity.ts | 85 +++ .../entities/subscription-plan.entity.ts | 83 +++ .../entities/tenant-subscription.entity.ts | 117 ++++ .../entities/usage-event.entity.ts | 73 +++ .../entities/usage-tracking.entity.ts | 91 +++ src/modules/billing-usage/index.ts | 18 + src/modules/billing-usage/services/index.ts | 8 + .../services/invoices.service.ts | 471 ++++++++++++++++ .../services/subscription-plans.service.ts | 200 +++++++ .../services/subscriptions.service.ts | 384 +++++++++++++ .../services/usage-tracking.service.ts | 381 +++++++++++++ .../entities/biometric-credential.entity.ts | 81 +++ .../entities/device-activity-log.entity.ts | 50 ++ .../entities/device-session.entity.ts | 84 +++ .../biometrics/entities/device.entity.ts | 121 ++++ src/modules/biometrics/entities/index.ts | 4 + src/modules/branches/branches.module.ts | 48 ++ .../controllers/branches.controller.ts | 364 ++++++++++++ src/modules/branches/controllers/index.ts | 1 + .../branches/dto/branch-schedule.dto.ts | 100 ++++ src/modules/branches/dto/create-branch.dto.ts | 265 +++++++++ src/modules/branches/dto/index.ts | 11 + .../branch-inventory-settings.entity.ts | 63 +++ .../branch-payment-terminal.entity.ts | 77 +++ .../entities/branch-schedule.entity.ts | 73 +++ .../branches/entities/branch.entity.ts | 158 ++++++ src/modules/branches/entities/index.ts | 5 + .../entities/user-branch-assignment.entity.ts | 72 +++ src/modules/branches/index.ts | 5 + .../branches/services/branches.service.ts | 435 +++++++++++++++ src/modules/branches/services/index.ts | 1 + src/modules/dashboard/controllers/index.ts | 96 ++++ src/modules/dashboard/dashboard.module.ts | 38 ++ src/modules/dashboard/index.ts | 3 + src/modules/dashboard/services/index.ts | 386 +++++++++++++ .../controllers/feature-flags.controller.ts | 367 ++++++++++++ .../feature-flags/controllers/index.ts | 1 + .../feature-flags/dto/feature-flag.dto.ts | 53 ++ src/modules/feature-flags/dto/index.ts | 1 + .../feature-flags/entities/flag.entity.ts | 57 ++ src/modules/feature-flags/entities/index.ts | 2 + .../entities/tenant-override.entity.ts | 50 ++ .../feature-flags/feature-flags.module.ts | 44 ++ src/modules/feature-flags/index.ts | 5 + .../services/feature-flags.service.ts | 345 ++++++++++++ src/modules/feature-flags/services/index.ts | 1 + src/modules/inventory/controllers/index.ts | 1 + .../controllers/inventory.controller.ts | 342 ++++++++++++ .../inventory/dto/create-inventory.dto.ts | 192 +++++++ src/modules/inventory/dto/index.ts | 6 + src/modules/inventory/entities/index.ts | 17 +- .../entities/inventory-count-line.entity.ts | 56 ++ .../entities/inventory-count.entity.ts | 53 ++ .../inventory/entities/stock-level.entity.ts | 87 +++ .../entities/stock-movement.entity.ts | 122 ++++ .../entities/transfer-order-line.entity.ts | 50 ++ .../entities/transfer-order.entity.ts | 50 ++ src/modules/inventory/index.ts | 21 +- src/modules/inventory/inventory.module.ts | 45 ++ src/modules/inventory/services/index.ts | 5 + .../inventory/services/inventory.service.ts | 470 ++++++++++++++++ src/modules/invoices/controllers/index.ts | 129 +++++ src/modules/invoices/dto/index.ts | 59 ++ src/modules/invoices/entities/index.ts | 4 + .../invoices/entities/invoice-item.entity.ts | 78 +++ .../invoices/entities/invoice.entity.ts | 118 ++++ .../entities/payment-allocation.entity.ts | 37 ++ .../invoices/entities/payment.entity.ts | 73 +++ src/modules/invoices/index.ts | 5 + src/modules/invoices/invoices.module.ts | 42 ++ src/modules/invoices/services/index.ts | 86 +++ src/modules/mcp/controllers/index.ts | 1 + src/modules/mcp/controllers/mcp.controller.ts | 223 ++++++++ src/modules/mcp/dto/index.ts | 1 + src/modules/mcp/dto/mcp.dto.ts | 66 +++ src/modules/mcp/entities/index.ts | 2 + .../mcp/entities/tool-call-result.entity.ts | 45 ++ src/modules/mcp/entities/tool-call.entity.ts | 65 +++ src/modules/mcp/index.ts | 7 + src/modules/mcp/interfaces/index.ts | 3 + .../mcp/interfaces/mcp-context.interface.ts | 17 + .../mcp/interfaces/mcp-resource.interface.ts | 18 + .../mcp/interfaces/mcp-tool.interface.ts | 62 +++ src/modules/mcp/mcp.module.ts | 64 +++ src/modules/mcp/services/index.ts | 3 + .../mcp/services/mcp-server.service.ts | 197 +++++++ .../mcp/services/tool-logger.service.ts | 171 ++++++ .../mcp/services/tool-registry.service.ts | 53 ++ .../mcp/tools/customers-tools.service.ts | 94 ++++ src/modules/mcp/tools/fiados-tools.service.ts | 216 +++++++ src/modules/mcp/tools/index.ts | 5 + .../mcp/tools/inventory-tools.service.ts | 154 +++++ src/modules/mcp/tools/orders-tools.service.ts | 139 +++++ .../mcp/tools/products-tools.service.ts | 128 +++++ src/modules/mobile/entities/index.ts | 6 + .../mobile/entities/mobile-session.entity.ts | 97 ++++ .../entities/offline-sync-queue.entity.ts | 93 ++++ .../entities/payment-transaction.entity.ts | 115 ++++ .../entities/push-notification-log.entity.ts | 81 +++ .../mobile/entities/push-token.entity.ts | 65 +++ .../mobile/entities/sync-conflict.entity.ts | 63 +++ .../notifications/controllers/index.ts | 1 + .../controllers/notifications.controller.ts | 257 +++++++++ src/modules/notifications/dto/index.ts | 8 + .../notifications/dto/notification.dto.ts | 256 +++++++++ .../notifications/entities/channel.entity.ts | 59 ++ .../entities/in-app-notification.entity.ts | 78 +++ src/modules/notifications/entities/index.ts | 6 + .../entities/notification-batch.entity.ts | 88 +++ .../entities/notification.entity.ts | 131 +++++ .../entities/preference.entity.ts | 74 +++ .../notifications/entities/template.entity.ts | 118 ++++ src/modules/notifications/index.ts | 5 + .../notifications/notifications.module.ts | 68 +++ src/modules/notifications/services/index.ts | 1 + .../services/notifications.service.ts | 215 +++++++ src/modules/partners/controllers/index.ts | 1 + .../controllers/partners.controller.ts | 348 ++++++++++++ .../partners/dto/create-partner.dto.ts | 389 +++++++++++++ src/modules/partners/dto/index.ts | 7 + src/modules/partners/entities/index.ts | 5 +- .../entities/partner-address.entity.ts | 82 +++ .../entities/partner-bank-account.entity.ts | 77 +++ .../entities/partner-contact.entity.ts | 72 +++ .../partners/entities/partner.entity.ts | 170 +++--- src/modules/partners/index.ts | 11 +- src/modules/partners/partners.module.ts | 48 ++ src/modules/partners/services/index.ts | 1 + .../partners/services/partners.service.ts | 266 +++++++++ .../payment-terminals/controllers/index.ts | 6 + .../controllers/terminals.controller.ts | 192 +++++++ .../controllers/transactions.controller.ts | 163 ++++++ src/modules/payment-terminals/dto/index.ts | 6 + .../payment-terminals/dto/terminal.dto.ts | 47 ++ .../payment-terminals/dto/transaction.dto.ts | 75 +++ src/modules/payment-terminals/index.ts | 15 + .../payment-terminals.module.ts | 46 ++ .../payment-terminals/services/index.ts | 6 + .../services/terminals.service.ts | 224 ++++++++ .../services/transactions.service.ts | 498 +++++++++++++++++ src/modules/products/controllers/index.ts | 1 + .../controllers/products.controller.ts | 377 +++++++++++++ .../products/dto/create-product.dto.ts | 431 ++++++++++++++ src/modules/products/dto/index.ts | 6 + src/modules/products/entities/index.ts | 4 + .../entities/product-category.entity.ts | 69 +++ .../products/entities/product-price.entity.ts | 48 ++ .../entities/product-supplier.entity.ts | 51 ++ .../products/entities/product.entity.ts | 176 ++++++ src/modules/products/index.ts | 5 + src/modules/products/products.module.ts | 44 ++ src/modules/products/services/index.ts | 1 + .../products/services/products.service.ts | 328 +++++++++++ src/modules/profiles/controllers/index.ts | 2 + .../controllers/persons.controller.ts | 180 ++++++ .../controllers/profiles.controller.ts | 281 ++++++++++ src/modules/profiles/dto/create-person.dto.ts | 135 +++++ .../profiles/dto/create-profile.dto.ts | 165 ++++++ src/modules/profiles/dto/index.ts | 12 + src/modules/profiles/entities/index.ts | 5 + .../profiles/entities/person.entity.ts | 85 +++ .../entities/profile-module.entity.ts | 38 ++ .../profiles/entities/profile-tool.entity.ts | 61 ++ .../user-profile-assignment.entity.ts | 43 ++ .../profiles/entities/user-profile.entity.ts | 89 +++ src/modules/profiles/index.ts | 5 + src/modules/profiles/profiles.module.ts | 55 ++ src/modules/profiles/services/index.ts | 2 + .../profiles/services/persons.service.ts | 162 ++++++ .../profiles/services/profiles.service.ts | 272 +++++++++ src/modules/purchases/controllers/index.ts | 89 +++ src/modules/purchases/dto/index.ts | 39 ++ src/modules/purchases/entities/index.ts | 4 + .../entities/purchase-order-item.entity.ts | 84 +++ .../entities/purchase-order.entity.ts | 98 ++++ .../entities/purchase-receipt-item.entity.ts | 57 ++ .../entities/purchase-receipt.entity.ts | 52 ++ src/modules/purchases/index.ts | 9 +- src/modules/purchases/purchases.module.ts | 39 ++ src/modules/purchases/services/index.ts | 66 +++ src/modules/reports/controllers/index.ts | 230 ++++++++ src/modules/reports/index.ts | 6 +- src/modules/reports/reports.module.ts | 38 ++ src/modules/reports/services/index.ts | 526 ++++++++++++++++++ src/modules/sales/controllers/index.ts | 177 ++++++ src/modules/sales/dto/index.ts | 82 +++ src/modules/sales/entities/index.ts | 4 + .../sales/entities/quotation-item.entity.ts | 65 +++ .../sales/entities/quotation.entity.ts | 101 ++++ .../sales/entities/sales-order-item.entity.ts | 90 +++ .../sales/entities/sales-order.entity.ts | 113 ++++ src/modules/sales/index.ts | 12 +- src/modules/sales/sales.module.ts | 42 ++ src/modules/sales/services/index.ts | 144 +++++ src/modules/storage/controllers/index.ts | 1 + .../storage/controllers/storage.controller.ts | 358 ++++++++++++ src/modules/storage/dto/index.ts | 10 + src/modules/storage/dto/storage.dto.ts | 286 ++++++++++ src/modules/storage/entities/bucket.entity.ts | 66 +++ .../entities/file-access-token.entity.ts | 63 +++ .../storage/entities/file-share.entity.ts | 88 +++ src/modules/storage/entities/file.entity.ts | 154 +++++ src/modules/storage/entities/folder.entity.ts | 83 +++ src/modules/storage/entities/index.ts | 7 + .../storage/entities/tenant-usage.entity.ts | 57 ++ src/modules/storage/entities/upload.entity.ts | 102 ++++ src/modules/storage/index.ts | 5 + src/modules/storage/services/index.ts | 1 + .../storage/services/storage.service.ts | 332 +++++++++++ src/modules/storage/storage.module.ts | 54 ++ src/modules/warehouses/controllers/index.ts | 1 + .../controllers/warehouses.controller.ts | 313 +++++++++++ .../warehouses/dto/create-warehouse.dto.ts | 378 +++++++++++++ src/modules/warehouses/dto/index.ts | 6 + src/modules/warehouses/entities/index.ts | 3 + .../entities/warehouse-location.entity.ts | 111 ++++ .../entities/warehouse-zone.entity.ts | 41 ++ .../warehouses/entities/warehouse.entity.ts | 115 ++++ src/modules/warehouses/index.ts | 5 + src/modules/warehouses/services/index.ts | 5 + .../warehouses/services/warehouses.service.ts | 294 ++++++++++ src/modules/warehouses/warehouses.module.ts | 41 ++ src/modules/webhooks/controllers/index.ts | 1 + .../controllers/webhooks.controller.ts | 276 +++++++++ src/modules/webhooks/dto/index.ts | 8 + src/modules/webhooks/dto/webhook.dto.ts | 178 ++++++ .../webhooks/entities/delivery.entity.ts | 97 ++++ .../webhooks/entities/endpoint-log.entity.ts | 46 ++ .../webhooks/entities/endpoint.entity.ts | 110 ++++ .../webhooks/entities/event-type.entity.ts | 48 ++ src/modules/webhooks/entities/event.entity.ts | 61 ++ src/modules/webhooks/entities/index.ts | 6 + .../webhooks/entities/subscription.entity.ts | 55 ++ src/modules/webhooks/index.ts | 5 + src/modules/webhooks/services/index.ts | 1 + .../webhooks/services/webhooks.service.ts | 263 +++++++++ src/modules/webhooks/webhooks.module.ts | 58 ++ src/modules/whatsapp/controllers/index.ts | 1 + .../controllers/whatsapp.controller.ts | 500 +++++++++++++++++ src/modules/whatsapp/dto/index.ts | 14 + src/modules/whatsapp/dto/whatsapp.dto.ts | 377 +++++++++++++ .../whatsapp/entities/account.entity.ts | 102 ++++ .../whatsapp/entities/automation.entity.ts | 75 +++ .../entities/broadcast-recipient.entity.ts | 69 +++ .../whatsapp/entities/broadcast.entity.ts | 102 ++++ .../whatsapp/entities/contact.entity.ts | 99 ++++ .../whatsapp/entities/conversation.entity.ts | 92 +++ src/modules/whatsapp/entities/index.ts | 10 + .../entities/message-status-update.entity.ts | 45 ++ .../whatsapp/entities/message.entity.ts | 137 +++++ .../whatsapp/entities/quick-reply.entity.ts | 67 +++ .../whatsapp/entities/template.entity.ts | 106 ++++ src/modules/whatsapp/index.ts | 5 + src/modules/whatsapp/services/index.ts | 1 + .../whatsapp/services/whatsapp.service.ts | 464 +++++++++++++++ src/modules/whatsapp/whatsapp.module.ts | 58 ++ src/shared/services/feature-flags.service.ts | 195 +++++++ src/shared/services/index.ts | 13 +- src/shared/utils/circuit-breaker.ts | 158 ++++++ src/shared/utils/index.ts | 7 + 308 files changed, 31420 insertions(+), 147 deletions(-) create mode 100644 src/app.integration.ts create mode 100644 src/modules/ai/ai.module.ts create mode 100644 src/modules/ai/controllers/ai.controller.ts create mode 100644 src/modules/ai/controllers/index.ts create mode 100644 src/modules/ai/dto/ai.dto.ts create mode 100644 src/modules/ai/dto/index.ts create mode 100644 src/modules/ai/entities/completion.entity.ts create mode 100644 src/modules/ai/entities/conversation.entity.ts create mode 100644 src/modules/ai/entities/embedding.entity.ts create mode 100644 src/modules/ai/entities/index.ts create mode 100644 src/modules/ai/entities/knowledge-base.entity.ts create mode 100644 src/modules/ai/entities/model.entity.ts create mode 100644 src/modules/ai/entities/prompt.entity.ts create mode 100644 src/modules/ai/entities/usage.entity.ts create mode 100644 src/modules/ai/index.ts create mode 100644 src/modules/ai/services/ai.service.ts create mode 100644 src/modules/ai/services/index.ts create mode 100644 src/modules/audit/audit.module.ts create mode 100644 src/modules/audit/controllers/audit.controller.ts create mode 100644 src/modules/audit/controllers/index.ts create mode 100644 src/modules/audit/dto/audit.dto.ts create mode 100644 src/modules/audit/dto/index.ts create mode 100644 src/modules/audit/entities/audit-log.entity.ts create mode 100644 src/modules/audit/entities/config-change.entity.ts create mode 100644 src/modules/audit/entities/data-export.entity.ts create mode 100644 src/modules/audit/entities/entity-change.entity.ts create mode 100644 src/modules/audit/entities/index.ts create mode 100644 src/modules/audit/entities/login-history.entity.ts create mode 100644 src/modules/audit/entities/permission-change.entity.ts create mode 100644 src/modules/audit/entities/sensitive-data-access.entity.ts create mode 100644 src/modules/audit/index.ts create mode 100644 src/modules/audit/services/audit.service.ts create mode 100644 src/modules/audit/services/index.ts create mode 100644 src/modules/billing-usage/billing-usage.module.ts create mode 100644 src/modules/billing-usage/controllers/index.ts create mode 100644 src/modules/billing-usage/controllers/invoices.controller.ts create mode 100644 src/modules/billing-usage/controllers/subscription-plans.controller.ts create mode 100644 src/modules/billing-usage/controllers/subscriptions.controller.ts create mode 100644 src/modules/billing-usage/controllers/usage.controller.ts create mode 100644 src/modules/billing-usage/dto/create-invoice.dto.ts create mode 100644 src/modules/billing-usage/dto/create-subscription-plan.dto.ts create mode 100644 src/modules/billing-usage/dto/create-subscription.dto.ts create mode 100644 src/modules/billing-usage/dto/index.ts create mode 100644 src/modules/billing-usage/dto/usage-tracking.dto.ts create mode 100644 src/modules/billing-usage/entities/billing-alert.entity.ts create mode 100644 src/modules/billing-usage/entities/index.ts create mode 100644 src/modules/billing-usage/entities/invoice-item.entity.ts create mode 100644 src/modules/billing-usage/entities/invoice.entity.ts create mode 100644 src/modules/billing-usage/entities/payment-method.entity.ts create mode 100644 src/modules/billing-usage/entities/subscription-plan.entity.ts create mode 100644 src/modules/billing-usage/entities/tenant-subscription.entity.ts create mode 100644 src/modules/billing-usage/entities/usage-event.entity.ts create mode 100644 src/modules/billing-usage/entities/usage-tracking.entity.ts create mode 100644 src/modules/billing-usage/index.ts create mode 100644 src/modules/billing-usage/services/index.ts create mode 100644 src/modules/billing-usage/services/invoices.service.ts create mode 100644 src/modules/billing-usage/services/subscription-plans.service.ts create mode 100644 src/modules/billing-usage/services/subscriptions.service.ts create mode 100644 src/modules/billing-usage/services/usage-tracking.service.ts create mode 100644 src/modules/biometrics/entities/biometric-credential.entity.ts create mode 100644 src/modules/biometrics/entities/device-activity-log.entity.ts create mode 100644 src/modules/biometrics/entities/device-session.entity.ts create mode 100644 src/modules/biometrics/entities/device.entity.ts create mode 100644 src/modules/biometrics/entities/index.ts create mode 100644 src/modules/branches/branches.module.ts create mode 100644 src/modules/branches/controllers/branches.controller.ts create mode 100644 src/modules/branches/controllers/index.ts create mode 100644 src/modules/branches/dto/branch-schedule.dto.ts create mode 100644 src/modules/branches/dto/create-branch.dto.ts create mode 100644 src/modules/branches/dto/index.ts create mode 100644 src/modules/branches/entities/branch-inventory-settings.entity.ts create mode 100644 src/modules/branches/entities/branch-payment-terminal.entity.ts create mode 100644 src/modules/branches/entities/branch-schedule.entity.ts create mode 100644 src/modules/branches/entities/branch.entity.ts create mode 100644 src/modules/branches/entities/index.ts create mode 100644 src/modules/branches/entities/user-branch-assignment.entity.ts create mode 100644 src/modules/branches/index.ts create mode 100644 src/modules/branches/services/branches.service.ts create mode 100644 src/modules/branches/services/index.ts create mode 100644 src/modules/dashboard/controllers/index.ts create mode 100644 src/modules/dashboard/dashboard.module.ts create mode 100644 src/modules/dashboard/index.ts create mode 100644 src/modules/dashboard/services/index.ts create mode 100644 src/modules/feature-flags/controllers/feature-flags.controller.ts create mode 100644 src/modules/feature-flags/controllers/index.ts create mode 100644 src/modules/feature-flags/dto/feature-flag.dto.ts create mode 100644 src/modules/feature-flags/dto/index.ts create mode 100644 src/modules/feature-flags/entities/flag.entity.ts create mode 100644 src/modules/feature-flags/entities/index.ts create mode 100644 src/modules/feature-flags/entities/tenant-override.entity.ts create mode 100644 src/modules/feature-flags/feature-flags.module.ts create mode 100644 src/modules/feature-flags/index.ts create mode 100644 src/modules/feature-flags/services/feature-flags.service.ts create mode 100644 src/modules/feature-flags/services/index.ts create mode 100644 src/modules/inventory/controllers/index.ts create mode 100644 src/modules/inventory/controllers/inventory.controller.ts create mode 100644 src/modules/inventory/dto/create-inventory.dto.ts create mode 100644 src/modules/inventory/dto/index.ts create mode 100644 src/modules/inventory/entities/inventory-count-line.entity.ts create mode 100644 src/modules/inventory/entities/inventory-count.entity.ts create mode 100644 src/modules/inventory/entities/stock-level.entity.ts create mode 100644 src/modules/inventory/entities/stock-movement.entity.ts create mode 100644 src/modules/inventory/entities/transfer-order-line.entity.ts create mode 100644 src/modules/inventory/entities/transfer-order.entity.ts create mode 100644 src/modules/inventory/inventory.module.ts create mode 100644 src/modules/inventory/services/index.ts create mode 100644 src/modules/inventory/services/inventory.service.ts create mode 100644 src/modules/invoices/controllers/index.ts create mode 100644 src/modules/invoices/dto/index.ts create mode 100644 src/modules/invoices/entities/index.ts create mode 100644 src/modules/invoices/entities/invoice-item.entity.ts create mode 100644 src/modules/invoices/entities/invoice.entity.ts create mode 100644 src/modules/invoices/entities/payment-allocation.entity.ts create mode 100644 src/modules/invoices/entities/payment.entity.ts create mode 100644 src/modules/invoices/index.ts create mode 100644 src/modules/invoices/invoices.module.ts create mode 100644 src/modules/invoices/services/index.ts create mode 100644 src/modules/mcp/controllers/index.ts create mode 100644 src/modules/mcp/controllers/mcp.controller.ts create mode 100644 src/modules/mcp/dto/index.ts create mode 100644 src/modules/mcp/dto/mcp.dto.ts create mode 100644 src/modules/mcp/entities/index.ts create mode 100644 src/modules/mcp/entities/tool-call-result.entity.ts create mode 100644 src/modules/mcp/entities/tool-call.entity.ts create mode 100644 src/modules/mcp/index.ts create mode 100644 src/modules/mcp/interfaces/index.ts create mode 100644 src/modules/mcp/interfaces/mcp-context.interface.ts create mode 100644 src/modules/mcp/interfaces/mcp-resource.interface.ts create mode 100644 src/modules/mcp/interfaces/mcp-tool.interface.ts create mode 100644 src/modules/mcp/mcp.module.ts create mode 100644 src/modules/mcp/services/index.ts create mode 100644 src/modules/mcp/services/mcp-server.service.ts create mode 100644 src/modules/mcp/services/tool-logger.service.ts create mode 100644 src/modules/mcp/services/tool-registry.service.ts create mode 100644 src/modules/mcp/tools/customers-tools.service.ts create mode 100644 src/modules/mcp/tools/fiados-tools.service.ts create mode 100644 src/modules/mcp/tools/index.ts create mode 100644 src/modules/mcp/tools/inventory-tools.service.ts create mode 100644 src/modules/mcp/tools/orders-tools.service.ts create mode 100644 src/modules/mcp/tools/products-tools.service.ts create mode 100644 src/modules/mobile/entities/index.ts create mode 100644 src/modules/mobile/entities/mobile-session.entity.ts create mode 100644 src/modules/mobile/entities/offline-sync-queue.entity.ts create mode 100644 src/modules/mobile/entities/payment-transaction.entity.ts create mode 100644 src/modules/mobile/entities/push-notification-log.entity.ts create mode 100644 src/modules/mobile/entities/push-token.entity.ts create mode 100644 src/modules/mobile/entities/sync-conflict.entity.ts create mode 100644 src/modules/notifications/controllers/index.ts create mode 100644 src/modules/notifications/controllers/notifications.controller.ts create mode 100644 src/modules/notifications/dto/index.ts create mode 100644 src/modules/notifications/dto/notification.dto.ts create mode 100644 src/modules/notifications/entities/channel.entity.ts create mode 100644 src/modules/notifications/entities/in-app-notification.entity.ts create mode 100644 src/modules/notifications/entities/index.ts create mode 100644 src/modules/notifications/entities/notification-batch.entity.ts create mode 100644 src/modules/notifications/entities/notification.entity.ts create mode 100644 src/modules/notifications/entities/preference.entity.ts create mode 100644 src/modules/notifications/entities/template.entity.ts create mode 100644 src/modules/notifications/index.ts create mode 100644 src/modules/notifications/notifications.module.ts create mode 100644 src/modules/notifications/services/index.ts create mode 100644 src/modules/notifications/services/notifications.service.ts create mode 100644 src/modules/partners/controllers/index.ts create mode 100644 src/modules/partners/controllers/partners.controller.ts create mode 100644 src/modules/partners/dto/create-partner.dto.ts create mode 100644 src/modules/partners/dto/index.ts create mode 100644 src/modules/partners/entities/partner-address.entity.ts create mode 100644 src/modules/partners/entities/partner-bank-account.entity.ts create mode 100644 src/modules/partners/entities/partner-contact.entity.ts create mode 100644 src/modules/partners/partners.module.ts create mode 100644 src/modules/partners/services/index.ts create mode 100644 src/modules/partners/services/partners.service.ts create mode 100644 src/modules/payment-terminals/controllers/index.ts create mode 100644 src/modules/payment-terminals/controllers/terminals.controller.ts create mode 100644 src/modules/payment-terminals/controllers/transactions.controller.ts create mode 100644 src/modules/payment-terminals/dto/index.ts create mode 100644 src/modules/payment-terminals/dto/terminal.dto.ts create mode 100644 src/modules/payment-terminals/dto/transaction.dto.ts create mode 100644 src/modules/payment-terminals/index.ts create mode 100644 src/modules/payment-terminals/payment-terminals.module.ts create mode 100644 src/modules/payment-terminals/services/index.ts create mode 100644 src/modules/payment-terminals/services/terminals.service.ts create mode 100644 src/modules/payment-terminals/services/transactions.service.ts create mode 100644 src/modules/products/controllers/index.ts create mode 100644 src/modules/products/controllers/products.controller.ts create mode 100644 src/modules/products/dto/create-product.dto.ts create mode 100644 src/modules/products/dto/index.ts create mode 100644 src/modules/products/entities/index.ts create mode 100644 src/modules/products/entities/product-category.entity.ts create mode 100644 src/modules/products/entities/product-price.entity.ts create mode 100644 src/modules/products/entities/product-supplier.entity.ts create mode 100644 src/modules/products/entities/product.entity.ts create mode 100644 src/modules/products/index.ts create mode 100644 src/modules/products/products.module.ts create mode 100644 src/modules/products/services/index.ts create mode 100644 src/modules/products/services/products.service.ts create mode 100644 src/modules/profiles/controllers/index.ts create mode 100644 src/modules/profiles/controllers/persons.controller.ts create mode 100644 src/modules/profiles/controllers/profiles.controller.ts create mode 100644 src/modules/profiles/dto/create-person.dto.ts create mode 100644 src/modules/profiles/dto/create-profile.dto.ts create mode 100644 src/modules/profiles/dto/index.ts create mode 100644 src/modules/profiles/entities/index.ts create mode 100644 src/modules/profiles/entities/person.entity.ts create mode 100644 src/modules/profiles/entities/profile-module.entity.ts create mode 100644 src/modules/profiles/entities/profile-tool.entity.ts create mode 100644 src/modules/profiles/entities/user-profile-assignment.entity.ts create mode 100644 src/modules/profiles/entities/user-profile.entity.ts create mode 100644 src/modules/profiles/index.ts create mode 100644 src/modules/profiles/profiles.module.ts create mode 100644 src/modules/profiles/services/index.ts create mode 100644 src/modules/profiles/services/persons.service.ts create mode 100644 src/modules/profiles/services/profiles.service.ts create mode 100644 src/modules/purchases/controllers/index.ts create mode 100644 src/modules/purchases/dto/index.ts create mode 100644 src/modules/purchases/entities/index.ts create mode 100644 src/modules/purchases/entities/purchase-order-item.entity.ts create mode 100644 src/modules/purchases/entities/purchase-order.entity.ts create mode 100644 src/modules/purchases/entities/purchase-receipt-item.entity.ts create mode 100644 src/modules/purchases/entities/purchase-receipt.entity.ts create mode 100644 src/modules/purchases/purchases.module.ts create mode 100644 src/modules/purchases/services/index.ts create mode 100644 src/modules/reports/controllers/index.ts create mode 100644 src/modules/reports/reports.module.ts create mode 100644 src/modules/reports/services/index.ts create mode 100644 src/modules/sales/controllers/index.ts create mode 100644 src/modules/sales/dto/index.ts create mode 100644 src/modules/sales/entities/index.ts create mode 100644 src/modules/sales/entities/quotation-item.entity.ts create mode 100644 src/modules/sales/entities/quotation.entity.ts create mode 100644 src/modules/sales/entities/sales-order-item.entity.ts create mode 100644 src/modules/sales/entities/sales-order.entity.ts create mode 100644 src/modules/sales/sales.module.ts create mode 100644 src/modules/sales/services/index.ts create mode 100644 src/modules/storage/controllers/index.ts create mode 100644 src/modules/storage/controllers/storage.controller.ts create mode 100644 src/modules/storage/dto/index.ts create mode 100644 src/modules/storage/dto/storage.dto.ts create mode 100644 src/modules/storage/entities/bucket.entity.ts create mode 100644 src/modules/storage/entities/file-access-token.entity.ts create mode 100644 src/modules/storage/entities/file-share.entity.ts create mode 100644 src/modules/storage/entities/file.entity.ts create mode 100644 src/modules/storage/entities/folder.entity.ts create mode 100644 src/modules/storage/entities/index.ts create mode 100644 src/modules/storage/entities/tenant-usage.entity.ts create mode 100644 src/modules/storage/entities/upload.entity.ts create mode 100644 src/modules/storage/index.ts create mode 100644 src/modules/storage/services/index.ts create mode 100644 src/modules/storage/services/storage.service.ts create mode 100644 src/modules/storage/storage.module.ts create mode 100644 src/modules/warehouses/controllers/index.ts create mode 100644 src/modules/warehouses/controllers/warehouses.controller.ts create mode 100644 src/modules/warehouses/dto/create-warehouse.dto.ts create mode 100644 src/modules/warehouses/dto/index.ts create mode 100644 src/modules/warehouses/entities/index.ts create mode 100644 src/modules/warehouses/entities/warehouse-location.entity.ts create mode 100644 src/modules/warehouses/entities/warehouse-zone.entity.ts create mode 100644 src/modules/warehouses/entities/warehouse.entity.ts create mode 100644 src/modules/warehouses/index.ts create mode 100644 src/modules/warehouses/services/index.ts create mode 100644 src/modules/warehouses/services/warehouses.service.ts create mode 100644 src/modules/warehouses/warehouses.module.ts create mode 100644 src/modules/webhooks/controllers/index.ts create mode 100644 src/modules/webhooks/controllers/webhooks.controller.ts create mode 100644 src/modules/webhooks/dto/index.ts create mode 100644 src/modules/webhooks/dto/webhook.dto.ts create mode 100644 src/modules/webhooks/entities/delivery.entity.ts create mode 100644 src/modules/webhooks/entities/endpoint-log.entity.ts create mode 100644 src/modules/webhooks/entities/endpoint.entity.ts create mode 100644 src/modules/webhooks/entities/event-type.entity.ts create mode 100644 src/modules/webhooks/entities/event.entity.ts create mode 100644 src/modules/webhooks/entities/index.ts create mode 100644 src/modules/webhooks/entities/subscription.entity.ts create mode 100644 src/modules/webhooks/index.ts create mode 100644 src/modules/webhooks/services/index.ts create mode 100644 src/modules/webhooks/services/webhooks.service.ts create mode 100644 src/modules/webhooks/webhooks.module.ts create mode 100644 src/modules/whatsapp/controllers/index.ts create mode 100644 src/modules/whatsapp/controllers/whatsapp.controller.ts create mode 100644 src/modules/whatsapp/dto/index.ts create mode 100644 src/modules/whatsapp/dto/whatsapp.dto.ts create mode 100644 src/modules/whatsapp/entities/account.entity.ts create mode 100644 src/modules/whatsapp/entities/automation.entity.ts create mode 100644 src/modules/whatsapp/entities/broadcast-recipient.entity.ts create mode 100644 src/modules/whatsapp/entities/broadcast.entity.ts create mode 100644 src/modules/whatsapp/entities/contact.entity.ts create mode 100644 src/modules/whatsapp/entities/conversation.entity.ts create mode 100644 src/modules/whatsapp/entities/index.ts create mode 100644 src/modules/whatsapp/entities/message-status-update.entity.ts create mode 100644 src/modules/whatsapp/entities/message.entity.ts create mode 100644 src/modules/whatsapp/entities/quick-reply.entity.ts create mode 100644 src/modules/whatsapp/entities/template.entity.ts create mode 100644 src/modules/whatsapp/index.ts create mode 100644 src/modules/whatsapp/services/index.ts create mode 100644 src/modules/whatsapp/services/whatsapp.service.ts create mode 100644 src/modules/whatsapp/whatsapp.module.ts create mode 100644 src/shared/services/feature-flags.service.ts create mode 100644 src/shared/utils/circuit-breaker.ts create mode 100644 src/shared/utils/index.ts diff --git a/src/app.integration.ts b/src/app.integration.ts new file mode 100644 index 0000000..e48face --- /dev/null +++ b/src/app.integration.ts @@ -0,0 +1,503 @@ +/** + * Application Integration + * + * Integrates all modules and configures the application + */ + +import express, { Express, Router } from 'express'; +import { DataSource } from 'typeorm'; + +// Import modules +import { ProfilesModule } from './modules/profiles'; +import { BranchesModule } from './modules/branches'; +import { BillingUsageModule } from './modules/billing-usage'; +import { PaymentTerminalsModule } from './modules/payment-terminals'; + +// Import new business modules +import { PartnersModule } from './modules/partners'; +import { ProductsModule } from './modules/products'; +import { WarehousesModule } from './modules/warehouses'; +import { InventoryModule } from './modules/inventory'; +import { SalesModule } from './modules/sales'; +import { PurchasesModule } from './modules/purchases'; +import { InvoicesModule } from './modules/invoices'; +import { ReportsModule } from './modules/reports'; +import { DashboardModule } from './modules/dashboard'; + +// Import entities from all modules for TypeORM +import { + Person, + UserProfile, + ProfileTool, + ProfileModule, + UserProfileAssignment, +} from './modules/profiles/entities'; + +import { + Device, + BiometricCredential, + DeviceSession, + DeviceActivityLog, +} from './modules/biometrics/entities'; + +import { + Branch, + UserBranchAssignment, + BranchSchedule, + BranchPaymentTerminal, +} from './modules/branches/entities'; + +import { + MobileSession, + OfflineSyncQueue, + PushToken, + PaymentTransaction, +} from './modules/mobile/entities'; + +import { + SubscriptionPlan, + TenantSubscription, + UsageTracking, + Invoice as BillingInvoice, + InvoiceItem as BillingInvoiceItem, +} from './modules/billing-usage/entities'; + +// Import entities from new business modules +import { + Partner, + PartnerAddress, + PartnerContact, + PartnerBankAccount, +} from './modules/partners/entities'; + +import { + ProductCategory, + Product, + ProductPrice, + ProductSupplier, +} from './modules/products/entities'; + +import { + Warehouse, + WarehouseLocation, + WarehouseZone, +} from './modules/warehouses/entities'; + +import { + StockLevel, + StockMovement, + InventoryCount, + InventoryCountLine, + TransferOrder, + TransferOrderLine, +} from './modules/inventory/entities'; + +import { + Quotation, + QuotationItem, + SalesOrder, + SalesOrderItem, +} from './modules/sales/entities'; + +import { + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, +} from './modules/purchases/entities'; + +import { + Invoice, + InvoiceItem, + Payment, + PaymentAllocation, +} from './modules/invoices/entities'; + +/** + * Get all entities for TypeORM configuration + */ +export function getAllEntities() { + return [ + // Profiles + Person, + UserProfile, + ProfileTool, + ProfileModule, + UserProfileAssignment, + // Biometrics + Device, + BiometricCredential, + DeviceSession, + DeviceActivityLog, + // Branches + Branch, + UserBranchAssignment, + BranchSchedule, + BranchPaymentTerminal, + // Mobile + MobileSession, + OfflineSyncQueue, + PushToken, + PaymentTransaction, + // Billing + SubscriptionPlan, + TenantSubscription, + UsageTracking, + BillingInvoice, + BillingInvoiceItem, + // Partners + Partner, + PartnerAddress, + PartnerContact, + PartnerBankAccount, + // Products + ProductCategory, + Product, + ProductPrice, + ProductSupplier, + // Warehouses + Warehouse, + WarehouseLocation, + WarehouseZone, + // Inventory + StockLevel, + StockMovement, + InventoryCount, + InventoryCountLine, + TransferOrder, + TransferOrderLine, + // Sales + Quotation, + QuotationItem, + SalesOrder, + SalesOrderItem, + // Purchases + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + // Invoices + Invoice, + InvoiceItem, + Payment, + PaymentAllocation, + ]; +} + +/** + * Module configuration options + */ +export interface ModuleOptions { + profiles?: { + enabled: boolean; + basePath?: string; + }; + branches?: { + enabled: boolean; + basePath?: string; + }; + billing?: { + enabled: boolean; + basePath?: string; + }; + payments?: { + enabled: boolean; + basePath?: string; + }; + partners?: { + enabled: boolean; + basePath?: string; + }; + products?: { + enabled: boolean; + basePath?: string; + }; + warehouses?: { + enabled: boolean; + basePath?: string; + }; + inventory?: { + enabled: boolean; + basePath?: string; + }; + sales?: { + enabled: boolean; + basePath?: string; + }; + purchases?: { + enabled: boolean; + basePath?: string; + }; + invoices?: { + enabled: boolean; + basePath?: string; + }; + reports?: { + enabled: boolean; + basePath?: string; + }; + dashboard?: { + enabled: boolean; + basePath?: string; + }; +} + +/** + * Default module options + */ +const defaultModuleOptions: ModuleOptions = { + profiles: { enabled: true, basePath: '/api' }, + branches: { enabled: true, basePath: '/api' }, + billing: { enabled: true, basePath: '/api' }, + payments: { enabled: true, basePath: '/api' }, + partners: { enabled: true, basePath: '/api' }, + products: { enabled: true, basePath: '/api' }, + warehouses: { enabled: true, basePath: '/api' }, + inventory: { enabled: true, basePath: '/api' }, + sales: { enabled: true, basePath: '/api' }, + purchases: { enabled: true, basePath: '/api' }, + invoices: { enabled: true, basePath: '/api' }, + reports: { enabled: true, basePath: '/api' }, + dashboard: { enabled: true, basePath: '/api' }, +}; + +/** + * Initialize and integrate all modules + */ +export function initializeModules( + app: Express, + dataSource: DataSource, + options: ModuleOptions = {} +): void { + const config = { ...defaultModuleOptions, ...options }; + + // Initialize Profiles Module + if (config.profiles?.enabled) { + const profilesModule = new ProfilesModule({ + dataSource, + basePath: config.profiles.basePath, + }); + app.use(profilesModule.router); + console.log('✅ Profiles module initialized'); + } + + // Initialize Branches Module + if (config.branches?.enabled) { + const branchesModule = new BranchesModule({ + dataSource, + basePath: config.branches.basePath, + }); + app.use(branchesModule.router); + console.log('✅ Branches module initialized'); + } + + // Initialize Billing Module + if (config.billing?.enabled) { + const billingModule = new BillingUsageModule({ + dataSource, + basePath: config.billing.basePath, + }); + app.use(billingModule.router); + console.log('✅ Billing module initialized'); + } + + // Initialize Payment Terminals Module + if (config.payments?.enabled) { + const paymentModule = new PaymentTerminalsModule({ + dataSource, + basePath: config.payments.basePath, + }); + app.use(paymentModule.router); + console.log('✅ Payment Terminals module initialized'); + } + + // Initialize Partners Module + if (config.partners?.enabled) { + const partnersModule = new PartnersModule({ + dataSource, + basePath: config.partners.basePath, + }); + app.use(partnersModule.router); + console.log('✅ Partners module initialized'); + } + + // Initialize Products Module + if (config.products?.enabled) { + const productsModule = new ProductsModule({ + dataSource, + basePath: config.products.basePath, + }); + app.use(productsModule.router); + console.log('✅ Products module initialized'); + } + + // Initialize Warehouses Module + if (config.warehouses?.enabled) { + const warehousesModule = new WarehousesModule({ + dataSource, + basePath: config.warehouses.basePath, + }); + app.use(warehousesModule.router); + console.log('✅ Warehouses module initialized'); + } + + // Initialize Inventory Module + if (config.inventory?.enabled) { + const inventoryModule = new InventoryModule({ + dataSource, + basePath: config.inventory.basePath, + }); + app.use(inventoryModule.router); + console.log('✅ Inventory module initialized'); + } + + // Initialize Sales Module + if (config.sales?.enabled) { + const salesModule = new SalesModule({ + dataSource, + basePath: config.sales.basePath, + }); + app.use(salesModule.router); + console.log('✅ Sales module initialized'); + } + + // Initialize Purchases Module + if (config.purchases?.enabled) { + const purchasesModule = new PurchasesModule({ + dataSource, + basePath: config.purchases.basePath, + }); + app.use(purchasesModule.router); + console.log('✅ Purchases module initialized'); + } + + // Initialize Invoices Module + if (config.invoices?.enabled) { + const invoicesModule = new InvoicesModule({ + dataSource, + basePath: config.invoices.basePath, + }); + app.use(invoicesModule.router); + console.log('✅ Invoices module initialized'); + } + + // Initialize Reports Module + if (config.reports?.enabled) { + const reportsModule = new ReportsModule({ + dataSource, + basePath: config.reports.basePath, + }); + app.use(reportsModule.router); + console.log('✅ Reports module initialized'); + } + + // Initialize Dashboard Module + if (config.dashboard?.enabled) { + const dashboardModule = new DashboardModule({ + dataSource, + basePath: config.dashboard.basePath, + }); + app.use(dashboardModule.router); + console.log('✅ Dashboard module initialized'); + } +} + +/** + * Create TypeORM DataSource configuration + */ +export function createDataSourceConfig(options: { + host: string; + port: number; + username: string; + password: string; + database: string; + ssl?: boolean; + logging?: boolean; +}) { + return { + type: 'postgres' as const, + host: options.host, + port: options.port, + username: options.username, + password: options.password, + database: options.database, + ssl: options.ssl ? { rejectUnauthorized: false } : false, + logging: options.logging ?? false, + entities: getAllEntities(), + synchronize: false, // Use migrations instead + migrations: ['src/migrations/*.ts'], + }; +} + +/** + * Example application setup + */ +export async function createApplication(dataSourceConfig: any): Promise { + // Create Express app + const app = express(); + + // Middleware + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // CORS middleware (configure for production) + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Tenant-ID'); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + next(); + }); + + // Initialize database + const dataSource = new DataSource(dataSourceConfig); + await dataSource.initialize(); + console.log('✅ Database connected'); + + // Initialize all modules + initializeModules(app, dataSource); + + // Health check endpoint + app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + modules: { + profiles: true, + branches: true, + billing: true, + payments: true, + partners: true, + products: true, + warehouses: true, + inventory: true, + sales: true, + purchases: true, + invoices: true, + reports: true, + dashboard: true, + }, + }); + }); + + // Error handling middleware + app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error('Error:', err); + res.status(err.status || 500).json({ + error: err.message || 'Internal Server Error', + code: err.code || 'INTERNAL_ERROR', + }); + }); + + return app; +} + +export default { + getAllEntities, + initializeModules, + createDataSourceConfig, + createApplication, +}; diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..c7083dd --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -0,0 +1,66 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { AIService } from './services'; +import { AIController } from './controllers'; +import { + AIModel, + AIPrompt, + AIConversation, + AIMessage, + AIUsageLog, + AITenantQuota, +} from './entities'; + +export interface AIModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class AIModule { + public router: Router; + public aiService: AIService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: AIModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const modelRepository = this.dataSource.getRepository(AIModel); + const conversationRepository = this.dataSource.getRepository(AIConversation); + const messageRepository = this.dataSource.getRepository(AIMessage); + const promptRepository = this.dataSource.getRepository(AIPrompt); + const usageLogRepository = this.dataSource.getRepository(AIUsageLog); + const quotaRepository = this.dataSource.getRepository(AITenantQuota); + + this.aiService = new AIService( + modelRepository, + conversationRepository, + messageRepository, + promptRepository, + usageLogRepository, + quotaRepository + ); + } + + private initializeRoutes(): void { + const aiController = new AIController(this.aiService); + this.router.use(`${this.basePath}/ai`, aiController.router); + } + + static getEntities(): Function[] { + return [ + AIModel, + AIPrompt, + AIConversation, + AIMessage, + AIUsageLog, + AITenantQuota, + ]; + } +} diff --git a/src/modules/ai/controllers/ai.controller.ts b/src/modules/ai/controllers/ai.controller.ts new file mode 100644 index 0000000..3d126cf --- /dev/null +++ b/src/modules/ai/controllers/ai.controller.ts @@ -0,0 +1,381 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { AIService, ConversationFilters } from '../services/ai.service'; + +export class AIController { + public router: Router; + + constructor(private readonly aiService: AIService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Models + this.router.get('/models', this.findAllModels.bind(this)); + this.router.get('/models/:id', this.findModel.bind(this)); + this.router.get('/models/code/:code', this.findModelByCode.bind(this)); + this.router.get('/models/provider/:provider', this.findModelsByProvider.bind(this)); + this.router.get('/models/type/:type', this.findModelsByType.bind(this)); + + // Prompts + this.router.get('/prompts', this.findAllPrompts.bind(this)); + this.router.get('/prompts/:id', this.findPrompt.bind(this)); + this.router.get('/prompts/code/:code', this.findPromptByCode.bind(this)); + this.router.post('/prompts', this.createPrompt.bind(this)); + this.router.patch('/prompts/:id', this.updatePrompt.bind(this)); + + // Conversations + this.router.get('/conversations', this.findConversations.bind(this)); + this.router.get('/conversations/user/:userId', this.findUserConversations.bind(this)); + this.router.get('/conversations/:id', this.findConversation.bind(this)); + this.router.post('/conversations', this.createConversation.bind(this)); + this.router.patch('/conversations/:id', this.updateConversation.bind(this)); + this.router.post('/conversations/:id/archive', this.archiveConversation.bind(this)); + + // Messages + this.router.get('/conversations/:conversationId/messages', this.findMessages.bind(this)); + this.router.post('/conversations/:conversationId/messages', this.addMessage.bind(this)); + this.router.get('/conversations/:conversationId/tokens', this.getConversationTokenCount.bind(this)); + + // Usage & Quotas + this.router.post('/usage', this.logUsage.bind(this)); + this.router.get('/usage/stats', this.getUsageStats.bind(this)); + this.router.get('/quotas', this.getTenantQuota.bind(this)); + this.router.patch('/quotas', this.updateTenantQuota.bind(this)); + this.router.get('/quotas/check', this.checkQuotaAvailable.bind(this)); + } + + // ============================================ + // MODELS + // ============================================ + + private async findAllModels(req: Request, res: Response, next: NextFunction): Promise { + try { + const models = await this.aiService.findAllModels(); + res.json({ data: models, total: models.length }); + } catch (error) { + next(error); + } + } + + private async findModel(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const model = await this.aiService.findModel(id); + + if (!model) { + res.status(404).json({ error: 'Model not found' }); + return; + } + + res.json({ data: model }); + } catch (error) { + next(error); + } + } + + private async findModelByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const model = await this.aiService.findModelByCode(code); + + if (!model) { + res.status(404).json({ error: 'Model not found' }); + return; + } + + res.json({ data: model }); + } catch (error) { + next(error); + } + } + + private async findModelsByProvider(req: Request, res: Response, next: NextFunction): Promise { + try { + const { provider } = req.params; + const models = await this.aiService.findModelsByProvider(provider); + res.json({ data: models, total: models.length }); + } catch (error) { + next(error); + } + } + + private async findModelsByType(req: Request, res: Response, next: NextFunction): Promise { + try { + const { type } = req.params; + const models = await this.aiService.findModelsByType(type); + res.json({ data: models, total: models.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PROMPTS + // ============================================ + + private async findAllPrompts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const prompts = await this.aiService.findAllPrompts(tenantId); + res.json({ data: prompts, total: prompts.length }); + } catch (error) { + next(error); + } + } + + private async findPrompt(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const prompt = await this.aiService.findPrompt(id); + + if (!prompt) { + res.status(404).json({ error: 'Prompt not found' }); + return; + } + + res.json({ data: prompt }); + } catch (error) { + next(error); + } + } + + private async findPromptByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const prompt = await this.aiService.findPromptByCode(code, tenantId); + + if (!prompt) { + res.status(404).json({ error: 'Prompt not found' }); + return; + } + + // Increment usage count + await this.aiService.incrementPromptUsage(prompt.id); + + res.json({ data: prompt }); + } catch (error) { + next(error); + } + } + + private async createPrompt(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const prompt = await this.aiService.createPrompt(tenantId, req.body, userId); + res.status(201).json({ data: prompt }); + } catch (error) { + next(error); + } + } + + private async updatePrompt(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + + const prompt = await this.aiService.updatePrompt(id, req.body, userId); + + if (!prompt) { + res.status(404).json({ error: 'Prompt not found' }); + return; + } + + res.json({ data: prompt }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONVERSATIONS + // ============================================ + + private async findConversations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: ConversationFilters = { + userId: req.query.userId as string, + modelId: req.query.modelId as string, + status: req.query.status as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const limit = parseInt(req.query.limit as string) || 50; + + const conversations = await this.aiService.findConversations(tenantId, filters, limit); + res.json({ data: conversations, total: conversations.length }); + } catch (error) { + next(error); + } + } + + private async findUserConversations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + + const conversations = await this.aiService.findUserConversations(tenantId, userId, limit); + res.json({ data: conversations, total: conversations.length }); + } catch (error) { + next(error); + } + } + + private async findConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const conversation = await this.aiService.findConversation(id); + + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + res.json({ data: conversation }); + } catch (error) { + next(error); + } + } + + private async createConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const conversation = await this.aiService.createConversation(tenantId, userId, req.body); + res.status(201).json({ data: conversation }); + } catch (error) { + next(error); + } + } + + private async updateConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const conversation = await this.aiService.updateConversation(id, req.body); + + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + res.json({ data: conversation }); + } catch (error) { + next(error); + } + } + + private async archiveConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const archived = await this.aiService.archiveConversation(id); + + if (!archived) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MESSAGES + // ============================================ + + private async findMessages(req: Request, res: Response, next: NextFunction): Promise { + try { + const { conversationId } = req.params; + const messages = await this.aiService.findMessages(conversationId); + res.json({ data: messages, total: messages.length }); + } catch (error) { + next(error); + } + } + + private async addMessage(req: Request, res: Response, next: NextFunction): Promise { + try { + const { conversationId } = req.params; + const message = await this.aiService.addMessage(conversationId, req.body); + res.status(201).json({ data: message }); + } catch (error) { + next(error); + } + } + + private async getConversationTokenCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { conversationId } = req.params; + const tokenCount = await this.aiService.getConversationTokenCount(conversationId); + res.json({ data: { tokenCount } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // USAGE & QUOTAS + // ============================================ + + private async logUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const log = await this.aiService.logUsage(tenantId, req.body); + res.status(201).json({ data: log }); + } catch (error) { + next(error); + } + } + + private async getUsageStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await this.aiService.getUsageStats(tenantId, startDate, endDate); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + private async getTenantQuota(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const quota = await this.aiService.getTenantQuota(tenantId); + res.json({ data: quota }); + } catch (error) { + next(error); + } + } + + private async updateTenantQuota(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const quota = await this.aiService.updateTenantQuota(tenantId, req.body); + res.json({ data: quota }); + } catch (error) { + next(error); + } + } + + private async checkQuotaAvailable(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const result = await this.aiService.checkQuotaAvailable(tenantId); + res.json({ data: result }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/ai/controllers/index.ts b/src/modules/ai/controllers/index.ts new file mode 100644 index 0000000..cf85729 --- /dev/null +++ b/src/modules/ai/controllers/index.ts @@ -0,0 +1 @@ +export { AIController } from './ai.controller'; diff --git a/src/modules/ai/dto/ai.dto.ts b/src/modules/ai/dto/ai.dto.ts new file mode 100644 index 0000000..39daa77 --- /dev/null +++ b/src/modules/ai/dto/ai.dto.ts @@ -0,0 +1,343 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + MaxLength, + MinLength, + Min, + Max, +} from 'class-validator'; + +// ============================================ +// PROMPT DTOs +// ============================================ + +export class CreatePromptDto { + @IsString() + @MinLength(2) + @MaxLength(50) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsString() + systemPrompt: string; + + @IsOptional() + @IsString() + userPromptTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + stopSequences?: string[]; + + @IsOptional() + @IsObject() + modelParameters?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedModels?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdatePromptDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + systemPrompt?: string; + + @IsOptional() + @IsString() + userPromptTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + stopSequences?: string[]; + + @IsOptional() + @IsObject() + modelParameters?: Record; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +// ============================================ +// CONVERSATION DTOs +// ============================================ + +export class CreateConversationDto { + @IsOptional() + @IsUUID() + modelId?: string; + + @IsOptional() + @IsUUID() + promptId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + systemPrompt?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsObject() + context?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateConversationDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + systemPrompt?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsObject() + context?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// MESSAGE DTOs +// ============================================ + +export class AddMessageDto { + @IsString() + @MaxLength(20) + role: string; + + @IsString() + content: string; + + @IsOptional() + @IsString() + @MaxLength(50) + modelCode?: string; + + @IsOptional() + @IsNumber() + @Min(0) + promptTokens?: number; + + @IsOptional() + @IsNumber() + @Min(0) + completionTokens?: number; + + @IsOptional() + @IsNumber() + @Min(0) + totalTokens?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + finishReason?: string; + + @IsOptional() + @IsNumber() + @Min(0) + latencyMs?: number; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// USAGE DTOs +// ============================================ + +export class LogUsageDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + conversationId?: string; + + @IsUUID() + modelId: string; + + @IsString() + @MaxLength(20) + usageType: string; + + @IsNumber() + @Min(0) + inputTokens: number; + + @IsNumber() + @Min(0) + outputTokens: number; + + @IsOptional() + @IsNumber() + @Min(0) + costUsd?: number; + + @IsOptional() + @IsNumber() + @Min(0) + latencyMs?: number; + + @IsOptional() + @IsBoolean() + wasSuccessful?: boolean; + + @IsOptional() + @IsString() + errorMessage?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// QUOTA DTOs +// ============================================ + +export class UpdateQuotaDto { + @IsOptional() + @IsNumber() + @Min(0) + maxRequestsPerMonth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxTokensPerMonth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxSpendPerMonth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxRequestsPerDay?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxTokensPerDay?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedModels?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + blockedModels?: string[]; +} diff --git a/src/modules/ai/dto/index.ts b/src/modules/ai/dto/index.ts new file mode 100644 index 0000000..65584c6 --- /dev/null +++ b/src/modules/ai/dto/index.ts @@ -0,0 +1,9 @@ +export { + CreatePromptDto, + UpdatePromptDto, + CreateConversationDto, + UpdateConversationDto, + AddMessageDto, + LogUsageDto, + UpdateQuotaDto, +} from './ai.dto'; diff --git a/src/modules/ai/entities/completion.entity.ts b/src/modules/ai/entities/completion.entity.ts new file mode 100644 index 0000000..6c0e712 --- /dev/null +++ b/src/modules/ai/entities/completion.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { AIModel } from './model.entity'; +import { AIPrompt } from './prompt.entity'; + +export type CompletionStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'completions', schema: 'ai' }) +export class AICompletion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'prompt_id', type: 'uuid', nullable: true }) + promptId: string; + + @Column({ name: 'prompt_code', type: 'varchar', length: 100, nullable: true }) + promptCode: string; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'input_text', type: 'text' }) + inputText: string; + + @Column({ name: 'input_variables', type: 'jsonb', default: {} }) + inputVariables: Record; + + @Column({ name: 'output_text', type: 'text', nullable: true }) + outputText: string; + + @Column({ name: 'prompt_tokens', type: 'int', nullable: true }) + promptTokens: number; + + @Column({ name: 'completion_tokens', type: 'int', nullable: true }) + completionTokens: number; + + @Column({ name: 'total_tokens', type: 'int', nullable: true }) + totalTokens: number; + + @Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true }) + cost: number; + + @Column({ name: 'latency_ms', type: 'int', nullable: true }) + latencyMs: number; + + @Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true }) + finishReason: string; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: CompletionStatus; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => AIModel, { nullable: true }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; + + @ManyToOne(() => AIPrompt, { nullable: true }) + @JoinColumn({ name: 'prompt_id' }) + prompt: AIPrompt; +} diff --git a/src/modules/ai/entities/conversation.entity.ts b/src/modules/ai/entities/conversation.entity.ts new file mode 100644 index 0000000..636d2a8 --- /dev/null +++ b/src/modules/ai/entities/conversation.entity.ts @@ -0,0 +1,160 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { AIModel } from './model.entity'; + +export type ConversationStatus = 'active' | 'archived' | 'deleted'; +export type MessageRole = 'system' | 'user' | 'assistant' | 'function'; +export type FinishReason = 'stop' | 'length' | 'function_call' | 'content_filter'; + +@Entity({ name: 'conversations', schema: 'ai' }) +export class AIConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'title', type: 'varchar', length: 255, nullable: true }) + title: string; + + @Column({ name: 'summary', type: 'text', nullable: true }) + summary: string; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_data', type: 'jsonb', default: {} }) + contextData: Record; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'prompt_id', type: 'uuid', nullable: true }) + promptId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'active' }) + status: ConversationStatus; + + @Column({ name: 'is_pinned', type: 'boolean', default: false }) + isPinned: boolean; + + @Column({ name: 'message_count', type: 'int', default: 0 }) + messageCount: number; + + @Column({ name: 'total_tokens', type: 'int', default: 0 }) + totalTokens: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 10, scale: 4, default: 0 }) + totalCost: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => AIModel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; + + @OneToMany(() => AIMessage, (message) => message.conversation) + messages: AIMessage[]; +} + +@Entity({ name: 'messages', schema: 'ai' }) +export class AIMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid' }) + conversationId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'role', type: 'varchar', length: 20 }) + role: MessageRole; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'function_name', type: 'varchar', length: 100, nullable: true }) + functionName: string; + + @Column({ name: 'function_arguments', type: 'jsonb', nullable: true }) + functionArguments: Record; + + @Column({ name: 'function_result', type: 'jsonb', nullable: true }) + functionResult: Record; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'model_response_id', type: 'varchar', length: 255, nullable: true }) + modelResponseId: string; + + @Column({ name: 'prompt_tokens', type: 'int', nullable: true }) + promptTokens: number; + + @Column({ name: 'completion_tokens', type: 'int', nullable: true }) + completionTokens: number; + + @Column({ name: 'total_tokens', type: 'int', nullable: true }) + totalTokens: number; + + @Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true }) + cost: number; + + @Column({ name: 'latency_ms', type: 'int', nullable: true }) + latencyMs: number; + + @Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true }) + finishReason: FinishReason; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'feedback_rating', type: 'int', nullable: true }) + feedbackRating: number; + + @Column({ name: 'feedback_text', type: 'text', nullable: true }) + feedbackText: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => AIConversation, (conversation) => conversation.messages, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'conversation_id' }) + conversation: AIConversation; + + @ManyToOne(() => AIModel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; +} diff --git a/src/modules/ai/entities/embedding.entity.ts b/src/modules/ai/entities/embedding.entity.ts new file mode 100644 index 0000000..4d30c99 --- /dev/null +++ b/src/modules/ai/entities/embedding.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { AIModel } from './model.entity'; + +@Entity({ name: 'embeddings', schema: 'ai' }) +export class AIEmbedding { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Index() + @Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true }) + contentHash: string; + + // Note: If pgvector is enabled, use 'vector' type instead of 'jsonb' + @Column({ name: 'embedding_json', type: 'jsonb', nullable: true }) + embeddingJson: number[]; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true }) + modelName: string; + + @Column({ name: 'dimensions', type: 'int', nullable: true }) + dimensions: number; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'chunk_index', type: 'int', nullable: true }) + chunkIndex: number; + + @Column({ name: 'chunk_total', type: 'int', nullable: true }) + chunkTotal: number; + + @Column({ name: 'parent_embedding_id', type: 'uuid', nullable: true }) + parentEmbeddingId: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => AIModel, { nullable: true }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; + + @ManyToOne(() => AIEmbedding, { nullable: true }) + @JoinColumn({ name: 'parent_embedding_id' }) + parentEmbedding: AIEmbedding; +} diff --git a/src/modules/ai/entities/index.ts b/src/modules/ai/entities/index.ts new file mode 100644 index 0000000..8317b21 --- /dev/null +++ b/src/modules/ai/entities/index.ts @@ -0,0 +1,7 @@ +export { AIModel, AIProvider, ModelType } from './model.entity'; +export { AIConversation, AIMessage, ConversationStatus, MessageRole, FinishReason } from './conversation.entity'; +export { AIPrompt, PromptCategory } from './prompt.entity'; +export { AIUsageLog, AITenantQuota, UsageType } from './usage.entity'; +export { AICompletion, CompletionStatus } from './completion.entity'; +export { AIEmbedding } from './embedding.entity'; +export { AIKnowledgeBase, KnowledgeSourceType, KnowledgeContentType } from './knowledge-base.entity'; diff --git a/src/modules/ai/entities/knowledge-base.entity.ts b/src/modules/ai/entities/knowledge-base.entity.ts new file mode 100644 index 0000000..55e65ec --- /dev/null +++ b/src/modules/ai/entities/knowledge-base.entity.ts @@ -0,0 +1,98 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { AIEmbedding } from './embedding.entity'; + +export type KnowledgeSourceType = 'manual' | 'document' | 'website' | 'api'; +export type KnowledgeContentType = 'faq' | 'documentation' | 'policy' | 'procedure'; + +@Entity({ name: 'knowledge_base', schema: 'ai' }) +@Unique(['tenantId', 'code']) +export class AIKnowledgeBase { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'source_type', type: 'varchar', length: 30, nullable: true }) + sourceType: KnowledgeSourceType; + + @Column({ name: 'source_url', type: 'text', nullable: true }) + sourceUrl: string; + + @Column({ name: 'source_file_id', type: 'uuid', nullable: true }) + sourceFileId: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'content_type', type: 'varchar', length: 50, nullable: true }) + contentType: KnowledgeContentType; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 100, nullable: true }) + category: string; + + @Column({ name: 'subcategory', type: 'varchar', length: 100, nullable: true }) + subcategory: string; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'embedding_id', type: 'uuid', nullable: true }) + embeddingId: string; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'relevance_score', type: 'decimal', precision: 5, scale: 4, nullable: true }) + relevanceScore: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy: string; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => AIEmbedding, { nullable: true }) + @JoinColumn({ name: 'embedding_id' }) + embedding: AIEmbedding; +} diff --git a/src/modules/ai/entities/model.entity.ts b/src/modules/ai/entities/model.entity.ts new file mode 100644 index 0000000..893ea83 --- /dev/null +++ b/src/modules/ai/entities/model.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type AIProvider = 'openai' | 'anthropic' | 'google' | 'azure' | 'local'; +export type ModelType = 'chat' | 'completion' | 'embedding' | 'image' | 'audio'; + +@Entity({ name: 'models', schema: 'ai' }) +export class AIModel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'provider', type: 'varchar', length: 50 }) + provider: AIProvider; + + @Column({ name: 'model_id', type: 'varchar', length: 100 }) + modelId: string; + + @Index() + @Column({ name: 'model_type', type: 'varchar', length: 30 }) + modelType: ModelType; + + @Column({ name: 'max_tokens', type: 'int', nullable: true }) + maxTokens: number; + + @Column({ name: 'supports_functions', type: 'boolean', default: false }) + supportsFunctions: boolean; + + @Column({ name: 'supports_vision', type: 'boolean', default: false }) + supportsVision: boolean; + + @Column({ name: 'supports_streaming', type: 'boolean', default: true }) + supportsStreaming: boolean; + + @Column({ name: 'input_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true }) + inputCostPer1k: number; + + @Column({ name: 'output_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true }) + outputCostPer1k: number; + + @Column({ name: 'rate_limit_rpm', type: 'int', nullable: true }) + rateLimitRpm: number; + + @Column({ name: 'rate_limit_tpm', type: 'int', nullable: true }) + rateLimitTpm: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/ai/entities/prompt.entity.ts b/src/modules/ai/entities/prompt.entity.ts new file mode 100644 index 0000000..dfbaf57 --- /dev/null +++ b/src/modules/ai/entities/prompt.entity.ts @@ -0,0 +1,110 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { AIModel } from './model.entity'; + +export type PromptCategory = 'assistant' | 'analysis' | 'generation' | 'extraction'; + +@Entity({ name: 'prompts', schema: 'ai' }) +@Unique(['tenantId', 'code', 'version']) +export class AIPrompt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: PromptCategory; + + @Column({ name: 'system_prompt', type: 'text', nullable: true }) + systemPrompt: string; + + @Column({ name: 'user_prompt_template', type: 'text' }) + userPromptTemplate: string; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'temperature', type: 'decimal', precision: 3, scale: 2, default: 0.7 }) + temperature: number; + + @Column({ name: 'max_tokens', type: 'int', nullable: true }) + maxTokens: number; + + @Column({ name: 'top_p', type: 'decimal', precision: 3, scale: 2, nullable: true }) + topP: number; + + @Column({ name: 'frequency_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true }) + frequencyPenalty: number; + + @Column({ name: 'presence_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true }) + presencePenalty: number; + + @Column({ name: 'required_variables', type: 'text', array: true, default: [] }) + requiredVariables: string[]; + + @Column({ name: 'variable_schema', type: 'jsonb', default: {} }) + variableSchema: Record; + + @Column({ name: 'functions', type: 'jsonb', default: [] }) + functions: Record[]; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'is_latest', type: 'boolean', default: true }) + isLatest: boolean; + + @Column({ name: 'parent_version_id', type: 'uuid', nullable: true }) + parentVersionId: string; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'avg_tokens_used', type: 'int', nullable: true }) + avgTokensUsed: number; + + @Column({ name: 'avg_latency_ms', type: 'int', nullable: true }) + avgLatencyMs: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => AIModel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; +} diff --git a/src/modules/ai/entities/usage.entity.ts b/src/modules/ai/entities/usage.entity.ts new file mode 100644 index 0000000..42eaf3d --- /dev/null +++ b/src/modules/ai/entities/usage.entity.ts @@ -0,0 +1,120 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type UsageType = 'chat' | 'completion' | 'embedding' | 'image'; + +@Entity({ name: 'usage_logs', schema: 'ai' }) +export class AIUsageLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true }) + modelName: string; + + @Column({ name: 'provider', type: 'varchar', length: 50, nullable: true }) + provider: string; + + @Column({ name: 'usage_type', type: 'varchar', length: 30 }) + usageType: UsageType; + + @Column({ name: 'prompt_tokens', type: 'int', default: 0 }) + promptTokens: number; + + @Column({ name: 'completion_tokens', type: 'int', default: 0 }) + completionTokens: number; + + @Column({ name: 'total_tokens', type: 'int', default: 0 }) + totalTokens: number; + + @Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, default: 0 }) + cost: number; + + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Column({ name: 'completion_id', type: 'uuid', nullable: true }) + completionId: string; + + @Column({ name: 'request_id', type: 'varchar', length: 255, nullable: true }) + requestId: string; + + @Index() + @Column({ name: 'usage_date', type: 'date', default: () => 'CURRENT_DATE' }) + usageDate: Date; + + @Index() + @Column({ name: 'usage_month', type: 'varchar', length: 7, nullable: true }) + usageMonth: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} + +@Entity({ name: 'tenant_quotas', schema: 'ai' }) +@Unique(['tenantId', 'quotaMonth']) +export class AITenantQuota { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'monthly_token_limit', type: 'int', nullable: true }) + monthlyTokenLimit: number; + + @Column({ name: 'monthly_request_limit', type: 'int', nullable: true }) + monthlyRequestLimit: number; + + @Column({ name: 'monthly_cost_limit', type: 'decimal', precision: 10, scale: 2, nullable: true }) + monthlyCostLimit: number; + + @Column({ name: 'current_tokens', type: 'int', default: 0 }) + currentTokens: number; + + @Column({ name: 'current_requests', type: 'int', default: 0 }) + currentRequests: number; + + @Column({ name: 'current_cost', type: 'decimal', precision: 10, scale: 4, default: 0 }) + currentCost: number; + + @Index() + @Column({ name: 'quota_month', type: 'varchar', length: 7 }) + quotaMonth: string; + + @Column({ name: 'is_exceeded', type: 'boolean', default: false }) + isExceeded: boolean; + + @Column({ name: 'exceeded_at', type: 'timestamptz', nullable: true }) + exceededAt: Date; + + @Column({ name: 'alert_threshold_percent', type: 'int', default: 80 }) + alertThresholdPercent: number; + + @Column({ name: 'alert_sent_at', type: 'timestamptz', nullable: true }) + alertSentAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/ai/index.ts b/src/modules/ai/index.ts new file mode 100644 index 0000000..b2ce0ae --- /dev/null +++ b/src/modules/ai/index.ts @@ -0,0 +1,5 @@ +export { AIModule, AIModuleOptions } from './ai.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/ai/services/ai.service.ts b/src/modules/ai/services/ai.service.ts new file mode 100644 index 0000000..cbc626c --- /dev/null +++ b/src/modules/ai/services/ai.service.ts @@ -0,0 +1,384 @@ +import { Repository, FindOptionsWhere, LessThan, MoreThanOrEqual } from 'typeorm'; +import { AIModel, AIConversation, AIMessage, AIPrompt, AIUsageLog, AITenantQuota } from '../entities'; + +export interface ConversationFilters { + userId?: string; + modelId?: string; + status?: string; + startDate?: Date; + endDate?: Date; +} + +export class AIService { + constructor( + private readonly modelRepository: Repository, + private readonly conversationRepository: Repository, + private readonly messageRepository: Repository, + private readonly promptRepository: Repository, + private readonly usageLogRepository: Repository, + private readonly quotaRepository: Repository + ) {} + + // ============================================ + // MODELS + // ============================================ + + async findAllModels(): Promise { + return this.modelRepository.find({ + where: { isActive: true }, + order: { provider: 'ASC', name: 'ASC' }, + }); + } + + async findModel(id: string): Promise { + return this.modelRepository.findOne({ where: { id } }); + } + + async findModelByCode(code: string): Promise { + return this.modelRepository.findOne({ where: { code } }); + } + + async findModelsByProvider(provider: string): Promise { + return this.modelRepository.find({ + where: { provider: provider as any, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findModelsByType(modelType: string): Promise { + return this.modelRepository.find({ + where: { modelType: modelType as any, isActive: true }, + order: { name: 'ASC' }, + }); + } + + // ============================================ + // PROMPTS + // ============================================ + + async findAllPrompts(tenantId?: string): Promise { + if (tenantId) { + return this.promptRepository.find({ + where: [{ tenantId, isActive: true }, { isSystem: true, isActive: true }], + order: { category: 'ASC', name: 'ASC' }, + }); + } + return this.promptRepository.find({ + where: { isActive: true }, + order: { category: 'ASC', name: 'ASC' }, + }); + } + + async findPrompt(id: string): Promise { + return this.promptRepository.findOne({ where: { id } }); + } + + async findPromptByCode(code: string, tenantId?: string): Promise { + if (tenantId) { + // Try tenant-specific first, then system prompt + const tenantPrompt = await this.promptRepository.findOne({ + where: { code, tenantId, isActive: true }, + }); + if (tenantPrompt) return tenantPrompt; + + return this.promptRepository.findOne({ + where: { code, isSystem: true, isActive: true }, + }); + } + return this.promptRepository.findOne({ where: { code, isActive: true } }); + } + + async createPrompt( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const prompt = this.promptRepository.create({ + ...data, + tenantId, + createdBy, + version: 1, + }); + return this.promptRepository.save(prompt); + } + + async updatePrompt( + id: string, + data: Partial, + updatedBy?: string + ): Promise { + const prompt = await this.findPrompt(id); + if (!prompt) return null; + + if (prompt.isSystem) { + throw new Error('Cannot update system prompts'); + } + + Object.assign(prompt, data, { updatedBy, version: prompt.version + 1 }); + return this.promptRepository.save(prompt); + } + + async incrementPromptUsage(id: string): Promise { + await this.promptRepository + .createQueryBuilder() + .update() + .set({ + usageCount: () => 'usage_count + 1', + lastUsedAt: new Date(), + }) + .where('id = :id', { id }) + .execute(); + } + + // ============================================ + // CONVERSATIONS + // ============================================ + + async findConversations( + tenantId: string, + filters: ConversationFilters = {}, + limit: number = 50 + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.modelId) where.modelId = filters.modelId; + if (filters.status) where.status = filters.status as any; + + return this.conversationRepository.find({ + where, + order: { updatedAt: 'DESC' }, + take: limit, + }); + } + + async findConversation(id: string): Promise { + return this.conversationRepository.findOne({ + where: { id }, + relations: ['messages'], + }); + } + + async findUserConversations( + tenantId: string, + userId: string, + limit: number = 20 + ): Promise { + return this.conversationRepository.find({ + where: { tenantId, userId }, + order: { updatedAt: 'DESC' }, + take: limit, + }); + } + + async createConversation( + tenantId: string, + userId: string, + data: Partial + ): Promise { + const conversation = this.conversationRepository.create({ + ...data, + tenantId, + userId, + status: 'active', + }); + return this.conversationRepository.save(conversation); + } + + async updateConversation( + id: string, + data: Partial + ): Promise { + const conversation = await this.conversationRepository.findOne({ where: { id } }); + if (!conversation) return null; + + Object.assign(conversation, data); + return this.conversationRepository.save(conversation); + } + + async archiveConversation(id: string): Promise { + const result = await this.conversationRepository.update(id, { status: 'archived' }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // MESSAGES + // ============================================ + + async findMessages(conversationId: string): Promise { + return this.messageRepository.find({ + where: { conversationId }, + order: { createdAt: 'ASC' }, + }); + } + + async addMessage(conversationId: string, data: Partial): Promise { + const message = this.messageRepository.create({ + ...data, + conversationId, + }); + + const savedMessage = await this.messageRepository.save(message); + + // Update conversation + await this.conversationRepository + .createQueryBuilder() + .update() + .set({ + messageCount: () => 'message_count + 1', + totalTokens: () => `total_tokens + ${data.totalTokens || 0}`, + updatedAt: new Date(), + }) + .where('id = :id', { id: conversationId }) + .execute(); + + return savedMessage; + } + + async getConversationTokenCount(conversationId: string): Promise { + const result = await this.messageRepository + .createQueryBuilder('message') + .select('SUM(message.total_tokens)', 'total') + .where('message.conversation_id = :conversationId', { conversationId }) + .getRawOne(); + + return parseInt(result?.total) || 0; + } + + // ============================================ + // USAGE & QUOTAS + // ============================================ + + async logUsage(tenantId: string, data: Partial): Promise { + const log = this.usageLogRepository.create({ + ...data, + tenantId, + }); + return this.usageLogRepository.save(log); + } + + async getUsageStats( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ + totalRequests: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCost: number; + byModel: Record; + }> { + const stats = await this.usageLogRepository + .createQueryBuilder('log') + .select('COUNT(*)', 'totalRequests') + .addSelect('SUM(log.input_tokens)', 'totalInputTokens') + .addSelect('SUM(log.output_tokens)', 'totalOutputTokens') + .addSelect('SUM(log.cost_usd)', 'totalCost') + .where('log.tenant_id = :tenantId', { tenantId }) + .andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + const byModelStats = await this.usageLogRepository + .createQueryBuilder('log') + .select('log.model_id', 'modelId') + .addSelect('COUNT(*)', 'requests') + .addSelect('SUM(log.input_tokens + log.output_tokens)', 'tokens') + .addSelect('SUM(log.cost_usd)', 'cost') + .where('log.tenant_id = :tenantId', { tenantId }) + .andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('log.model_id') + .getRawMany(); + + const byModel: Record = {}; + for (const stat of byModelStats) { + byModel[stat.modelId] = { + requests: parseInt(stat.requests) || 0, + tokens: parseInt(stat.tokens) || 0, + cost: parseFloat(stat.cost) || 0, + }; + } + + return { + totalRequests: parseInt(stats?.totalRequests) || 0, + totalInputTokens: parseInt(stats?.totalInputTokens) || 0, + totalOutputTokens: parseInt(stats?.totalOutputTokens) || 0, + totalCost: parseFloat(stats?.totalCost) || 0, + byModel, + }; + } + + async getTenantQuota(tenantId: string): Promise { + return this.quotaRepository.findOne({ where: { tenantId } }); + } + + async updateTenantQuota( + tenantId: string, + data: Partial + ): Promise { + let quota = await this.getTenantQuota(tenantId); + + if (!quota) { + quota = this.quotaRepository.create({ + tenantId, + ...data, + }); + } else { + Object.assign(quota, data); + } + + return this.quotaRepository.save(quota); + } + + async incrementQuotaUsage( + tenantId: string, + requestCount: number, + tokenCount: number, + costUsd: number + ): Promise { + await this.quotaRepository + .createQueryBuilder() + .update() + .set({ + currentRequestsMonth: () => `current_requests_month + ${requestCount}`, + currentTokensMonth: () => `current_tokens_month + ${tokenCount}`, + currentSpendMonth: () => `current_spend_month + ${costUsd}`, + }) + .where('tenant_id = :tenantId', { tenantId }) + .execute(); + } + + async checkQuotaAvailable(tenantId: string): Promise<{ + available: boolean; + reason?: string; + }> { + const quota = await this.getTenantQuota(tenantId); + if (!quota) return { available: true }; + + if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) { + return { available: false, reason: 'Monthly request limit reached' }; + } + + if (quota.maxTokensPerMonth && quota.currentTokensMonth >= quota.maxTokensPerMonth) { + return { available: false, reason: 'Monthly token limit reached' }; + } + + if (quota.maxSpendPerMonth && quota.currentSpendMonth >= quota.maxSpendPerMonth) { + return { available: false, reason: 'Monthly spend limit reached' }; + } + + return { available: true }; + } + + async resetMonthlyQuotas(): Promise { + const result = await this.quotaRepository.update( + {}, + { + currentRequestsMonth: 0, + currentTokensMonth: 0, + currentSpendMonth: 0, + lastResetAt: new Date(), + } + ); + return result.affected ?? 0; + } +} diff --git a/src/modules/ai/services/index.ts b/src/modules/ai/services/index.ts new file mode 100644 index 0000000..d4fe86b --- /dev/null +++ b/src/modules/ai/services/index.ts @@ -0,0 +1 @@ +export { AIService, ConversationFilters } from './ai.service'; diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..6686fc8 --- /dev/null +++ b/src/modules/audit/audit.module.ts @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { AuditService } from './services'; +import { AuditController } from './controllers'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from './entities'; + +export interface AuditModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class AuditModule { + public router: Router; + public auditService: AuditService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: AuditModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const auditLogRepository = this.dataSource.getRepository(AuditLog); + const entityChangeRepository = this.dataSource.getRepository(EntityChange); + const loginHistoryRepository = this.dataSource.getRepository(LoginHistory); + const sensitiveDataAccessRepository = this.dataSource.getRepository(SensitiveDataAccess); + const dataExportRepository = this.dataSource.getRepository(DataExport); + const permissionChangeRepository = this.dataSource.getRepository(PermissionChange); + const configChangeRepository = this.dataSource.getRepository(ConfigChange); + + this.auditService = new AuditService( + auditLogRepository, + entityChangeRepository, + loginHistoryRepository, + sensitiveDataAccessRepository, + dataExportRepository, + permissionChangeRepository, + configChangeRepository + ); + } + + private initializeRoutes(): void { + const auditController = new AuditController(this.auditService); + this.router.use(`${this.basePath}/audit`, auditController.router); + } + + static getEntities(): Function[] { + return [ + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, + ]; + } +} diff --git a/src/modules/audit/controllers/audit.controller.ts b/src/modules/audit/controllers/audit.controller.ts new file mode 100644 index 0000000..518c09a --- /dev/null +++ b/src/modules/audit/controllers/audit.controller.ts @@ -0,0 +1,342 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { AuditService, AuditLogFilters } from '../services/audit.service'; + +export class AuditController { + public router: Router; + + constructor(private readonly auditService: AuditService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Audit Logs + this.router.get('/logs', this.findAuditLogs.bind(this)); + this.router.get('/logs/entity/:entityType/:entityId', this.findAuditLogsByEntity.bind(this)); + this.router.post('/logs', this.createAuditLog.bind(this)); + + // Entity Changes + this.router.get('/changes/:entityType/:entityId', this.findEntityChanges.bind(this)); + this.router.get('/changes/:entityType/:entityId/version/:version', this.getEntityVersion.bind(this)); + this.router.post('/changes', this.createEntityChange.bind(this)); + + // Login History + this.router.get('/logins/user/:userId', this.findLoginHistory.bind(this)); + this.router.get('/logins/user/:userId/active-sessions', this.getActiveSessionsCount.bind(this)); + this.router.post('/logins', this.createLoginHistory.bind(this)); + this.router.post('/logins/:sessionId/logout', this.markSessionLogout.bind(this)); + + // Sensitive Data Access + this.router.get('/sensitive-access', this.findSensitiveDataAccess.bind(this)); + this.router.post('/sensitive-access', this.logSensitiveDataAccess.bind(this)); + + // Data Exports + this.router.get('/exports', this.findUserDataExports.bind(this)); + this.router.get('/exports/:id', this.findDataExport.bind(this)); + this.router.post('/exports', this.createDataExport.bind(this)); + this.router.patch('/exports/:id/status', this.updateDataExportStatus.bind(this)); + + // Permission Changes + this.router.get('/permission-changes', this.findPermissionChanges.bind(this)); + this.router.post('/permission-changes', this.logPermissionChange.bind(this)); + + // Config Changes + this.router.get('/config-changes', this.findConfigChanges.bind(this)); + this.router.post('/config-changes', this.logConfigChange.bind(this)); + } + + // ============================================ + // AUDIT LOGS + // ============================================ + + private async findAuditLogs(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: AuditLogFilters = { + userId: req.query.userId as string, + entityType: req.query.entityType as string, + action: req.query.action as string, + category: req.query.category as string, + ipAddress: req.query.ipAddress as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 50; + + const result = await this.auditService.findAuditLogs(tenantId, filters, { page, limit }); + res.json({ data: result.data, total: result.total, page, limit }); + } catch (error) { + next(error); + } + } + + private async findAuditLogsByEntity(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId } = req.params; + + const logs = await this.auditService.findAuditLogsByEntity(tenantId, entityType, entityId); + res.json({ data: logs, total: logs.length }); + } catch (error) { + next(error); + } + } + + private async createAuditLog(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const log = await this.auditService.createAuditLog(tenantId, req.body); + res.status(201).json({ data: log }); + } catch (error) { + next(error); + } + } + + // ============================================ + // ENTITY CHANGES + // ============================================ + + private async findEntityChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId } = req.params; + + const changes = await this.auditService.findEntityChanges(tenantId, entityType, entityId); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async getEntityVersion(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId, version } = req.params; + + const change = await this.auditService.getEntityVersion( + tenantId, + entityType, + entityId, + parseInt(version) + ); + + if (!change) { + res.status(404).json({ error: 'Version not found' }); + return; + } + + res.json({ data: change }); + } catch (error) { + next(error); + } + } + + private async createEntityChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.createEntityChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } + + // ============================================ + // LOGIN HISTORY + // ============================================ + + private async findLoginHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + + const history = await this.auditService.findLoginHistory(userId, tenantId, limit); + res.json({ data: history, total: history.length }); + } catch (error) { + next(error); + } + } + + private async getActiveSessionsCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const count = await this.auditService.getActiveSessionsCount(userId); + res.json({ data: { activeSessions: count } }); + } catch (error) { + next(error); + } + } + + private async createLoginHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const login = await this.auditService.createLoginHistory(req.body); + res.status(201).json({ data: login }); + } catch (error) { + next(error); + } + } + + private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise { + try { + const { sessionId } = req.params; + const marked = await this.auditService.markSessionLogout(sessionId); + + if (!marked) { + res.status(404).json({ error: 'Session not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // SENSITIVE DATA ACCESS + // ============================================ + + private async findSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = { + userId: req.query.userId as string, + dataType: req.query.dataType as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const access = await this.auditService.findSensitiveDataAccess(tenantId, filters); + res.json({ data: access, total: access.length }); + } catch (error) { + next(error); + } + } + + private async logSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const access = await this.auditService.logSensitiveDataAccess(tenantId, req.body); + res.status(201).json({ data: access }); + } catch (error) { + next(error); + } + } + + // ============================================ + // DATA EXPORTS + // ============================================ + + private async findUserDataExports(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const exports = await this.auditService.findUserDataExports(tenantId, userId); + res.json({ data: exports, total: exports.length }); + } catch (error) { + next(error); + } + } + + private async findDataExport(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const exportRecord = await this.auditService.findDataExport(id); + + if (!exportRecord) { + res.status(404).json({ error: 'Export not found' }); + return; + } + + res.json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + private async createDataExport(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const exportRecord = await this.auditService.createDataExport(tenantId, req.body); + res.status(201).json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + private async updateDataExportStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, ...updates } = req.body; + + const exportRecord = await this.auditService.updateDataExportStatus(id, status, updates); + + if (!exportRecord) { + res.status(404).json({ error: 'Export not found' }); + return; + } + + res.json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PERMISSION CHANGES + // ============================================ + + private async findPermissionChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const targetUserId = req.query.targetUserId as string; + + const changes = await this.auditService.findPermissionChanges(tenantId, targetUserId); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async logPermissionChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.logPermissionChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONFIG CHANGES + // ============================================ + + private async findConfigChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const configType = req.query.configType as string; + + const changes = await this.auditService.findConfigChanges(tenantId, configType); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async logConfigChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.logConfigChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/audit/controllers/index.ts b/src/modules/audit/controllers/index.ts new file mode 100644 index 0000000..668948b --- /dev/null +++ b/src/modules/audit/controllers/index.ts @@ -0,0 +1 @@ +export { AuditController } from './audit.controller'; diff --git a/src/modules/audit/dto/audit.dto.ts b/src/modules/audit/dto/audit.dto.ts new file mode 100644 index 0000000..f646e6a --- /dev/null +++ b/src/modules/audit/dto/audit.dto.ts @@ -0,0 +1,346 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsEnum, + IsIP, + MaxLength, + MinLength, +} from 'class-validator'; + +// ============================================ +// AUDIT LOG DTOs +// ============================================ + +export class CreateAuditLogDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsString() + @MaxLength(20) + action: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsObject() + oldValues?: Record; + + @IsOptional() + @IsObject() + newValues?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + requestId?: string; +} + +// ============================================ +// ENTITY CHANGE DTOs +// ============================================ + +export class CreateEntityChangeDto { + @IsString() + @MaxLength(100) + entityType: string; + + @IsUUID() + entityId: string; + + @IsString() + @MaxLength(20) + changeType: string; + + @IsOptional() + @IsUUID() + changedBy?: string; + + @IsNumber() + version: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + changedFields?: string[]; + + @IsOptional() + @IsObject() + previousData?: Record; + + @IsOptional() + @IsObject() + newData?: Record; + + @IsOptional() + @IsString() + changeReason?: string; +} + +// ============================================ +// LOGIN HISTORY DTOs +// ============================================ + +export class CreateLoginHistoryDto { + @IsUUID() + userId: string; + + @IsOptional() + @IsUUID() + tenantId?: string; + + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + @MaxLength(30) + authMethod?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mfaMethod?: string; + + @IsOptional() + @IsBoolean() + mfaUsed?: boolean; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + deviceFingerprint?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + location?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + sessionId?: string; + + @IsOptional() + @IsString() + failureReason?: string; +} + +// ============================================ +// SENSITIVE DATA ACCESS DTOs +// ============================================ + +export class CreateSensitiveDataAccessDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(50) + dataType: string; + + @IsString() + @MaxLength(20) + accessType: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + fieldsAccessed?: string[]; + + @IsOptional() + @IsString() + accessReason?: string; + + @IsOptional() + @IsBoolean() + wasExported?: boolean; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; +} + +// ============================================ +// DATA EXPORT DTOs +// ============================================ + +export class CreateDataExportDto { + @IsString() + @MaxLength(30) + exportType: string; + + @IsString() + @MaxLength(20) + format: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + entities?: string[]; + + @IsOptional() + @IsObject() + filters?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + fields?: string[]; + + @IsOptional() + @IsString() + exportReason?: string; +} + +export class UpdateDataExportStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + filePath?: string; + + @IsOptional() + @IsNumber() + fileSize?: number; + + @IsOptional() + @IsNumber() + recordCount?: number; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +// ============================================ +// PERMISSION CHANGE DTOs +// ============================================ + +export class CreatePermissionChangeDto { + @IsUUID() + targetUserId: string; + + @IsUUID() + changedBy: string; + + @IsString() + @MaxLength(20) + changeType: string; + + @IsString() + @MaxLength(30) + scope: string; + + @IsOptional() + @IsString() + @MaxLength(100) + resourceType?: string; + + @IsOptional() + @IsUUID() + resourceId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + previousPermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + newPermissions?: string[]; + + @IsOptional() + @IsString() + changeReason?: string; +} + +// ============================================ +// CONFIG CHANGE DTOs +// ============================================ + +export class CreateConfigChangeDto { + @IsString() + @MaxLength(30) + configType: string; + + @IsString() + @MaxLength(200) + configKey: string; + + @IsUUID() + changedBy: string; + + @IsNumber() + version: number; + + @IsOptional() + @IsObject() + previousValue?: Record; + + @IsOptional() + @IsObject() + newValue?: Record; + + @IsOptional() + @IsString() + changeReason?: string; +} diff --git a/src/modules/audit/dto/index.ts b/src/modules/audit/dto/index.ts new file mode 100644 index 0000000..51a4ace --- /dev/null +++ b/src/modules/audit/dto/index.ts @@ -0,0 +1,10 @@ +export { + CreateAuditLogDto, + CreateEntityChangeDto, + CreateLoginHistoryDto, + CreateSensitiveDataAccessDto, + CreateDataExportDto, + UpdateDataExportStatusDto, + CreatePermissionChangeDto, + CreateConfigChangeDto, +} from './audit.dto'; diff --git a/src/modules/audit/entities/audit-log.entity.ts b/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 0000000..6fd98e7 --- /dev/null +++ b/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,108 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export'; +export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing'; +export type AuditStatus = 'success' | 'failure' | 'partial'; + +@Entity({ name: 'audit_logs', schema: 'audit' }) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true }) + userEmail: string; + + @Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true }) + userName: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Column({ name: 'impersonator_id', type: 'uuid', nullable: true }) + impersonatorId: string; + + @Index() + @Column({ name: 'action', type: 'varchar', length: 50 }) + action: AuditAction; + + @Index() + @Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true }) + actionCategory: AuditCategory; + + @Index() + @Column({ name: 'resource_type', type: 'varchar', length: 100 }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true }) + resourceName: string; + + @Column({ name: 'old_values', type: 'jsonb', nullable: true }) + oldValues: Record; + + @Column({ name: 'new_values', type: 'jsonb', nullable: true }) + newValues: Record; + + @Column({ name: 'changed_fields', type: 'text', array: true, nullable: true }) + changedFields: string[]; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'device_info', type: 'jsonb', default: {} }) + deviceInfo: Record; + + @Column({ name: 'location', type: 'jsonb', default: {} }) + location: Record; + + @Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true }) + requestId: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true }) + requestMethod: string; + + @Column({ name: 'request_path', type: 'text', nullable: true }) + requestPath: string; + + @Column({ name: 'request_params', type: 'jsonb', default: {} }) + requestParams: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'success' }) + status: AuditStatus; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/audit/entities/config-change.entity.ts b/src/modules/audit/entities/config-change.entity.ts new file mode 100644 index 0000000..f9b3a69 --- /dev/null +++ b/src/modules/audit/entities/config-change.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags'; + +@Entity({ name: 'config_changes', schema: 'audit' }) +export class ConfigChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ name: 'changed_by', type: 'uuid' }) + changedBy: string; + + @Index() + @Column({ name: 'config_type', type: 'varchar', length: 50 }) + configType: ConfigType; + + @Column({ name: 'config_key', type: 'varchar', length: 100 }) + configKey: string; + + @Column({ name: 'config_path', type: 'text', nullable: true }) + configPath: string; + + @Column({ name: 'old_value', type: 'jsonb', nullable: true }) + oldValue: Record; + + @Column({ name: 'new_value', type: 'jsonb', nullable: true }) + newValue: Record; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true }) + ticketId: string; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/data-export.entity.ts b/src/modules/audit/entities/data-export.entity.ts new file mode 100644 index 0000000..727bf36 --- /dev/null +++ b/src/modules/audit/entities/data-export.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export'; +export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json'; +export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired'; + +@Entity({ name: 'data_exports', schema: 'audit' }) +export class DataExport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'export_type', type: 'varchar', length: 50 }) + exportType: ExportType; + + @Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true }) + exportFormat: ExportFormat; + + @Column({ name: 'entity_types', type: 'text', array: true }) + entityTypes: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'date_range_start', type: 'timestamptz', nullable: true }) + dateRangeStart: Date; + + @Column({ name: 'date_range_end', type: 'timestamptz', nullable: true }) + dateRangeEnd: Date; + + @Column({ name: 'record_count', type: 'int', nullable: true }) + recordCount: number; + + @Column({ name: 'file_size_bytes', type: 'bigint', nullable: true }) + fileSizeBytes: number; + + @Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true }) + fileHash: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: ExportStatus; + + @Column({ name: 'download_url', type: 'text', nullable: true }) + downloadUrl: string; + + @Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true }) + downloadExpiresAt: Date; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Index() + @Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + requestedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/audit/entities/entity-change.entity.ts b/src/modules/audit/entities/entity-change.entity.ts new file mode 100644 index 0000000..b2e208e --- /dev/null +++ b/src/modules/audit/entities/entity-change.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ChangeType = 'create' | 'update' | 'delete' | 'restore'; + +@Entity({ name: 'entity_changes', schema: 'audit' }) +export class EntityChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100 }) + entityType: string; + + @Index() + @Column({ name: 'entity_id', type: 'uuid' }) + entityId: string; + + @Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true }) + entityName: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'previous_version', type: 'int', nullable: true }) + previousVersion: number; + + @Column({ name: 'data_snapshot', type: 'jsonb' }) + dataSnapshot: Record; + + @Column({ name: 'changes', type: 'jsonb', default: [] }) + changes: Record[]; + + @Index() + @Column({ name: 'changed_by', type: 'uuid', nullable: true }) + changedBy: string; + + @Column({ name: 'change_reason', type: 'text', nullable: true }) + changeReason: string; + + @Column({ name: 'change_type', type: 'varchar', length: 20 }) + changeType: ChangeType; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/index.ts b/src/modules/audit/entities/index.ts new file mode 100644 index 0000000..e0f3abd --- /dev/null +++ b/src/modules/audit/entities/index.ts @@ -0,0 +1,7 @@ +export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity'; +export { EntityChange, ChangeType } from './entity-change.entity'; +export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity'; +export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity'; +export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity'; +export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity'; +export { ConfigChange, ConfigType } from './config-change.entity'; diff --git a/src/modules/audit/entities/login-history.entity.ts b/src/modules/audit/entities/login-history.entity.ts new file mode 100644 index 0000000..d90123d --- /dev/null +++ b/src/modules/audit/entities/login-history.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed'; +export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric'; +export type MfaMethod = 'totp' | 'sms' | 'email' | 'push'; + +@Entity({ name: 'login_history', schema: 'audit' }) +export class LoginHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'email', type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'username', type: 'varchar', length: 100, nullable: true }) + username: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: LoginStatus; + + @Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true }) + authMethod: AuthMethod; + + @Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true }) + oauthProvider: string; + + @Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true }) + mfaMethod: MfaMethod; + + @Column({ name: 'mfa_verified', type: 'boolean', nullable: true }) + mfaVerified: boolean; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true }) + deviceFingerprint: string; + + @Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true }) + deviceType: string; + + @Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true }) + deviceOs: string; + + @Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true }) + deviceBrowser: string; + + @Index() + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true }) + countryCode: string; + + @Column({ name: 'city', type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Column({ name: 'risk_score', type: 'int', nullable: true }) + riskScore: number; + + @Column({ name: 'risk_factors', type: 'jsonb', default: [] }) + riskFactors: string[]; + + @Index() + @Column({ name: 'is_suspicious', type: 'boolean', default: false }) + isSuspicious: boolean; + + @Column({ name: 'is_new_device', type: 'boolean', default: false }) + isNewDevice: boolean; + + @Column({ name: 'is_new_location', type: 'boolean', default: false }) + isNewLocation: boolean; + + @Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true }) + failureReason: string; + + @Column({ name: 'failure_count', type: 'int', nullable: true }) + failureCount: number; + + @Index() + @Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + attemptedAt: Date; +} diff --git a/src/modules/audit/entities/permission-change.entity.ts b/src/modules/audit/entities/permission-change.entity.ts new file mode 100644 index 0000000..b673a6a --- /dev/null +++ b/src/modules/audit/entities/permission-change.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked'; +export type PermissionScope = 'global' | 'tenant' | 'branch'; + +@Entity({ name: 'permission_changes', schema: 'audit' }) +export class PermissionChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'changed_by', type: 'uuid' }) + changedBy: string; + + @Index() + @Column({ name: 'target_user_id', type: 'uuid' }) + targetUserId: string; + + @Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true }) + targetUserEmail: string; + + @Column({ name: 'change_type', type: 'varchar', length: 30 }) + changeType: PermissionChangeType; + + @Column({ name: 'role_id', type: 'uuid', nullable: true }) + roleId: string; + + @Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true }) + roleCode: string; + + @Column({ name: 'permission_id', type: 'uuid', nullable: true }) + permissionId: string; + + @Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true }) + permissionCode: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Column({ name: 'scope', type: 'varchar', length: 30, nullable: true }) + scope: PermissionScope; + + @Column({ name: 'previous_roles', type: 'text', array: true, nullable: true }) + previousRoles: string[]; + + @Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true }) + previousPermissions: string[]; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/sensitive-data-access.entity.ts b/src/modules/audit/entities/sensitive-data-access.entity.ts new file mode 100644 index 0000000..140c0eb --- /dev/null +++ b/src/modules/audit/entities/sensitive-data-access.entity.ts @@ -0,0 +1,62 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type DataType = 'pii' | 'financial' | 'medical' | 'credentials'; +export type AccessType = 'view' | 'export' | 'modify' | 'decrypt'; + +@Entity({ name: 'sensitive_data_access', schema: 'audit' }) +export class SensitiveDataAccess { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Index() + @Column({ name: 'data_type', type: 'varchar', length: 100 }) + dataType: DataType; + + @Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true }) + dataCategory: string; + + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'access_type', type: 'varchar', length: 30 }) + accessType: AccessType; + + @Column({ name: 'access_reason', type: 'text', nullable: true }) + accessReason: string; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Index() + @Column({ name: 'was_authorized', type: 'boolean', default: true }) + wasAuthorized: boolean; + + @Column({ name: 'denial_reason', type: 'text', nullable: true }) + denialReason: string; + + @Index() + @Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + accessedAt: Date; +} diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..c9df41c --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1,5 @@ +export { AuditModule, AuditModuleOptions } from './audit.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts new file mode 100644 index 0000000..7a1e14b --- /dev/null +++ b/src/modules/audit/services/audit.service.ts @@ -0,0 +1,303 @@ +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from '../entities'; + +export interface AuditLogFilters { + userId?: string; + entityType?: string; + action?: string; + category?: string; + startDate?: Date; + endDate?: Date; + ipAddress?: string; +} + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export class AuditService { + constructor( + private readonly auditLogRepository: Repository, + private readonly entityChangeRepository: Repository, + private readonly loginHistoryRepository: Repository, + private readonly sensitiveDataAccessRepository: Repository, + private readonly dataExportRepository: Repository, + private readonly permissionChangeRepository: Repository, + private readonly configChangeRepository: Repository + ) {} + + // ============================================ + // AUDIT LOGS + // ============================================ + + async createAuditLog(tenantId: string, data: Partial): Promise { + const log = this.auditLogRepository.create({ + ...data, + tenantId, + }); + return this.auditLogRepository.save(log); + } + + async findAuditLogs( + tenantId: string, + filters: AuditLogFilters = {}, + pagination: PaginationOptions = {} + ): Promise<{ data: AuditLog[]; total: number }> { + const { page = 1, limit = 50 } = pagination; + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.entityType) where.entityType = filters.entityType; + if (filters.action) where.action = filters.action as any; + if (filters.category) where.category = filters.category as any; + if (filters.ipAddress) where.ipAddress = filters.ipAddress; + + if (filters.startDate && filters.endDate) { + where.createdAt = Between(filters.startDate, filters.endDate); + } else if (filters.startDate) { + where.createdAt = MoreThanOrEqual(filters.startDate); + } else if (filters.endDate) { + where.createdAt = LessThanOrEqual(filters.endDate); + } + + const [data, total] = await this.auditLogRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + async findAuditLogsByEntity( + tenantId: string, + entityType: string, + entityId: string + ): Promise { + return this.auditLogRepository.find({ + where: { tenantId, entityType, entityId }, + order: { createdAt: 'DESC' }, + }); + } + + // ============================================ + // ENTITY CHANGES + // ============================================ + + async createEntityChange(tenantId: string, data: Partial): Promise { + const change = this.entityChangeRepository.create({ + ...data, + tenantId, + }); + return this.entityChangeRepository.save(change); + } + + async findEntityChanges( + tenantId: string, + entityType: string, + entityId: string + ): Promise { + return this.entityChangeRepository.find({ + where: { tenantId, entityType, entityId }, + order: { changedAt: 'DESC' }, + }); + } + + async getEntityVersion( + tenantId: string, + entityType: string, + entityId: string, + version: number + ): Promise { + return this.entityChangeRepository.findOne({ + where: { tenantId, entityType, entityId, version }, + }); + } + + // ============================================ + // LOGIN HISTORY + // ============================================ + + async createLoginHistory(data: Partial): Promise { + const login = this.loginHistoryRepository.create(data); + return this.loginHistoryRepository.save(login); + } + + async findLoginHistory( + userId: string, + tenantId?: string, + limit: number = 20 + ): Promise { + const where: FindOptionsWhere = { userId }; + if (tenantId) where.tenantId = tenantId; + + return this.loginHistoryRepository.find({ + where, + order: { loginAt: 'DESC' }, + take: limit, + }); + } + + async getActiveSessionsCount(userId: string): Promise { + return this.loginHistoryRepository.count({ + where: { userId, logoutAt: undefined, status: 'success' }, + }); + } + + async markSessionLogout(sessionId: string): Promise { + const result = await this.loginHistoryRepository.update( + { sessionId }, + { logoutAt: new Date() } + ); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // SENSITIVE DATA ACCESS + // ============================================ + + async logSensitiveDataAccess( + tenantId: string, + data: Partial + ): Promise { + const access = this.sensitiveDataAccessRepository.create({ + ...data, + tenantId, + }); + return this.sensitiveDataAccessRepository.save(access); + } + + async findSensitiveDataAccess( + tenantId: string, + filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {} + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.dataType) where.dataType = filters.dataType as any; + + if (filters.startDate && filters.endDate) { + where.accessedAt = Between(filters.startDate, filters.endDate); + } + + return this.sensitiveDataAccessRepository.find({ + where, + order: { accessedAt: 'DESC' }, + take: 100, + }); + } + + // ============================================ + // DATA EXPORTS + // ============================================ + + async createDataExport(tenantId: string, data: Partial): Promise { + const exportRecord = this.dataExportRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.dataExportRepository.save(exportRecord); + } + + async findDataExport(id: string): Promise { + return this.dataExportRepository.findOne({ where: { id } }); + } + + async findUserDataExports(tenantId: string, userId: string): Promise { + return this.dataExportRepository.find({ + where: { tenantId, requestedBy: userId }, + order: { requestedAt: 'DESC' }, + }); + } + + async updateDataExportStatus( + id: string, + status: string, + updates: Partial = {} + ): Promise { + const exportRecord = await this.findDataExport(id); + if (!exportRecord) return null; + + exportRecord.status = status as any; + Object.assign(exportRecord, updates); + + if (status === 'completed') { + exportRecord.completedAt = new Date(); + } + + return this.dataExportRepository.save(exportRecord); + } + + // ============================================ + // PERMISSION CHANGES + // ============================================ + + async logPermissionChange( + tenantId: string, + data: Partial + ): Promise { + const change = this.permissionChangeRepository.create({ + ...data, + tenantId, + }); + return this.permissionChangeRepository.save(change); + } + + async findPermissionChanges( + tenantId: string, + targetUserId?: string + ): Promise { + const where: FindOptionsWhere = { tenantId }; + if (targetUserId) where.targetUserId = targetUserId; + + return this.permissionChangeRepository.find({ + where, + order: { changedAt: 'DESC' }, + take: 100, + }); + } + + // ============================================ + // CONFIG CHANGES + // ============================================ + + async logConfigChange(tenantId: string, data: Partial): Promise { + const change = this.configChangeRepository.create({ + ...data, + tenantId, + }); + return this.configChangeRepository.save(change); + } + + async findConfigChanges(tenantId: string, configType?: string): Promise { + const where: FindOptionsWhere = { tenantId }; + if (configType) where.configType = configType as any; + + return this.configChangeRepository.find({ + where, + order: { changedAt: 'DESC' }, + take: 100, + }); + } + + async getConfigVersion( + tenantId: string, + configKey: string, + version: number + ): Promise { + return this.configChangeRepository.findOne({ + where: { tenantId, configKey, version }, + }); + } +} diff --git a/src/modules/audit/services/index.ts b/src/modules/audit/services/index.ts new file mode 100644 index 0000000..4e17eb0 --- /dev/null +++ b/src/modules/audit/services/index.ts @@ -0,0 +1 @@ +export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service'; diff --git a/src/modules/billing-usage/billing-usage.module.ts b/src/modules/billing-usage/billing-usage.module.ts new file mode 100644 index 0000000..69d63e4 --- /dev/null +++ b/src/modules/billing-usage/billing-usage.module.ts @@ -0,0 +1,60 @@ +/** + * Billing Usage Module + * + * Module registration for billing and usage tracking + */ + +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { + SubscriptionPlansController, + SubscriptionsController, + UsageController, + InvoicesController, +} from './controllers'; + +export interface BillingUsageModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class BillingUsageModule { + public router: Router; + private subscriptionPlansController: SubscriptionPlansController; + private subscriptionsController: SubscriptionsController; + private usageController: UsageController; + private invoicesController: InvoicesController; + + constructor(options: BillingUsageModuleOptions) { + const { dataSource, basePath = '/billing' } = options; + + this.router = Router(); + + // Initialize controllers + this.subscriptionPlansController = new SubscriptionPlansController(dataSource); + this.subscriptionsController = new SubscriptionsController(dataSource); + this.usageController = new UsageController(dataSource); + this.invoicesController = new InvoicesController(dataSource); + + // Register routes + this.router.use(`${basePath}/subscription-plans`, this.subscriptionPlansController.router); + this.router.use(`${basePath}/subscriptions`, this.subscriptionsController.router); + this.router.use(`${basePath}/usage`, this.usageController.router); + this.router.use(`${basePath}/invoices`, this.invoicesController.router); + } + + /** + * Get all entities for this module (for TypeORM configuration) + */ + static getEntities() { + return [ + require('./entities/subscription-plan.entity').SubscriptionPlan, + require('./entities/tenant-subscription.entity').TenantSubscription, + require('./entities/usage-tracking.entity').UsageTracking, + require('./entities/invoice.entity').Invoice, + require('./entities/invoice-item.entity').InvoiceItem, + ]; + } +} + +export default BillingUsageModule; diff --git a/src/modules/billing-usage/controllers/index.ts b/src/modules/billing-usage/controllers/index.ts new file mode 100644 index 0000000..f529d57 --- /dev/null +++ b/src/modules/billing-usage/controllers/index.ts @@ -0,0 +1,8 @@ +/** + * Billing Usage Controllers Index + */ + +export { SubscriptionPlansController } from './subscription-plans.controller'; +export { SubscriptionsController } from './subscriptions.controller'; +export { UsageController } from './usage.controller'; +export { InvoicesController } from './invoices.controller'; diff --git a/src/modules/billing-usage/controllers/invoices.controller.ts b/src/modules/billing-usage/controllers/invoices.controller.ts new file mode 100644 index 0000000..5ead18f --- /dev/null +++ b/src/modules/billing-usage/controllers/invoices.controller.ts @@ -0,0 +1,258 @@ +/** + * Invoices Controller + * + * REST API endpoints for invoice management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { InvoicesService } from '../services'; +import { + CreateInvoiceDto, + UpdateInvoiceDto, + RecordPaymentDto, + VoidInvoiceDto, + RefundInvoiceDto, + GenerateInvoiceDto, + InvoiceFilterDto, +} from '../dto'; + +export class InvoicesController { + public router: Router; + private service: InvoicesService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new InvoicesService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stats + this.router.get('/stats', this.getStats.bind(this)); + + // List and search + this.router.get('/', this.getAll.bind(this)); + this.router.get('/tenant/:tenantId', this.getByTenant.bind(this)); + this.router.get('/:id', this.getById.bind(this)); + this.router.get('/number/:invoiceNumber', this.getByNumber.bind(this)); + + // Create + this.router.post('/', this.create.bind(this)); + this.router.post('/generate', this.generate.bind(this)); + + // Update + this.router.put('/:id', this.update.bind(this)); + + // Actions + this.router.post('/:id/send', this.send.bind(this)); + this.router.post('/:id/payment', this.recordPayment.bind(this)); + this.router.post('/:id/void', this.void.bind(this)); + this.router.post('/:id/refund', this.refund.bind(this)); + + // Batch operations + this.router.post('/mark-overdue', this.markOverdue.bind(this)); + } + + /** + * GET /invoices/stats + * Get invoice statistics + */ + private async getStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tenantId } = req.query; + const stats = await this.service.getStats(tenantId as string); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices + * Get all invoices with filters + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const filter: InvoiceFilterDto = { + tenantId: req.query.tenantId as string, + status: req.query.status as any, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + overdue: req.query.overdue === 'true', + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string) : undefined, + }; + + const result = await this.service.findAll(filter); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices/tenant/:tenantId + * Get invoices for specific tenant + */ + private async getByTenant(req: Request, res: Response, next: NextFunction): Promise { + try { + const result = await this.service.findAll({ + tenantId: req.params.tenantId, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + offset: req.query.offset ? parseInt(req.query.offset as string) : 0, + }); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices/:id + * Get invoice by ID + */ + private async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const invoice = await this.service.findById(req.params.id); + + if (!invoice) { + res.status(404).json({ error: 'Invoice not found' }); + return; + } + + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices/number/:invoiceNumber + * Get invoice by number + */ + private async getByNumber(req: Request, res: Response, next: NextFunction): Promise { + try { + const invoice = await this.service.findByNumber(req.params.invoiceNumber); + + if (!invoice) { + res.status(404).json({ error: 'Invoice not found' }); + return; + } + + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices + * Create invoice manually + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreateInvoiceDto = req.body; + const invoice = await this.service.create(dto); + res.status(201).json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/generate + * Generate invoice from subscription + */ + private async generate(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: GenerateInvoiceDto = req.body; + const invoice = await this.service.generateFromSubscription(dto); + res.status(201).json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * PUT /invoices/:id + * Update invoice + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateInvoiceDto = req.body; + const invoice = await this.service.update(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/send + * Send invoice to customer + */ + private async send(req: Request, res: Response, next: NextFunction): Promise { + try { + const invoice = await this.service.send(req.params.id); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/payment + * Record payment on invoice + */ + private async recordPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: RecordPaymentDto = req.body; + const invoice = await this.service.recordPayment(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/void + * Void an invoice + */ + private async void(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: VoidInvoiceDto = req.body; + const invoice = await this.service.void(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/refund + * Refund an invoice + */ + private async refund(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: RefundInvoiceDto = req.body; + const invoice = await this.service.refund(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/mark-overdue + * Mark all overdue invoices (scheduled job endpoint) + */ + private async markOverdue(req: Request, res: Response, next: NextFunction): Promise { + try { + const count = await this.service.markOverdueInvoices(); + res.json({ data: { markedOverdue: count } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/controllers/subscription-plans.controller.ts b/src/modules/billing-usage/controllers/subscription-plans.controller.ts new file mode 100644 index 0000000..5a4ae2f --- /dev/null +++ b/src/modules/billing-usage/controllers/subscription-plans.controller.ts @@ -0,0 +1,168 @@ +/** + * Subscription Plans Controller + * + * REST API endpoints for subscription plan management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SubscriptionPlansService } from '../services'; +import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto'; + +export class SubscriptionPlansController { + public router: Router; + private service: SubscriptionPlansService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new SubscriptionPlansService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Public routes + this.router.get('/public', this.getPublicPlans.bind(this)); + this.router.get('/:id/compare/:otherId', this.comparePlans.bind(this)); + + // Protected routes (require admin) + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:id', this.getById.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.put('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.patch('/:id/activate', this.activate.bind(this)); + this.router.patch('/:id/deactivate', this.deactivate.bind(this)); + } + + /** + * GET /subscription-plans/public + * Get public plans for pricing page + */ + private async getPublicPlans(req: Request, res: Response, next: NextFunction): Promise { + try { + const plans = await this.service.findPublicPlans(); + res.json({ data: plans }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscription-plans + * Get all plans (admin only) + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const { isActive, isPublic, planType } = req.query; + + const plans = await this.service.findAll({ + isActive: isActive !== undefined ? isActive === 'true' : undefined, + isPublic: isPublic !== undefined ? isPublic === 'true' : undefined, + planType: planType as any, + }); + + res.json({ data: plans }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscription-plans/:id + * Get plan by ID + */ + private async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const plan = await this.service.findById(req.params.id); + + if (!plan) { + res.status(404).json({ error: 'Plan not found' }); + return; + } + + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscription-plans + * Create new plan + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreateSubscriptionPlanDto = req.body; + const plan = await this.service.create(dto); + res.status(201).json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * PUT /subscription-plans/:id + * Update plan + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateSubscriptionPlanDto = req.body; + const plan = await this.service.update(req.params.id, dto); + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /subscription-plans/:id + * Delete plan (soft delete) + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + await this.service.delete(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * PATCH /subscription-plans/:id/activate + * Activate plan + */ + private async activate(req: Request, res: Response, next: NextFunction): Promise { + try { + const plan = await this.service.setActive(req.params.id, true); + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /subscription-plans/:id/deactivate + * Deactivate plan + */ + private async deactivate(req: Request, res: Response, next: NextFunction): Promise { + try { + const plan = await this.service.setActive(req.params.id, false); + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscription-plans/:id/compare/:otherId + * Compare two plans + */ + private async comparePlans(req: Request, res: Response, next: NextFunction): Promise { + try { + const comparison = await this.service.comparePlans(req.params.id, req.params.otherId); + res.json({ data: comparison }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/controllers/subscriptions.controller.ts b/src/modules/billing-usage/controllers/subscriptions.controller.ts new file mode 100644 index 0000000..f8fc3a6 --- /dev/null +++ b/src/modules/billing-usage/controllers/subscriptions.controller.ts @@ -0,0 +1,232 @@ +/** + * Subscriptions Controller + * + * REST API endpoints for tenant subscription management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SubscriptionsService } from '../services'; +import { + CreateTenantSubscriptionDto, + UpdateTenantSubscriptionDto, + CancelSubscriptionDto, + ChangePlanDto, + SetPaymentMethodDto, +} from '../dto'; + +export class SubscriptionsController { + public router: Router; + private service: SubscriptionsService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new SubscriptionsService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stats (admin) + this.router.get('/stats', this.getStats.bind(this)); + + // Tenant subscription + this.router.get('/tenant/:tenantId', this.getByTenant.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.put('/:id', this.update.bind(this)); + + // Subscription actions + this.router.post('/:id/cancel', this.cancel.bind(this)); + this.router.post('/:id/reactivate', this.reactivate.bind(this)); + this.router.post('/:id/change-plan', this.changePlan.bind(this)); + this.router.post('/:id/payment-method', this.setPaymentMethod.bind(this)); + this.router.post('/:id/renew', this.renew.bind(this)); + this.router.post('/:id/suspend', this.suspend.bind(this)); + this.router.post('/:id/activate', this.activate.bind(this)); + + // Alerts/expiring + this.router.get('/expiring', this.getExpiring.bind(this)); + this.router.get('/trials-ending', this.getTrialsEnding.bind(this)); + } + + /** + * GET /subscriptions/stats + * Get subscription statistics + */ + private async getStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const stats = await this.service.getStats(); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscriptions/tenant/:tenantId + * Get subscription by tenant ID + */ + private async getByTenant(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.findByTenantId(req.params.tenantId); + + if (!subscription) { + res.status(404).json({ error: 'Subscription not found' }); + return; + } + + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions + * Create new subscription + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreateTenantSubscriptionDto = req.body; + const subscription = await this.service.create(dto); + res.status(201).json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * PUT /subscriptions/:id + * Update subscription + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateTenantSubscriptionDto = req.body; + const subscription = await this.service.update(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/cancel + * Cancel subscription + */ + private async cancel(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CancelSubscriptionDto = req.body; + const subscription = await this.service.cancel(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/reactivate + * Reactivate cancelled subscription + */ + private async reactivate(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.reactivate(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/change-plan + * Change subscription plan + */ + private async changePlan(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: ChangePlanDto = req.body; + const subscription = await this.service.changePlan(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/payment-method + * Set payment method + */ + private async setPaymentMethod(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: SetPaymentMethodDto = req.body; + const subscription = await this.service.setPaymentMethod(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/renew + * Renew subscription + */ + private async renew(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.renew(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/suspend + * Suspend subscription + */ + private async suspend(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.suspend(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/activate + * Activate subscription + */ + private async activate(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.activate(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscriptions/expiring + * Get subscriptions expiring soon + */ + private async getExpiring(req: Request, res: Response, next: NextFunction): Promise { + try { + const days = parseInt(req.query.days as string) || 7; + const subscriptions = await this.service.findExpiringSoon(days); + res.json({ data: subscriptions }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscriptions/trials-ending + * Get trials ending soon + */ + private async getTrialsEnding(req: Request, res: Response, next: NextFunction): Promise { + try { + const days = parseInt(req.query.days as string) || 3; + const subscriptions = await this.service.findTrialsEndingSoon(days); + res.json({ data: subscriptions }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/controllers/usage.controller.ts b/src/modules/billing-usage/controllers/usage.controller.ts new file mode 100644 index 0000000..b9088c9 --- /dev/null +++ b/src/modules/billing-usage/controllers/usage.controller.ts @@ -0,0 +1,173 @@ +/** + * Usage Controller + * + * REST API endpoints for usage tracking + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { UsageTrackingService } from '../services'; +import { RecordUsageDto, UpdateUsageDto, IncrementUsageDto, UsageMetrics } from '../dto'; + +export class UsageController { + public router: Router; + private service: UsageTrackingService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new UsageTrackingService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Current usage + this.router.get('/tenant/:tenantId/current', this.getCurrentUsage.bind(this)); + this.router.get('/tenant/:tenantId/summary', this.getUsageSummary.bind(this)); + this.router.get('/tenant/:tenantId/limits', this.checkLimits.bind(this)); + + // Usage history + this.router.get('/tenant/:tenantId/history', this.getUsageHistory.bind(this)); + this.router.get('/tenant/:tenantId/report', this.getUsageReport.bind(this)); + + // Record usage + this.router.post('/', this.recordUsage.bind(this)); + this.router.put('/:id', this.updateUsage.bind(this)); + this.router.post('/increment', this.incrementMetric.bind(this)); + } + + /** + * GET /usage/tenant/:tenantId/current + * Get current usage for tenant + */ + private async getCurrentUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const usage = await this.service.getCurrentUsage(req.params.tenantId); + res.json({ data: usage }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/summary + * Get usage summary with limits + */ + private async getUsageSummary(req: Request, res: Response, next: NextFunction): Promise { + try { + const summary = await this.service.getUsageSummary(req.params.tenantId); + res.json({ data: summary }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/limits + * Check if tenant exceeds limits + */ + private async checkLimits(req: Request, res: Response, next: NextFunction): Promise { + try { + const limits = await this.service.checkLimits(req.params.tenantId); + res.json({ data: limits }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/history + * Get usage history + */ + private async getUsageHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ error: 'startDate and endDate are required' }); + return; + } + + const history = await this.service.getUsageHistory( + req.params.tenantId, + new Date(startDate as string), + new Date(endDate as string) + ); + + res.json({ data: history }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/report + * Get usage report + */ + private async getUsageReport(req: Request, res: Response, next: NextFunction): Promise { + try { + const { startDate, endDate, granularity } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ error: 'startDate and endDate are required' }); + return; + } + + const report = await this.service.getUsageReport( + req.params.tenantId, + new Date(startDate as string), + new Date(endDate as string), + (granularity as 'daily' | 'weekly' | 'monthly') || 'monthly' + ); + + res.json({ data: report }); + } catch (error) { + next(error); + } + } + + /** + * POST /usage + * Record usage for period + */ + private async recordUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: RecordUsageDto = req.body; + const usage = await this.service.recordUsage(dto); + res.status(201).json({ data: usage }); + } catch (error) { + next(error); + } + } + + /** + * PUT /usage/:id + * Update usage record + */ + private async updateUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateUsageDto = req.body; + const usage = await this.service.update(req.params.id, dto); + res.json({ data: usage }); + } catch (error) { + next(error); + } + } + + /** + * POST /usage/increment + * Increment a specific metric + */ + private async incrementMetric(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: IncrementUsageDto = req.body; + await this.service.incrementMetric( + dto.tenantId, + dto.metric as keyof UsageMetrics, + dto.amount || 1 + ); + res.json({ success: true }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/dto/create-invoice.dto.ts b/src/modules/billing-usage/dto/create-invoice.dto.ts new file mode 100644 index 0000000..ff5435e --- /dev/null +++ b/src/modules/billing-usage/dto/create-invoice.dto.ts @@ -0,0 +1,75 @@ +/** + * Create Invoice DTO + */ + +import { InvoiceStatus, InvoiceItemType } from '../entities'; + +export class CreateInvoiceDto { + tenantId: string; + subscriptionId?: string; + invoiceDate?: Date; + periodStart: Date; + periodEnd: Date; + billingName?: string; + billingEmail?: string; + billingAddress?: Record; + taxId?: string; + dueDate: Date; + currency?: string; + notes?: string; + internalNotes?: string; + items: CreateInvoiceItemDto[]; +} + +export class CreateInvoiceItemDto { + itemType: InvoiceItemType; + description: string; + quantity: number; + unitPrice: number; + discountPercent?: number; + metadata?: Record; +} + +export class UpdateInvoiceDto { + billingName?: string; + billingEmail?: string; + billingAddress?: Record; + taxId?: string; + dueDate?: Date; + notes?: string; + internalNotes?: string; +} + +export class RecordPaymentDto { + amount: number; + paymentMethod: string; + paymentReference?: string; + paymentDate?: Date; +} + +export class VoidInvoiceDto { + reason: string; +} + +export class RefundInvoiceDto { + amount?: number; + reason: string; +} + +export class GenerateInvoiceDto { + tenantId: string; + subscriptionId: string; + periodStart: Date; + periodEnd: Date; + includeUsageCharges?: boolean; +} + +export class InvoiceFilterDto { + tenantId?: string; + status?: InvoiceStatus; + dateFrom?: Date; + dateTo?: Date; + overdue?: boolean; + limit?: number; + offset?: number; +} diff --git a/src/modules/billing-usage/dto/create-subscription-plan.dto.ts b/src/modules/billing-usage/dto/create-subscription-plan.dto.ts new file mode 100644 index 0000000..5fc1272 --- /dev/null +++ b/src/modules/billing-usage/dto/create-subscription-plan.dto.ts @@ -0,0 +1,41 @@ +/** + * Create Subscription Plan DTO + */ + +import { PlanType } from '../entities'; + +export class CreateSubscriptionPlanDto { + code: string; + name: string; + description?: string; + planType?: PlanType; + baseMonthlyPrice: number; + baseAnnualPrice?: number; + setupFee?: number; + maxUsers?: number; + maxBranches?: number; + storageGb?: number; + apiCallsMonthly?: number; + includedModules?: string[]; + includedPlatforms?: string[]; + features?: Record; + isActive?: boolean; + isPublic?: boolean; +} + +export class UpdateSubscriptionPlanDto { + name?: string; + description?: string; + baseMonthlyPrice?: number; + baseAnnualPrice?: number; + setupFee?: number; + maxUsers?: number; + maxBranches?: number; + storageGb?: number; + apiCallsMonthly?: number; + includedModules?: string[]; + includedPlatforms?: string[]; + features?: Record; + isActive?: boolean; + isPublic?: boolean; +} diff --git a/src/modules/billing-usage/dto/create-subscription.dto.ts b/src/modules/billing-usage/dto/create-subscription.dto.ts new file mode 100644 index 0000000..cdb1bac --- /dev/null +++ b/src/modules/billing-usage/dto/create-subscription.dto.ts @@ -0,0 +1,57 @@ +/** + * Create Tenant Subscription DTO + */ + +import { BillingCycle, SubscriptionStatus } from '../entities'; + +export class CreateTenantSubscriptionDto { + tenantId: string; + planId: string; + billingCycle?: BillingCycle; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + billingEmail?: string; + billingName?: string; + billingAddress?: Record; + taxId?: string; + currentPrice: number; + discountPercent?: number; + discountReason?: string; + contractedUsers?: number; + contractedBranches?: number; + autoRenew?: boolean; + // Trial + startWithTrial?: boolean; + trialDays?: number; +} + +export class UpdateTenantSubscriptionDto { + planId?: string; + billingCycle?: BillingCycle; + billingEmail?: string; + billingName?: string; + billingAddress?: Record; + taxId?: string; + currentPrice?: number; + discountPercent?: number; + discountReason?: string; + contractedUsers?: number; + contractedBranches?: number; + autoRenew?: boolean; +} + +export class CancelSubscriptionDto { + reason?: string; + cancelImmediately?: boolean; +} + +export class ChangePlanDto { + newPlanId: string; + effectiveDate?: Date; + prorateBilling?: boolean; +} + +export class SetPaymentMethodDto { + paymentMethodId: string; + paymentProvider: string; +} diff --git a/src/modules/billing-usage/dto/index.ts b/src/modules/billing-usage/dto/index.ts new file mode 100644 index 0000000..197e989 --- /dev/null +++ b/src/modules/billing-usage/dto/index.ts @@ -0,0 +1,8 @@ +/** + * Billing Usage DTOs Index + */ + +export * from './create-subscription-plan.dto'; +export * from './create-subscription.dto'; +export * from './create-invoice.dto'; +export * from './usage-tracking.dto'; diff --git a/src/modules/billing-usage/dto/usage-tracking.dto.ts b/src/modules/billing-usage/dto/usage-tracking.dto.ts new file mode 100644 index 0000000..b728664 --- /dev/null +++ b/src/modules/billing-usage/dto/usage-tracking.dto.ts @@ -0,0 +1,90 @@ +/** + * Usage Tracking DTO + */ + +export class RecordUsageDto { + tenantId: string; + periodStart: Date; + periodEnd: Date; + activeUsers?: number; + peakConcurrentUsers?: number; + usersByProfile?: Record; + usersByPlatform?: Record; + activeBranches?: number; + storageUsedGb?: number; + documentsCount?: number; + apiCalls?: number; + apiErrors?: number; + salesCount?: number; + salesAmount?: number; + invoicesGenerated?: number; + mobileSessions?: number; + offlineSyncs?: number; + paymentTransactions?: number; +} + +export class UpdateUsageDto { + activeUsers?: number; + peakConcurrentUsers?: number; + usersByProfile?: Record; + usersByPlatform?: Record; + activeBranches?: number; + storageUsedGb?: number; + documentsCount?: number; + apiCalls?: number; + apiErrors?: number; + salesCount?: number; + salesAmount?: number; + invoicesGenerated?: number; + mobileSessions?: number; + offlineSyncs?: number; + paymentTransactions?: number; +} + +export class IncrementUsageDto { + tenantId: string; + metric: keyof UsageMetrics; + amount?: number; +} + +export interface UsageMetrics { + apiCalls: number; + apiErrors: number; + salesCount: number; + salesAmount: number; + invoicesGenerated: number; + mobileSessions: number; + offlineSyncs: number; + paymentTransactions: number; + documentsCount: number; + storageUsedGb: number; +} + +export class UsageReportDto { + tenantId: string; + startDate: Date; + endDate: Date; + granularity?: 'daily' | 'weekly' | 'monthly'; +} + +export class UsageSummaryDto { + tenantId: string; + currentUsers: number; + currentBranches: number; + currentStorageGb: number; + apiCallsThisMonth: number; + salesThisMonth: number; + salesAmountThisMonth: number; + limits: { + maxUsers: number; + maxBranches: number; + maxStorageGb: number; + maxApiCalls: number; + }; + percentages: { + usersUsed: number; + branchesUsed: number; + storageUsed: number; + apiCallsUsed: number; + }; +} diff --git a/src/modules/billing-usage/entities/billing-alert.entity.ts b/src/modules/billing-usage/entities/billing-alert.entity.ts new file mode 100644 index 0000000..b6afbdc --- /dev/null +++ b/src/modules/billing-usage/entities/billing-alert.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BillingAlertType = + | 'usage_limit' + | 'payment_due' + | 'payment_failed' + | 'trial_ending' + | 'subscription_ending'; + +export type AlertSeverity = 'info' | 'warning' | 'critical'; +export type AlertStatus = 'active' | 'acknowledged' | 'resolved'; + +/** + * Entidad para alertas de facturacion y limites de uso. + * Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'billing_alerts', schema: 'billing' }) +export class BillingAlert { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de alerta + @Index() + @Column({ name: 'alert_type', type: 'varchar', length: 30 }) + alertType: BillingAlertType; + + // Detalles + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + message: string; + + @Column({ type: 'varchar', length: 20, default: 'info' }) + severity: AlertSeverity; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: AlertStatus; + + // Notificacion + @Column({ name: 'notified_at', type: 'timestamptz', nullable: true }) + notifiedAt: Date; + + @Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true }) + acknowledgedAt: Date; + + @Column({ name: 'acknowledged_by', type: 'uuid', nullable: true }) + acknowledgedBy: string; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/billing-usage/entities/index.ts b/src/modules/billing-usage/entities/index.ts new file mode 100644 index 0000000..90d2d55 --- /dev/null +++ b/src/modules/billing-usage/entities/index.ts @@ -0,0 +1,8 @@ +export { SubscriptionPlan, PlanType } from './subscription-plan.entity'; +export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity'; +export { UsageTracking } from './usage-tracking.entity'; +export { UsageEvent, EventCategory } from './usage-event.entity'; +export { Invoice, InvoiceStatus } from './invoice.entity'; +export { InvoiceItem, InvoiceItemType } from './invoice-item.entity'; +export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity'; +export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity'; diff --git a/src/modules/billing-usage/entities/invoice-item.entity.ts b/src/modules/billing-usage/entities/invoice-item.entity.ts new file mode 100644 index 0000000..d8aecac --- /dev/null +++ b/src/modules/billing-usage/entities/invoice-item.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from './invoice.entity'; + +export type InvoiceItemType = 'subscription' | 'user' | 'profile' | 'overage' | 'addon'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + // Descripcion + @Column({ type: 'varchar', length: 500 }) + description: string; + + @Index() + @Column({ name: 'item_type', type: 'varchar', length: 30 }) + itemType: InvoiceItemType; + + // Cantidades + @Column({ type: 'integer', default: 1 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + // Detalles adicionales + @Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true }) + profileCode: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'period_start', type: 'date', nullable: true }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date', nullable: true }) + periodEnd: Date; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; +} diff --git a/src/modules/billing-usage/entities/invoice.entity.ts b/src/modules/billing-usage/entities/invoice.entity.ts new file mode 100644 index 0000000..557dadf --- /dev/null +++ b/src/modules/billing-usage/entities/invoice.entity.ts @@ -0,0 +1,121 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { InvoiceItem } from './invoice-item.entity'; + +export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded'; + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'subscription_id', type: 'uuid', nullable: true }) + subscriptionId: string; + + // Numero de factura + @Index({ unique: true }) + @Column({ name: 'invoice_number', type: 'varchar', length: 30 }) + invoiceNumber: string; + + @Index() + @Column({ name: 'invoice_date', type: 'date' }) + invoiceDate: Date; + + // Periodo facturado + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + // Cliente + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string; + + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', default: {} }) + billingAddress: Record; + + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; + + // Montos + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: InvoiceStatus; + + // Fechas de pago + @Index() + @Column({ name: 'due_date', type: 'date' }) + dueDate: Date; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date; + + @Column({ name: 'paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + paidAmount: number; + + // Detalles de pago + @Column({ name: 'payment_method', type: 'varchar', length: 30, nullable: true }) + paymentMethod: string; + + @Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true }) + paymentReference: string; + + // CFDI (para Mexico) + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 36, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_xml', type: 'text', nullable: true }) + cfdiXml: string; + + @Column({ name: 'cfdi_pdf_url', type: 'text', nullable: true }) + cfdiPdfUrl: string; + + // Metadata + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true }) + items: InvoiceItem[]; +} diff --git a/src/modules/billing-usage/entities/payment-method.entity.ts b/src/modules/billing-usage/entities/payment-method.entity.ts new file mode 100644 index 0000000..2f2e819 --- /dev/null +++ b/src/modules/billing-usage/entities/payment-method.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PaymentProvider = 'stripe' | 'mercadopago' | 'bank_transfer'; +export type PaymentMethodType = 'card' | 'bank_account' | 'wallet'; + +/** + * Entidad para metodos de pago guardados por tenant. + * Almacena informacion tokenizada/encriptada de metodos de pago. + * Mapea a billing.payment_methods (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'payment_methods', schema: 'billing' }) +export class BillingPaymentMethod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Proveedor + @Index() + @Column({ type: 'varchar', length: 30 }) + provider: PaymentProvider; + + // Tipo + @Column({ name: 'method_type', type: 'varchar', length: 20 }) + methodType: PaymentMethodType; + + // Datos tokenizados del proveedor + @Column({ name: 'provider_customer_id', type: 'varchar', length: 255, nullable: true }) + providerCustomerId: string; + + @Column({ name: 'provider_method_id', type: 'varchar', length: 255, nullable: true }) + providerMethodId: string; + + // Display info (no sensible) + @Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true }) + displayName: string; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string; + + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_exp_month', type: 'integer', nullable: true }) + cardExpMonth: number; + + @Column({ name: 'card_exp_year', type: 'integer', nullable: true }) + cardExpYear: number; + + @Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true }) + bankName: string; + + @Column({ name: 'bank_last_four', type: 'varchar', length: 4, nullable: true }) + bankLastFour: string; + + // Estado + @Index() + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/billing-usage/entities/subscription-plan.entity.ts b/src/modules/billing-usage/entities/subscription-plan.entity.ts new file mode 100644 index 0000000..324e7c3 --- /dev/null +++ b/src/modules/billing-usage/entities/subscription-plan.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PlanType = 'saas' | 'on_premise' | 'hybrid'; + +@Entity({ name: 'subscription_plans', schema: 'billing' }) +export class SubscriptionPlan { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Identificacion + @Index({ unique: true }) + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Column({ name: 'plan_type', type: 'varchar', length: 20, default: 'saas' }) + planType: PlanType; + + // Precios base + @Column({ name: 'base_monthly_price', type: 'decimal', precision: 12, scale: 2, default: 0 }) + baseMonthlyPrice: number; + + @Column({ name: 'base_annual_price', type: 'decimal', precision: 12, scale: 2, nullable: true }) + baseAnnualPrice: number; + + @Column({ name: 'setup_fee', type: 'decimal', precision: 12, scale: 2, default: 0 }) + setupFee: number; + + // Limites base + @Column({ name: 'max_users', type: 'integer', default: 5 }) + maxUsers: number; + + @Column({ name: 'max_branches', type: 'integer', default: 1 }) + maxBranches: number; + + @Column({ name: 'storage_gb', type: 'integer', default: 10 }) + storageGb: number; + + @Column({ name: 'api_calls_monthly', type: 'integer', default: 10000 }) + apiCallsMonthly: number; + + // Modulos incluidos + @Column({ name: 'included_modules', type: 'text', array: true, default: [] }) + includedModules: string[]; + + // Plataformas incluidas + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; + + // Features + @Column({ type: 'jsonb', default: {} }) + features: Record; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_public', type: 'boolean', default: true }) + isPublic: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/billing-usage/entities/tenant-subscription.entity.ts b/src/modules/billing-usage/entities/tenant-subscription.entity.ts new file mode 100644 index 0000000..5cdc50e --- /dev/null +++ b/src/modules/billing-usage/entities/tenant-subscription.entity.ts @@ -0,0 +1,117 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { SubscriptionPlan } from './subscription-plan.entity'; + +export type BillingCycle = 'monthly' | 'annual'; +export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended'; + +@Entity({ name: 'tenant_subscriptions', schema: 'billing' }) +@Unique(['tenantId']) +export class TenantSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'plan_id', type: 'uuid' }) + planId: string; + + // Periodo + @Column({ name: 'billing_cycle', type: 'varchar', length: 20, default: 'monthly' }) + billingCycle: BillingCycle; + + @Column({ name: 'current_period_start', type: 'timestamptz' }) + currentPeriodStart: Date; + + @Column({ name: 'current_period_end', type: 'timestamptz' }) + currentPeriodEnd: Date; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: SubscriptionStatus; + + // Trial + @Column({ name: 'trial_start', type: 'timestamptz', nullable: true }) + trialStart: Date; + + @Column({ name: 'trial_end', type: 'timestamptz', nullable: true }) + trialEnd: Date; + + // Configuracion de facturacion + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string; + + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string; + + @Column({ name: 'billing_address', type: 'jsonb', default: {} }) + billingAddress: Record; + + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; // RFC para Mexico + + // Metodo de pago + @Column({ name: 'payment_method_id', type: 'uuid', nullable: true }) + paymentMethodId: string; + + @Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true }) + paymentProvider: string; // stripe, mercadopago, bank_transfer + + // Precios actuales + @Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 }) + currentPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_reason', type: 'varchar', length: 100, nullable: true }) + discountReason: string; + + // Uso contratado + @Column({ name: 'contracted_users', type: 'integer', nullable: true }) + contractedUsers: number; + + @Column({ name: 'contracted_branches', type: 'integer', nullable: true }) + contractedBranches: number; + + // Facturacion automatica + @Column({ name: 'auto_renew', type: 'boolean', default: true }) + autoRenew: boolean; + + @Column({ name: 'next_invoice_date', type: 'date', nullable: true }) + nextInvoiceDate: Date; + + // Cancelacion + @Column({ name: 'cancel_at_period_end', type: 'boolean', default: false }) + cancelAtPeriodEnd: boolean; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => SubscriptionPlan) + @JoinColumn({ name: 'plan_id' }) + plan: SubscriptionPlan; +} diff --git a/src/modules/billing-usage/entities/usage-event.entity.ts b/src/modules/billing-usage/entities/usage-event.entity.ts new file mode 100644 index 0000000..ab29f61 --- /dev/null +++ b/src/modules/billing-usage/entities/usage-event.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'user' | 'api' | 'storage' | 'transaction' | 'mobile'; + +/** + * Entidad para eventos de uso en tiempo real. + * Utilizada para calculo de billing y tracking granular. + * Mapea a billing.usage_events (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'usage_events', schema: 'billing' }) +export class UsageEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Evento + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 50 }) + eventType: string; // login, api_call, document_upload, sale, invoice, sync + + @Index() + @Column({ name: 'event_category', type: 'varchar', length: 30 }) + eventCategory: EventCategory; + + // Detalles + @Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true }) + profileCode: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'resource_type', type: 'varchar', length: 50, nullable: true }) + resourceType: string; + + // Metricas + @Column({ type: 'integer', default: 1 }) + quantity: number; + + @Column({ name: 'bytes_used', type: 'bigint', default: 0 }) + bytesUsed: number; + + @Column({ name: 'duration_ms', type: 'integer', nullable: true }) + durationMs: number; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/billing-usage/entities/usage-tracking.entity.ts b/src/modules/billing-usage/entities/usage-tracking.entity.ts new file mode 100644 index 0000000..d5ad4b3 --- /dev/null +++ b/src/modules/billing-usage/entities/usage-tracking.entity.ts @@ -0,0 +1,91 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'usage_tracking', schema: 'billing' }) +@Unique(['tenantId', 'periodStart']) +export class UsageTracking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Periodo + @Index() + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + // Usuarios + @Column({ name: 'active_users', type: 'integer', default: 0 }) + activeUsers: number; + + @Column({ name: 'peak_concurrent_users', type: 'integer', default: 0 }) + peakConcurrentUsers: number; + + // Por perfil + @Column({ name: 'users_by_profile', type: 'jsonb', default: {} }) + usersByProfile: Record; // {"ADM": 2, "VNT": 5, "ALM": 3} + + // Por plataforma + @Column({ name: 'users_by_platform', type: 'jsonb', default: {} }) + usersByPlatform: Record; // {"web": 8, "mobile": 5, "desktop": 0} + + // Sucursales + @Column({ name: 'active_branches', type: 'integer', default: 0 }) + activeBranches: number; + + // Storage + @Column({ name: 'storage_used_gb', type: 'decimal', precision: 10, scale: 2, default: 0 }) + storageUsedGb: number; + + @Column({ name: 'documents_count', type: 'integer', default: 0 }) + documentsCount: number; + + // API + @Column({ name: 'api_calls', type: 'integer', default: 0 }) + apiCalls: number; + + @Column({ name: 'api_errors', type: 'integer', default: 0 }) + apiErrors: number; + + // Transacciones + @Column({ name: 'sales_count', type: 'integer', default: 0 }) + salesCount: number; + + @Column({ name: 'sales_amount', type: 'decimal', precision: 14, scale: 2, default: 0 }) + salesAmount: number; + + @Column({ name: 'invoices_generated', type: 'integer', default: 0 }) + invoicesGenerated: number; + + // Mobile + @Column({ name: 'mobile_sessions', type: 'integer', default: 0 }) + mobileSessions: number; + + @Column({ name: 'offline_syncs', type: 'integer', default: 0 }) + offlineSyncs: number; + + @Column({ name: 'payment_transactions', type: 'integer', default: 0 }) + paymentTransactions: number; + + // Calculado + @Column({ name: 'total_billable_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalBillableAmount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/billing-usage/index.ts b/src/modules/billing-usage/index.ts new file mode 100644 index 0000000..08dc806 --- /dev/null +++ b/src/modules/billing-usage/index.ts @@ -0,0 +1,18 @@ +/** + * Billing Usage Module Index + */ + +// Module +export { BillingUsageModule, BillingUsageModuleOptions } from './billing-usage.module'; + +// Entities +export * from './entities'; + +// DTOs +export * from './dto'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/billing-usage/services/index.ts b/src/modules/billing-usage/services/index.ts new file mode 100644 index 0000000..c0d4392 --- /dev/null +++ b/src/modules/billing-usage/services/index.ts @@ -0,0 +1,8 @@ +/** + * Billing Usage Services Index + */ + +export { SubscriptionPlansService } from './subscription-plans.service'; +export { SubscriptionsService } from './subscriptions.service'; +export { UsageTrackingService } from './usage-tracking.service'; +export { InvoicesService } from './invoices.service'; diff --git a/src/modules/billing-usage/services/invoices.service.ts b/src/modules/billing-usage/services/invoices.service.ts new file mode 100644 index 0000000..117fc8e --- /dev/null +++ b/src/modules/billing-usage/services/invoices.service.ts @@ -0,0 +1,471 @@ +/** + * Invoices Service + * + * Service for managing invoices + */ + +import { Repository, DataSource, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { Invoice, InvoiceItem, InvoiceStatus, TenantSubscription, UsageTracking } from '../entities'; +import { + CreateInvoiceDto, + UpdateInvoiceDto, + RecordPaymentDto, + VoidInvoiceDto, + RefundInvoiceDto, + GenerateInvoiceDto, + InvoiceFilterDto, +} from '../dto'; + +export class InvoicesService { + private invoiceRepository: Repository; + private itemRepository: Repository; + private subscriptionRepository: Repository; + private usageRepository: Repository; + + constructor(private dataSource: DataSource) { + this.invoiceRepository = dataSource.getRepository(Invoice); + this.itemRepository = dataSource.getRepository(InvoiceItem); + this.subscriptionRepository = dataSource.getRepository(TenantSubscription); + this.usageRepository = dataSource.getRepository(UsageTracking); + } + + /** + * Create invoice manually + */ + async create(dto: CreateInvoiceDto): Promise { + const invoiceNumber = await this.generateInvoiceNumber(); + + // Calculate totals + let subtotal = 0; + for (const item of dto.items) { + const itemTotal = item.quantity * item.unitPrice; + const discount = itemTotal * ((item.discountPercent || 0) / 100); + subtotal += itemTotal - discount; + } + + const taxAmount = subtotal * 0.16; // 16% IVA for Mexico + const total = subtotal + taxAmount; + + const invoice = this.invoiceRepository.create({ + tenantId: dto.tenantId, + subscriptionId: dto.subscriptionId, + invoiceNumber, + invoiceDate: dto.invoiceDate || new Date(), + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + billingName: dto.billingName, + billingEmail: dto.billingEmail, + billingAddress: dto.billingAddress || {}, + taxId: dto.taxId, + subtotal, + taxAmount, + discountAmount: 0, + total, + currency: dto.currency || 'MXN', + status: 'draft', + dueDate: dto.dueDate, + notes: dto.notes, + internalNotes: dto.internalNotes, + }); + + const savedInvoice = await this.invoiceRepository.save(invoice); + + // Create items + for (const itemDto of dto.items) { + const itemTotal = itemDto.quantity * itemDto.unitPrice; + const discount = itemTotal * ((itemDto.discountPercent || 0) / 100); + + const item = this.itemRepository.create({ + invoiceId: savedInvoice.id, + itemType: itemDto.itemType, + description: itemDto.description, + quantity: itemDto.quantity, + unitPrice: itemDto.unitPrice, + discountPercent: itemDto.discountPercent || 0, + subtotal: itemTotal - discount, + metadata: itemDto.metadata || {}, + }); + + await this.itemRepository.save(item); + } + + return this.findById(savedInvoice.id) as Promise; + } + + /** + * Generate invoice automatically from subscription + */ + async generateFromSubscription(dto: GenerateInvoiceDto): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { id: dto.subscriptionId }, + relations: ['plan'], + }); + + if (!subscription) { + throw new Error('Subscription not found'); + } + + const items: CreateInvoiceDto['items'] = []; + + // Base subscription fee + items.push({ + itemType: 'subscription', + description: `Suscripcion ${subscription.plan.name} - ${subscription.billingCycle === 'annual' ? 'Anual' : 'Mensual'}`, + quantity: 1, + unitPrice: Number(subscription.currentPrice), + }); + + // Include usage charges if requested + if (dto.includeUsageCharges) { + const usage = await this.usageRepository.findOne({ + where: { + tenantId: dto.tenantId, + periodStart: dto.periodStart, + }, + }); + + if (usage) { + // Extra users + const extraUsers = Math.max( + 0, + usage.activeUsers - (subscription.contractedUsers || subscription.plan.maxUsers) + ); + if (extraUsers > 0) { + items.push({ + itemType: 'overage', + description: `Usuarios adicionales (${extraUsers})`, + quantity: extraUsers, + unitPrice: 10, // $10 per extra user + metadata: { metric: 'extra_users' }, + }); + } + + // Extra branches + const extraBranches = Math.max( + 0, + usage.activeBranches - (subscription.contractedBranches || subscription.plan.maxBranches) + ); + if (extraBranches > 0) { + items.push({ + itemType: 'overage', + description: `Sucursales adicionales (${extraBranches})`, + quantity: extraBranches, + unitPrice: 20, // $20 per extra branch + metadata: { metric: 'extra_branches' }, + }); + } + + // Extra storage + const extraStorageGb = Math.max( + 0, + Number(usage.storageUsedGb) - subscription.plan.storageGb + ); + if (extraStorageGb > 0) { + items.push({ + itemType: 'overage', + description: `Almacenamiento adicional (${extraStorageGb} GB)`, + quantity: Math.ceil(extraStorageGb), + unitPrice: 0.5, // $0.50 per GB + metadata: { metric: 'extra_storage' }, + }); + } + } + } + + // Calculate due date (15 days from invoice date) + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 15); + + return this.create({ + tenantId: dto.tenantId, + subscriptionId: dto.subscriptionId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + billingName: subscription.billingName, + billingEmail: subscription.billingEmail, + billingAddress: subscription.billingAddress, + taxId: subscription.taxId, + dueDate, + items, + }); + } + + /** + * Find invoice by ID + */ + async findById(id: string): Promise { + return this.invoiceRepository.findOne({ + where: { id }, + relations: ['items'], + }); + } + + /** + * Find invoice by number + */ + async findByNumber(invoiceNumber: string): Promise { + return this.invoiceRepository.findOne({ + where: { invoiceNumber }, + relations: ['items'], + }); + } + + /** + * Find invoices with filters + */ + async findAll(filter: InvoiceFilterDto): Promise<{ data: Invoice[]; total: number }> { + const query = this.invoiceRepository + .createQueryBuilder('invoice') + .leftJoinAndSelect('invoice.items', 'items'); + + if (filter.tenantId) { + query.andWhere('invoice.tenantId = :tenantId', { tenantId: filter.tenantId }); + } + + if (filter.status) { + query.andWhere('invoice.status = :status', { status: filter.status }); + } + + if (filter.dateFrom) { + query.andWhere('invoice.invoiceDate >= :dateFrom', { dateFrom: filter.dateFrom }); + } + + if (filter.dateTo) { + query.andWhere('invoice.invoiceDate <= :dateTo', { dateTo: filter.dateTo }); + } + + if (filter.overdue) { + query.andWhere('invoice.dueDate < :now', { now: new Date() }); + query.andWhere("invoice.status IN ('sent', 'partial')"); + } + + const total = await query.getCount(); + + query.orderBy('invoice.invoiceDate', 'DESC'); + + if (filter.limit) { + query.take(filter.limit); + } + + if (filter.offset) { + query.skip(filter.offset); + } + + const data = await query.getMany(); + + return { data, total }; + } + + /** + * Update invoice + */ + async update(id: string, dto: UpdateInvoiceDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status !== 'draft') { + throw new Error('Only draft invoices can be updated'); + } + + Object.assign(invoice, dto); + return this.invoiceRepository.save(invoice); + } + + /** + * Send invoice + */ + async send(id: string): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status !== 'draft') { + throw new Error('Only draft invoices can be sent'); + } + + invoice.status = 'sent'; + // TODO: Send email notification to billing email + + return this.invoiceRepository.save(invoice); + } + + /** + * Record payment + */ + async recordPayment(id: string, dto: RecordPaymentDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status === 'void' || invoice.status === 'refunded') { + throw new Error('Cannot record payment for voided or refunded invoice'); + } + + const newPaidAmount = Number(invoice.paidAmount) + dto.amount; + const total = Number(invoice.total); + + invoice.paidAmount = newPaidAmount; + invoice.paymentMethod = dto.paymentMethod; + invoice.paymentReference = dto.paymentReference; + + if (newPaidAmount >= total) { + invoice.status = 'paid'; + invoice.paidAt = dto.paymentDate || new Date(); + } else if (newPaidAmount > 0) { + invoice.status = 'partial'; + } + + return this.invoiceRepository.save(invoice); + } + + /** + * Void invoice + */ + async void(id: string, dto: VoidInvoiceDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status === 'paid' || invoice.status === 'refunded') { + throw new Error('Cannot void paid or refunded invoice'); + } + + invoice.status = 'void'; + invoice.internalNotes = `${invoice.internalNotes || ''}\n\nVoided: ${dto.reason}`.trim(); + + return this.invoiceRepository.save(invoice); + } + + /** + * Refund invoice + */ + async refund(id: string, dto: RefundInvoiceDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status !== 'paid' && invoice.status !== 'partial') { + throw new Error('Only paid invoices can be refunded'); + } + + const refundAmount = dto.amount || Number(invoice.paidAmount); + + if (refundAmount > Number(invoice.paidAmount)) { + throw new Error('Refund amount cannot exceed paid amount'); + } + + invoice.status = 'refunded'; + invoice.internalNotes = + `${invoice.internalNotes || ''}\n\nRefunded: ${refundAmount} - ${dto.reason}`.trim(); + + // TODO: Process actual refund through payment provider + + return this.invoiceRepository.save(invoice); + } + + /** + * Mark overdue invoices + */ + async markOverdueInvoices(): Promise { + const now = new Date(); + + const result = await this.invoiceRepository + .createQueryBuilder() + .update(Invoice) + .set({ status: 'overdue' }) + .where("status IN ('sent', 'partial')") + .andWhere('dueDate < :now', { now }) + .execute(); + + return result.affected || 0; + } + + /** + * Get invoice statistics + */ + async getStats(tenantId?: string): Promise<{ + total: number; + byStatus: Record; + totalRevenue: number; + pendingAmount: number; + overdueAmount: number; + }> { + const query = this.invoiceRepository.createQueryBuilder('invoice'); + + if (tenantId) { + query.where('invoice.tenantId = :tenantId', { tenantId }); + } + + const invoices = await query.getMany(); + + const byStatus: Record = { + draft: 0, + sent: 0, + paid: 0, + partial: 0, + overdue: 0, + void: 0, + refunded: 0, + }; + + let totalRevenue = 0; + let pendingAmount = 0; + let overdueAmount = 0; + const now = new Date(); + + for (const invoice of invoices) { + byStatus[invoice.status]++; + + if (invoice.status === 'paid') { + totalRevenue += Number(invoice.paidAmount); + } + + if (invoice.status === 'sent' || invoice.status === 'partial') { + const pending = Number(invoice.total) - Number(invoice.paidAmount); + pendingAmount += pending; + + if (invoice.dueDate < now) { + overdueAmount += pending; + } + } + } + + return { + total: invoices.length, + byStatus, + totalRevenue, + pendingAmount, + overdueAmount, + }; + } + + /** + * Generate unique invoice number + */ + private async generateInvoiceNumber(): Promise { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + + // Get last invoice number for this month + const lastInvoice = await this.invoiceRepository + .createQueryBuilder('invoice') + .where('invoice.invoiceNumber LIKE :pattern', { pattern: `INV-${year}${month}%` }) + .orderBy('invoice.invoiceNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastInvoice) { + const lastSequence = parseInt(lastInvoice.invoiceNumber.slice(-4), 10); + sequence = lastSequence + 1; + } + + return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`; + } +} diff --git a/src/modules/billing-usage/services/subscription-plans.service.ts b/src/modules/billing-usage/services/subscription-plans.service.ts new file mode 100644 index 0000000..c4c8dbd --- /dev/null +++ b/src/modules/billing-usage/services/subscription-plans.service.ts @@ -0,0 +1,200 @@ +/** + * Subscription Plans Service + * + * Service for managing subscription plans + */ + +import { Repository, DataSource } from 'typeorm'; +import { SubscriptionPlan, PlanType } from '../entities'; +import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto'; + +export class SubscriptionPlansService { + private planRepository: Repository; + + constructor(private dataSource: DataSource) { + this.planRepository = dataSource.getRepository(SubscriptionPlan); + } + + /** + * Create a new subscription plan + */ + async create(dto: CreateSubscriptionPlanDto): Promise { + // Check if code already exists + const existing = await this.planRepository.findOne({ + where: { code: dto.code }, + }); + + if (existing) { + throw new Error(`Plan with code ${dto.code} already exists`); + } + + const plan = this.planRepository.create({ + code: dto.code, + name: dto.name, + description: dto.description, + planType: dto.planType || 'saas', + baseMonthlyPrice: dto.baseMonthlyPrice, + baseAnnualPrice: dto.baseAnnualPrice, + setupFee: dto.setupFee || 0, + maxUsers: dto.maxUsers || 5, + maxBranches: dto.maxBranches || 1, + storageGb: dto.storageGb || 10, + apiCallsMonthly: dto.apiCallsMonthly || 10000, + includedModules: dto.includedModules || [], + includedPlatforms: dto.includedPlatforms || ['web'], + features: dto.features || {}, + isActive: dto.isActive !== false, + isPublic: dto.isPublic !== false, + }); + + return this.planRepository.save(plan); + } + + /** + * Find all plans + */ + async findAll(options?: { + isActive?: boolean; + isPublic?: boolean; + planType?: PlanType; + }): Promise { + const query = this.planRepository.createQueryBuilder('plan'); + + if (options?.isActive !== undefined) { + query.andWhere('plan.isActive = :isActive', { isActive: options.isActive }); + } + + if (options?.isPublic !== undefined) { + query.andWhere('plan.isPublic = :isPublic', { isPublic: options.isPublic }); + } + + if (options?.planType) { + query.andWhere('plan.planType = :planType', { planType: options.planType }); + } + + return query.orderBy('plan.baseMonthlyPrice', 'ASC').getMany(); + } + + /** + * Find public plans (for pricing page) + */ + async findPublicPlans(): Promise { + return this.findAll({ isActive: true, isPublic: true }); + } + + /** + * Find plan by ID + */ + async findById(id: string): Promise { + return this.planRepository.findOne({ where: { id } }); + } + + /** + * Find plan by code + */ + async findByCode(code: string): Promise { + return this.planRepository.findOne({ where: { code } }); + } + + /** + * Update a plan + */ + async update(id: string, dto: UpdateSubscriptionPlanDto): Promise { + const plan = await this.findById(id); + if (!plan) { + throw new Error('Plan not found'); + } + + Object.assign(plan, dto); + return this.planRepository.save(plan); + } + + /** + * Soft delete a plan + */ + async delete(id: string): Promise { + const plan = await this.findById(id); + if (!plan) { + throw new Error('Plan not found'); + } + + // Check if plan has active subscriptions + const subscriptionCount = await this.dataSource + .createQueryBuilder() + .select('COUNT(*)') + .from('billing.tenant_subscriptions', 'ts') + .where('ts.plan_id = :planId', { planId: id }) + .andWhere("ts.status IN ('active', 'trial')") + .getRawOne(); + + if (parseInt(subscriptionCount.count) > 0) { + throw new Error('Cannot delete plan with active subscriptions'); + } + + await this.planRepository.softDelete(id); + } + + /** + * Activate/deactivate a plan + */ + async setActive(id: string, isActive: boolean): Promise { + return this.update(id, { isActive }); + } + + /** + * Compare two plans + */ + async comparePlans( + planId1: string, + planId2: string + ): Promise<{ + plan1: SubscriptionPlan; + plan2: SubscriptionPlan; + differences: Record; + }> { + const [plan1, plan2] = await Promise.all([ + this.findById(planId1), + this.findById(planId2), + ]); + + if (!plan1 || !plan2) { + throw new Error('One or both plans not found'); + } + + const fieldsToCompare = [ + 'baseMonthlyPrice', + 'baseAnnualPrice', + 'maxUsers', + 'maxBranches', + 'storageGb', + 'apiCallsMonthly', + ]; + + const differences: Record = {}; + + for (const field of fieldsToCompare) { + if ((plan1 as any)[field] !== (plan2 as any)[field]) { + differences[field] = { + plan1: (plan1 as any)[field], + plan2: (plan2 as any)[field], + }; + } + } + + // Compare included modules + const modules1 = new Set(plan1.includedModules); + const modules2 = new Set(plan2.includedModules); + const modulesDiff = { + onlyInPlan1: plan1.includedModules.filter((m) => !modules2.has(m)), + onlyInPlan2: plan2.includedModules.filter((m) => !modules1.has(m)), + }; + if (modulesDiff.onlyInPlan1.length > 0 || modulesDiff.onlyInPlan2.length > 0) { + differences.includedModules = { + plan1: modulesDiff.onlyInPlan1, + plan2: modulesDiff.onlyInPlan2, + }; + } + + return { plan1, plan2, differences }; + } +} diff --git a/src/modules/billing-usage/services/subscriptions.service.ts b/src/modules/billing-usage/services/subscriptions.service.ts new file mode 100644 index 0000000..c693f05 --- /dev/null +++ b/src/modules/billing-usage/services/subscriptions.service.ts @@ -0,0 +1,384 @@ +/** + * Subscriptions Service + * + * Service for managing tenant subscriptions + */ + +import { Repository, DataSource } from 'typeorm'; +import { + TenantSubscription, + SubscriptionPlan, + BillingCycle, + SubscriptionStatus, +} from '../entities'; +import { + CreateTenantSubscriptionDto, + UpdateTenantSubscriptionDto, + CancelSubscriptionDto, + ChangePlanDto, + SetPaymentMethodDto, +} from '../dto'; + +export class SubscriptionsService { + private subscriptionRepository: Repository; + private planRepository: Repository; + + constructor(private dataSource: DataSource) { + this.subscriptionRepository = dataSource.getRepository(TenantSubscription); + this.planRepository = dataSource.getRepository(SubscriptionPlan); + } + + /** + * Create a new subscription + */ + async create(dto: CreateTenantSubscriptionDto): Promise { + // Check if tenant already has a subscription + const existing = await this.subscriptionRepository.findOne({ + where: { tenantId: dto.tenantId }, + }); + + if (existing) { + throw new Error('Tenant already has a subscription'); + } + + // Validate plan exists + const plan = await this.planRepository.findOne({ where: { id: dto.planId } }); + if (!plan) { + throw new Error('Plan not found'); + } + + const now = new Date(); + const currentPeriodStart = dto.currentPeriodStart || now; + const currentPeriodEnd = + dto.currentPeriodEnd || this.calculatePeriodEnd(currentPeriodStart, dto.billingCycle || 'monthly'); + + const subscription = this.subscriptionRepository.create({ + tenantId: dto.tenantId, + planId: dto.planId, + billingCycle: dto.billingCycle || 'monthly', + currentPeriodStart, + currentPeriodEnd, + status: dto.startWithTrial ? 'trial' : 'active', + billingEmail: dto.billingEmail, + billingName: dto.billingName, + billingAddress: dto.billingAddress || {}, + taxId: dto.taxId, + currentPrice: dto.currentPrice, + discountPercent: dto.discountPercent || 0, + discountReason: dto.discountReason, + contractedUsers: dto.contractedUsers || plan.maxUsers, + contractedBranches: dto.contractedBranches || plan.maxBranches, + autoRenew: dto.autoRenew !== false, + nextInvoiceDate: currentPeriodEnd, + }); + + // Set trial dates if starting with trial + if (dto.startWithTrial) { + subscription.trialStart = now; + subscription.trialEnd = new Date(now.getTime() + (dto.trialDays || 14) * 24 * 60 * 60 * 1000); + } + + return this.subscriptionRepository.save(subscription); + } + + /** + * Find subscription by tenant ID + */ + async findByTenantId(tenantId: string): Promise { + return this.subscriptionRepository.findOne({ + where: { tenantId }, + relations: ['plan'], + }); + } + + /** + * Find subscription by ID + */ + async findById(id: string): Promise { + return this.subscriptionRepository.findOne({ + where: { id }, + relations: ['plan'], + }); + } + + /** + * Update subscription + */ + async update(id: string, dto: UpdateTenantSubscriptionDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + // If changing plan, validate it exists + if (dto.planId && dto.planId !== subscription.planId) { + const plan = await this.planRepository.findOne({ where: { id: dto.planId } }); + if (!plan) { + throw new Error('Plan not found'); + } + } + + Object.assign(subscription, dto); + return this.subscriptionRepository.save(subscription); + } + + /** + * Cancel subscription + */ + async cancel(id: string, dto: CancelSubscriptionDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (subscription.status === 'cancelled') { + throw new Error('Subscription is already cancelled'); + } + + subscription.cancellationReason = dto.reason; + subscription.cancelledAt = new Date(); + + if (dto.cancelImmediately) { + subscription.status = 'cancelled'; + } else { + subscription.cancelAtPeriodEnd = true; + subscription.autoRenew = false; + } + + return this.subscriptionRepository.save(subscription); + } + + /** + * Reactivate cancelled subscription + */ + async reactivate(id: string): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (subscription.status !== 'cancelled' && !subscription.cancelAtPeriodEnd) { + throw new Error('Subscription is not cancelled'); + } + + subscription.status = 'active'; + subscription.cancelAtPeriodEnd = false; + subscription.cancellationReason = null as any; + subscription.cancelledAt = null as any; + subscription.autoRenew = true; + + return this.subscriptionRepository.save(subscription); + } + + /** + * Change subscription plan + */ + async changePlan(id: string, dto: ChangePlanDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + const newPlan = await this.planRepository.findOne({ where: { id: dto.newPlanId } }); + if (!newPlan) { + throw new Error('New plan not found'); + } + + // Calculate new price + const newPrice = + subscription.billingCycle === 'annual' && newPlan.baseAnnualPrice + ? newPlan.baseAnnualPrice + : newPlan.baseMonthlyPrice; + + // Apply existing discount if any + const discountedPrice = newPrice * (1 - (subscription.discountPercent || 0) / 100); + + subscription.planId = dto.newPlanId; + subscription.currentPrice = discountedPrice; + subscription.contractedUsers = newPlan.maxUsers; + subscription.contractedBranches = newPlan.maxBranches; + + // If effective immediately and prorate, calculate adjustment + // This would typically create a credit/debit memo + + return this.subscriptionRepository.save(subscription); + } + + /** + * Set payment method + */ + async setPaymentMethod(id: string, dto: SetPaymentMethodDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + subscription.paymentMethodId = dto.paymentMethodId; + subscription.paymentProvider = dto.paymentProvider; + + return this.subscriptionRepository.save(subscription); + } + + /** + * Renew subscription (for periodic billing) + */ + async renew(id: string): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (!subscription.autoRenew) { + throw new Error('Subscription auto-renew is disabled'); + } + + if (subscription.cancelAtPeriodEnd) { + subscription.status = 'cancelled'; + return this.subscriptionRepository.save(subscription); + } + + // Calculate new period + const newPeriodStart = subscription.currentPeriodEnd; + const newPeriodEnd = this.calculatePeriodEnd(newPeriodStart, subscription.billingCycle); + + subscription.currentPeriodStart = newPeriodStart; + subscription.currentPeriodEnd = newPeriodEnd; + subscription.nextInvoiceDate = newPeriodEnd; + + // Reset trial status if was in trial + if (subscription.status === 'trial') { + subscription.status = 'active'; + } + + return this.subscriptionRepository.save(subscription); + } + + /** + * Mark subscription as past due + */ + async markPastDue(id: string): Promise { + return this.updateStatus(id, 'past_due'); + } + + /** + * Suspend subscription + */ + async suspend(id: string): Promise { + return this.updateStatus(id, 'suspended'); + } + + /** + * Activate subscription (from suspended or past_due) + */ + async activate(id: string): Promise { + return this.updateStatus(id, 'active'); + } + + /** + * Update subscription status + */ + private async updateStatus(id: string, status: SubscriptionStatus): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + subscription.status = status; + return this.subscriptionRepository.save(subscription); + } + + /** + * Find subscriptions expiring soon + */ + async findExpiringSoon(days: number = 7): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + return this.subscriptionRepository + .createQueryBuilder('sub') + .leftJoinAndSelect('sub.plan', 'plan') + .where('sub.currentPeriodEnd <= :futureDate', { futureDate }) + .andWhere("sub.status IN ('active', 'trial')") + .andWhere('sub.cancelAtPeriodEnd = false') + .orderBy('sub.currentPeriodEnd', 'ASC') + .getMany(); + } + + /** + * Find subscriptions with trials ending soon + */ + async findTrialsEndingSoon(days: number = 3): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + return this.subscriptionRepository + .createQueryBuilder('sub') + .leftJoinAndSelect('sub.plan', 'plan') + .where("sub.status = 'trial'") + .andWhere('sub.trialEnd <= :futureDate', { futureDate }) + .orderBy('sub.trialEnd', 'ASC') + .getMany(); + } + + /** + * Calculate period end date based on billing cycle + */ + private calculatePeriodEnd(start: Date, cycle: BillingCycle): Date { + const end = new Date(start); + if (cycle === 'annual') { + end.setFullYear(end.getFullYear() + 1); + } else { + end.setMonth(end.getMonth() + 1); + } + return end; + } + + /** + * Get subscription statistics + */ + async getStats(): Promise<{ + total: number; + byStatus: Record; + byPlan: Record; + totalMRR: number; + totalARR: number; + }> { + const subscriptions = await this.subscriptionRepository.find({ + relations: ['plan'], + }); + + const byStatus: Record = { + trial: 0, + active: 0, + past_due: 0, + cancelled: 0, + suspended: 0, + }; + + const byPlan: Record = {}; + let totalMRR = 0; + + for (const sub of subscriptions) { + byStatus[sub.status]++; + + const planCode = sub.plan?.code || 'unknown'; + byPlan[planCode] = (byPlan[planCode] || 0) + 1; + + if (sub.status === 'active' || sub.status === 'trial') { + const monthlyPrice = + sub.billingCycle === 'annual' + ? Number(sub.currentPrice) / 12 + : Number(sub.currentPrice); + totalMRR += monthlyPrice; + } + } + + return { + total: subscriptions.length, + byStatus, + byPlan, + totalMRR, + totalARR: totalMRR * 12, + }; + } +} diff --git a/src/modules/billing-usage/services/usage-tracking.service.ts b/src/modules/billing-usage/services/usage-tracking.service.ts new file mode 100644 index 0000000..3095bbe --- /dev/null +++ b/src/modules/billing-usage/services/usage-tracking.service.ts @@ -0,0 +1,381 @@ +/** + * Usage Tracking Service + * + * Service for tracking and reporting usage metrics + */ + +import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { UsageTracking, TenantSubscription, SubscriptionPlan } from '../entities'; +import { RecordUsageDto, UpdateUsageDto, UsageMetrics, UsageSummaryDto } from '../dto'; + +export class UsageTrackingService { + private usageRepository: Repository; + private subscriptionRepository: Repository; + private planRepository: Repository; + + constructor(private dataSource: DataSource) { + this.usageRepository = dataSource.getRepository(UsageTracking); + this.subscriptionRepository = dataSource.getRepository(TenantSubscription); + this.planRepository = dataSource.getRepository(SubscriptionPlan); + } + + /** + * Record usage for a period + */ + async recordUsage(dto: RecordUsageDto): Promise { + // Check if record exists for this tenant/period + const existing = await this.usageRepository.findOne({ + where: { + tenantId: dto.tenantId, + periodStart: dto.periodStart, + }, + }); + + if (existing) { + // Update existing record + return this.update(existing.id, dto); + } + + const usage = this.usageRepository.create({ + tenantId: dto.tenantId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + activeUsers: dto.activeUsers || 0, + peakConcurrentUsers: dto.peakConcurrentUsers || 0, + usersByProfile: dto.usersByProfile || {}, + usersByPlatform: dto.usersByPlatform || {}, + activeBranches: dto.activeBranches || 0, + storageUsedGb: dto.storageUsedGb || 0, + documentsCount: dto.documentsCount || 0, + apiCalls: dto.apiCalls || 0, + apiErrors: dto.apiErrors || 0, + salesCount: dto.salesCount || 0, + salesAmount: dto.salesAmount || 0, + invoicesGenerated: dto.invoicesGenerated || 0, + mobileSessions: dto.mobileSessions || 0, + offlineSyncs: dto.offlineSyncs || 0, + paymentTransactions: dto.paymentTransactions || 0, + }); + + // Calculate billable amount + usage.totalBillableAmount = await this.calculateBillableAmount(dto.tenantId, usage); + + return this.usageRepository.save(usage); + } + + /** + * Update usage record + */ + async update(id: string, dto: UpdateUsageDto): Promise { + const usage = await this.usageRepository.findOne({ where: { id } }); + if (!usage) { + throw new Error('Usage record not found'); + } + + Object.assign(usage, dto); + usage.totalBillableAmount = await this.calculateBillableAmount(usage.tenantId, usage); + + return this.usageRepository.save(usage); + } + + /** + * Increment a specific metric + */ + async incrementMetric( + tenantId: string, + metric: keyof UsageMetrics, + amount: number = 1 + ): Promise { + const currentPeriod = this.getCurrentPeriodDates(); + + let usage = await this.usageRepository.findOne({ + where: { + tenantId, + periodStart: currentPeriod.start, + }, + }); + + if (!usage) { + usage = await this.recordUsage({ + tenantId, + periodStart: currentPeriod.start, + periodEnd: currentPeriod.end, + }); + } + + // Increment the specific metric + (usage as any)[metric] = ((usage as any)[metric] || 0) + amount; + + await this.usageRepository.save(usage); + } + + /** + * Get current usage for tenant + */ + async getCurrentUsage(tenantId: string): Promise { + const currentPeriod = this.getCurrentPeriodDates(); + + return this.usageRepository.findOne({ + where: { + tenantId, + periodStart: currentPeriod.start, + }, + }); + } + + /** + * Get usage history for tenant + */ + async getUsageHistory( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.usageRepository.find({ + where: { + tenantId, + periodStart: MoreThanOrEqual(startDate), + periodEnd: LessThanOrEqual(endDate), + }, + order: { periodStart: 'DESC' }, + }); + } + + /** + * Get usage summary with limits comparison + */ + async getUsageSummary(tenantId: string): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { tenantId }, + relations: ['plan'], + }); + + if (!subscription) { + throw new Error('Subscription not found'); + } + + const currentUsage = await this.getCurrentUsage(tenantId); + const plan = subscription.plan; + + const summary: UsageSummaryDto = { + tenantId, + currentUsers: currentUsage?.activeUsers || 0, + currentBranches: currentUsage?.activeBranches || 0, + currentStorageGb: Number(currentUsage?.storageUsedGb || 0), + apiCallsThisMonth: currentUsage?.apiCalls || 0, + salesThisMonth: currentUsage?.salesCount || 0, + salesAmountThisMonth: Number(currentUsage?.salesAmount || 0), + limits: { + maxUsers: subscription.contractedUsers || plan.maxUsers, + maxBranches: subscription.contractedBranches || plan.maxBranches, + maxStorageGb: plan.storageGb, + maxApiCalls: plan.apiCallsMonthly, + }, + percentages: { + usersUsed: 0, + branchesUsed: 0, + storageUsed: 0, + apiCallsUsed: 0, + }, + }; + + // Calculate percentages + summary.percentages.usersUsed = + Math.round((summary.currentUsers / summary.limits.maxUsers) * 100); + summary.percentages.branchesUsed = + Math.round((summary.currentBranches / summary.limits.maxBranches) * 100); + summary.percentages.storageUsed = + Math.round((summary.currentStorageGb / summary.limits.maxStorageGb) * 100); + summary.percentages.apiCallsUsed = + Math.round((summary.apiCallsThisMonth / summary.limits.maxApiCalls) * 100); + + return summary; + } + + /** + * Check if tenant exceeds limits + */ + async checkLimits(tenantId: string): Promise<{ + exceeds: boolean; + violations: string[]; + warnings: string[]; + }> { + const summary = await this.getUsageSummary(tenantId); + const violations: string[] = []; + const warnings: string[] = []; + + // Check hard limits + if (summary.currentUsers > summary.limits.maxUsers) { + violations.push(`Users: ${summary.currentUsers}/${summary.limits.maxUsers}`); + } + + if (summary.currentBranches > summary.limits.maxBranches) { + violations.push(`Branches: ${summary.currentBranches}/${summary.limits.maxBranches}`); + } + + if (summary.currentStorageGb > summary.limits.maxStorageGb) { + violations.push( + `Storage: ${summary.currentStorageGb}GB/${summary.limits.maxStorageGb}GB` + ); + } + + // Check warnings (80% threshold) + if (summary.percentages.usersUsed >= 80 && summary.percentages.usersUsed < 100) { + warnings.push(`Users at ${summary.percentages.usersUsed}% capacity`); + } + + if (summary.percentages.branchesUsed >= 80 && summary.percentages.branchesUsed < 100) { + warnings.push(`Branches at ${summary.percentages.branchesUsed}% capacity`); + } + + if (summary.percentages.storageUsed >= 80 && summary.percentages.storageUsed < 100) { + warnings.push(`Storage at ${summary.percentages.storageUsed}% capacity`); + } + + if (summary.percentages.apiCallsUsed >= 80 && summary.percentages.apiCallsUsed < 100) { + warnings.push(`API calls at ${summary.percentages.apiCallsUsed}% capacity`); + } + + return { + exceeds: violations.length > 0, + violations, + warnings, + }; + } + + /** + * Get usage report + */ + async getUsageReport( + tenantId: string, + startDate: Date, + endDate: Date, + granularity: 'daily' | 'weekly' | 'monthly' = 'monthly' + ): Promise<{ + tenantId: string; + startDate: Date; + endDate: Date; + granularity: string; + data: UsageTracking[]; + totals: { + apiCalls: number; + salesCount: number; + salesAmount: number; + mobileSessions: number; + paymentTransactions: number; + }; + averages: { + activeUsers: number; + activeBranches: number; + storageUsedGb: number; + }; + }> { + const data = await this.getUsageHistory(tenantId, startDate, endDate); + + // Calculate totals + const totals = { + apiCalls: 0, + salesCount: 0, + salesAmount: 0, + mobileSessions: 0, + paymentTransactions: 0, + }; + + let totalUsers = 0; + let totalBranches = 0; + let totalStorage = 0; + + for (const record of data) { + totals.apiCalls += record.apiCalls; + totals.salesCount += record.salesCount; + totals.salesAmount += Number(record.salesAmount); + totals.mobileSessions += record.mobileSessions; + totals.paymentTransactions += record.paymentTransactions; + + totalUsers += record.activeUsers; + totalBranches += record.activeBranches; + totalStorage += Number(record.storageUsedGb); + } + + const count = data.length || 1; + + return { + tenantId, + startDate, + endDate, + granularity, + data, + totals, + averages: { + activeUsers: Math.round(totalUsers / count), + activeBranches: Math.round(totalBranches / count), + storageUsedGb: Math.round((totalStorage / count) * 100) / 100, + }, + }; + } + + /** + * Calculate billable amount based on usage + */ + private async calculateBillableAmount( + tenantId: string, + usage: UsageTracking + ): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { tenantId }, + relations: ['plan'], + }); + + if (!subscription) { + return 0; + } + + let billableAmount = Number(subscription.currentPrice); + + // Add overage charges if applicable + const plan = subscription.plan; + + // Extra users + const extraUsers = Math.max(0, usage.activeUsers - (subscription.contractedUsers || plan.maxUsers)); + if (extraUsers > 0) { + // Assume $10 per extra user per month + billableAmount += extraUsers * 10; + } + + // Extra branches + const extraBranches = Math.max( + 0, + usage.activeBranches - (subscription.contractedBranches || plan.maxBranches) + ); + if (extraBranches > 0) { + // Assume $20 per extra branch per month + billableAmount += extraBranches * 20; + } + + // Extra storage + const extraStorageGb = Math.max(0, Number(usage.storageUsedGb) - plan.storageGb); + if (extraStorageGb > 0) { + // Assume $0.50 per extra GB + billableAmount += extraStorageGb * 0.5; + } + + // Extra API calls + const extraApiCalls = Math.max(0, usage.apiCalls - plan.apiCallsMonthly); + if (extraApiCalls > 0) { + // Assume $0.001 per extra API call + billableAmount += extraApiCalls * 0.001; + } + + return billableAmount; + } + + /** + * Get current period dates (first and last day of current month) + */ + private getCurrentPeriodDates(): { start: Date; end: Date } { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), 1); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); + return { start, end }; + } +} diff --git a/src/modules/biometrics/entities/biometric-credential.entity.ts b/src/modules/biometrics/entities/biometric-credential.entity.ts new file mode 100644 index 0000000..c77fbce --- /dev/null +++ b/src/modules/biometrics/entities/biometric-credential.entity.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Device, BiometricType } from './device.entity'; + +@Entity({ name: 'biometric_credentials', schema: 'auth' }) +@Unique(['deviceId', 'credentialId']) +export class BiometricCredential { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + // Tipo de biometrico + @Index() + @Column({ name: 'biometric_type', type: 'varchar', length: 50 }) + biometricType: BiometricType; + + // Credencial (public key para WebAuthn/FIDO2) + @Column({ name: 'credential_id', type: 'text' }) + credentialId: string; + + @Column({ name: 'public_key', type: 'text' }) + publicKey: string; + + @Column({ type: 'varchar', length: 20, default: 'ES256' }) + algorithm: string; + + // Metadata + @Column({ name: 'credential_name', type: 'varchar', length: 100, nullable: true }) + credentialName: string; // "Huella indice derecho", "Face ID iPhone" + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'use_count', type: 'integer', default: 0 }) + useCount: number; + + // Seguridad + @Column({ name: 'failed_attempts', type: 'integer', default: 0 }) + failedAttempts: number; + + @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) + lockedUntil: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @ManyToOne(() => Device, (device) => device.biometricCredentials, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'device_id' }) + device: Device; +} diff --git a/src/modules/biometrics/entities/device-activity-log.entity.ts b/src/modules/biometrics/entities/device-activity-log.entity.ts new file mode 100644 index 0000000..e245f45 --- /dev/null +++ b/src/modules/biometrics/entities/device-activity-log.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type ActivityType = 'login' | 'logout' | 'biometric_auth' | 'location_update' | 'app_open' | 'app_close'; +export type ActivityStatus = 'success' | 'failed' | 'blocked'; + +@Entity({ name: 'device_activity_log', schema: 'auth' }) +export class DeviceActivityLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + // Actividad + @Index() + @Column({ name: 'activity_type', type: 'varchar', length: 50 }) + activityType: ActivityType; + + @Column({ name: 'activity_status', type: 'varchar', length: 20 }) + activityStatus: ActivityStatus; + + // Detalles + @Column({ type: 'jsonb', default: {} }) + details: Record; + + // Ubicacion + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/biometrics/entities/device-session.entity.ts b/src/modules/biometrics/entities/device-session.entity.ts new file mode 100644 index 0000000..c94ecb4 --- /dev/null +++ b/src/modules/biometrics/entities/device-session.entity.ts @@ -0,0 +1,84 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Device } from './device.entity'; + +export type AuthMethod = 'password' | 'biometric' | 'oauth' | 'mfa'; + +@Entity({ name: 'device_sessions', schema: 'auth' }) +export class DeviceSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + // Tokens + @Index() + @Column({ name: 'access_token_hash', type: 'varchar', length: 255 }) + accessTokenHash: string; + + @Column({ name: 'refresh_token_hash', type: 'varchar', length: 255, nullable: true }) + refreshTokenHash: string; + + // Metodo de autenticacion + @Column({ name: 'auth_method', type: 'varchar', length: 50 }) + authMethod: AuthMethod; + + // Validez + @Column({ name: 'issued_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + issuedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'refresh_expires_at', type: 'timestamptz', nullable: true }) + refreshExpiresAt: Date; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'revoked_reason', type: 'varchar', length: 100, nullable: true }) + revokedReason: string; + + // Ubicacion + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => Device, (device) => device.sessions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'device_id' }) + device: Device; +} diff --git a/src/modules/biometrics/entities/device.entity.ts b/src/modules/biometrics/entities/device.entity.ts new file mode 100644 index 0000000..6ee5295 --- /dev/null +++ b/src/modules/biometrics/entities/device.entity.ts @@ -0,0 +1,121 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, + Unique, +} from 'typeorm'; +import { BiometricCredential } from './biometric-credential.entity'; +import { DeviceSession } from './device-session.entity'; + +export type DevicePlatform = 'ios' | 'android' | 'web' | 'desktop'; +export type BiometricType = 'fingerprint' | 'face_id' | 'face_recognition' | 'iris'; + +@Entity({ name: 'devices', schema: 'auth' }) +@Unique(['userId', 'deviceUuid']) +export class Device { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + // Identificacion del dispositivo + @Index() + @Column({ name: 'device_uuid', type: 'varchar', length: 100 }) + deviceUuid: string; + + @Column({ name: 'device_name', type: 'varchar', length: 100, nullable: true }) + deviceName: string; + + @Column({ name: 'device_model', type: 'varchar', length: 100, nullable: true }) + deviceModel: string; + + @Column({ name: 'device_brand', type: 'varchar', length: 50, nullable: true }) + deviceBrand: string; + + // Plataforma + @Index() + @Column({ type: 'varchar', length: 20 }) + platform: DevicePlatform; + + @Column({ name: 'platform_version', type: 'varchar', length: 20, nullable: true }) + platformVersion: string; + + @Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true }) + appVersion: string; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_trusted', type: 'boolean', default: false }) + isTrusted: boolean; + + @Column({ name: 'trust_level', type: 'integer', default: 0 }) + trustLevel: number; // 0=none, 1=low, 2=medium, 3=high + + // Biometricos habilitados + @Column({ name: 'biometric_enabled', type: 'boolean', default: false }) + biometricEnabled: boolean; + + @Column({ name: 'biometric_type', type: 'varchar', length: 50, nullable: true }) + biometricType: BiometricType; + + // Push notifications + @Column({ name: 'push_token', type: 'text', nullable: true }) + pushToken: string; + + @Column({ name: 'push_token_updated_at', type: 'timestamptz', nullable: true }) + pushTokenUpdatedAt: Date; + + // Ubicacion ultima conocida + @Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + lastLatitude: number; + + @Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + lastLongitude: number; + + @Column({ name: 'last_location_at', type: 'timestamptz', nullable: true }) + lastLocationAt: Date; + + // Seguridad + @Column({ name: 'last_ip_address', type: 'inet', nullable: true }) + lastIpAddress: string; + + @Column({ name: 'last_user_agent', type: 'text', nullable: true }) + lastUserAgent: string; + + // Registro + @Column({ name: 'first_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + firstSeenAt: Date; + + @Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastSeenAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @OneToMany(() => BiometricCredential, (credential) => credential.device) + biometricCredentials: BiometricCredential[]; + + @OneToMany(() => DeviceSession, (session) => session.device) + sessions: DeviceSession[]; +} diff --git a/src/modules/biometrics/entities/index.ts b/src/modules/biometrics/entities/index.ts new file mode 100644 index 0000000..17eca5d --- /dev/null +++ b/src/modules/biometrics/entities/index.ts @@ -0,0 +1,4 @@ +export { Device, DevicePlatform, BiometricType } from './device.entity'; +export { BiometricCredential } from './biometric-credential.entity'; +export { DeviceSession, AuthMethod } from './device-session.entity'; +export { DeviceActivityLog, ActivityType, ActivityStatus } from './device-activity-log.entity'; diff --git a/src/modules/branches/branches.module.ts b/src/modules/branches/branches.module.ts new file mode 100644 index 0000000..31c6748 --- /dev/null +++ b/src/modules/branches/branches.module.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { BranchesService } from './services'; +import { BranchesController } from './controllers'; +import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from './entities'; + +export interface BranchesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class BranchesModule { + public router: Router; + public branchesService: BranchesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: BranchesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const branchRepository = this.dataSource.getRepository(Branch); + const assignmentRepository = this.dataSource.getRepository(UserBranchAssignment); + const scheduleRepository = this.dataSource.getRepository(BranchSchedule); + const terminalRepository = this.dataSource.getRepository(BranchPaymentTerminal); + + this.branchesService = new BranchesService( + branchRepository, + assignmentRepository, + scheduleRepository, + terminalRepository + ); + } + + private initializeRoutes(): void { + const branchesController = new BranchesController(this.branchesService); + this.router.use(`${this.basePath}/branches`, branchesController.router); + } + + static getEntities(): Function[] { + return [Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal]; + } +} diff --git a/src/modules/branches/controllers/branches.controller.ts b/src/modules/branches/controllers/branches.controller.ts new file mode 100644 index 0000000..40d896e --- /dev/null +++ b/src/modules/branches/controllers/branches.controller.ts @@ -0,0 +1,364 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { BranchesService } from '../services/branches.service'; +import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto'; + +export class BranchesController { + public router: Router; + + constructor(private readonly branchesService: BranchesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Branch CRUD + this.router.get('/', this.findAll.bind(this)); + this.router.get('/hierarchy', this.getHierarchy.bind(this)); + this.router.get('/main', this.getMainBranch.bind(this)); + this.router.get('/nearby', this.findNearbyBranches.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/set-main', this.setAsMainBranch.bind(this)); + + // Hierarchy + this.router.get('/:id/children', this.getChildren.bind(this)); + this.router.get('/:id/parents', this.getParents.bind(this)); + + // User Assignments + this.router.post('/assign', this.assignUser.bind(this)); + this.router.delete('/assign/:userId/:branchId', this.unassignUser.bind(this)); + this.router.get('/user/:userId', this.getUserBranches.bind(this)); + this.router.get('/user/:userId/primary', this.getPrimaryBranch.bind(this)); + this.router.get('/:id/users', this.getBranchUsers.bind(this)); + + // Geofencing + this.router.post('/validate-geofence', this.validateGeofence.bind(this)); + + // Schedules + this.router.get('/:id/schedules', this.getSchedules.bind(this)); + this.router.post('/:id/schedules', this.addSchedule.bind(this)); + this.router.get('/:id/is-open', this.isOpenNow.bind(this)); + } + + // ============================================ + // BRANCH CRUD + // ============================================ + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { search, branchType, isActive, parentId, limit, offset } = req.query; + + const result = await this.branchesService.findAll(tenantId, { + search: search as string, + branchType: branchType as string, + isActive: isActive ? isActive === 'true' : undefined, + parentId: parentId as string, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const branch = await this.branchesService.findOne(id); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { code } = req.params; + const branch = await this.branchesService.findByCode(tenantId, code); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const dto: CreateBranchDto = req.body; + + const branch = await this.branchesService.create(tenantId, dto, userId); + res.status(201).json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const dto: UpdateBranchDto = req.body; + + const branch = await this.branchesService.update(id, dto, userId); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.branchesService.delete(id); + + if (!deleted) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // HIERARCHY + // ============================================ + + private async getHierarchy(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const hierarchy = await this.branchesService.getHierarchy(tenantId); + res.json({ data: hierarchy }); + } catch (error) { + next(error); + } + } + + private async getChildren(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { recursive } = req.query; + const children = await this.branchesService.getChildren(id, recursive === 'true'); + res.json({ data: children }); + } catch (error) { + next(error); + } + } + + private async getParents(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const parents = await this.branchesService.getParents(id); + res.json({ data: parents }); + } catch (error) { + next(error); + } + } + + // ============================================ + // USER ASSIGNMENTS + // ============================================ + + private async assignUser(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const assignedBy = req.headers['x-user-id'] as string; + const dto: AssignUserToBranchDto = req.body; + + const assignment = await this.branchesService.assignUser(tenantId, dto, assignedBy); + res.status(201).json({ data: assignment }); + } catch (error) { + next(error); + } + } + + private async unassignUser(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, branchId } = req.params; + const unassigned = await this.branchesService.unassignUser(userId, branchId); + + if (!unassigned) { + res.status(404).json({ error: 'Assignment not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getUserBranches(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const branches = await this.branchesService.getUserBranches(userId); + res.json({ data: branches }); + } catch (error) { + next(error); + } + } + + private async getPrimaryBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const branch = await this.branchesService.getPrimaryBranch(userId); + + if (!branch) { + res.status(404).json({ error: 'No primary branch found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async getBranchUsers(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const users = await this.branchesService.getBranchUsers(id); + res.json({ data: users }); + } catch (error) { + next(error); + } + } + + // ============================================ + // GEOFENCING + // ============================================ + + private async validateGeofence(req: Request, res: Response, next: NextFunction): Promise { + try { + const { branchId, latitude, longitude } = req.body; + + const result = await this.branchesService.validateGeofence(branchId, latitude, longitude); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async findNearbyBranches(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { latitude, longitude, radius } = req.query; + + if (!latitude || !longitude) { + res.status(400).json({ error: 'Latitude and longitude are required' }); + return; + } + + const branches = await this.branchesService.findNearbyBranches( + tenantId, + parseFloat(latitude as string), + parseFloat(longitude as string), + radius ? parseInt(radius as string, 10) : undefined + ); + + res.json({ data: branches }); + } catch (error) { + next(error); + } + } + + // ============================================ + // SCHEDULES + // ============================================ + + private async getSchedules(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const schedules = await this.branchesService.getSchedules(id); + res.json({ data: schedules }); + } catch (error) { + next(error); + } + } + + private async addSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreateBranchScheduleDto = req.body; + + const schedule = await this.branchesService.addSchedule(id, dto); + res.status(201).json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async isOpenNow(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const isOpen = await this.branchesService.isOpenNow(id); + res.json({ data: { isOpen } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MAIN BRANCH + // ============================================ + + private async getMainBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const branch = await this.branchesService.getMainBranch(tenantId); + + if (!branch) { + res.status(404).json({ error: 'No main branch found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async setAsMainBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const branch = await this.branchesService.setAsMainBranch(id); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/branches/controllers/index.ts b/src/modules/branches/controllers/index.ts new file mode 100644 index 0000000..9bb0086 --- /dev/null +++ b/src/modules/branches/controllers/index.ts @@ -0,0 +1 @@ +export { BranchesController } from './branches.controller'; diff --git a/src/modules/branches/dto/branch-schedule.dto.ts b/src/modules/branches/dto/branch-schedule.dto.ts new file mode 100644 index 0000000..a922a69 --- /dev/null +++ b/src/modules/branches/dto/branch-schedule.dto.ts @@ -0,0 +1,100 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsEnum, + MaxLength, + IsDateString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ScheduleType } from '../entities/branch-schedule.entity'; + +class ShiftDto { + @IsString() + @MaxLength(50) + name: string; + + @IsString() + start: string; + + @IsString() + end: string; +} + +export class CreateBranchScheduleDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['regular', 'holiday', 'special']) + scheduleType?: ScheduleType; + + @IsOptional() + @IsNumber() + dayOfWeek?: number; // 0=domingo, 1=lunes, ..., 6=sabado + + @IsOptional() + @IsDateString() + specificDate?: string; + + @IsString() + openTime: string; + + @IsString() + closeTime: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ShiftDto) + shifts?: ShiftDto[]; +} + +export class UpdateBranchScheduleDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['regular', 'holiday', 'special']) + scheduleType?: ScheduleType; + + @IsOptional() + @IsNumber() + dayOfWeek?: number; + + @IsOptional() + @IsDateString() + specificDate?: string; + + @IsOptional() + @IsString() + openTime?: string; + + @IsOptional() + @IsString() + closeTime?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ShiftDto) + shifts?: ShiftDto[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/branches/dto/create-branch.dto.ts b/src/modules/branches/dto/create-branch.dto.ts new file mode 100644 index 0000000..afee637 --- /dev/null +++ b/src/modules/branches/dto/create-branch.dto.ts @@ -0,0 +1,265 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsObject, + IsUUID, + IsArray, + MaxLength, + MinLength, + IsEnum, + IsLatitude, + IsLongitude, +} from 'class-validator'; +import { BranchType } from '../entities/branch.entity'; + +export class CreateBranchDto { + @IsString() + @MinLength(2) + @MaxLength(20) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory']) + branchType?: BranchType; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + geofenceRadius?: number; + + @IsOptional() + @IsBoolean() + geofenceEnabled?: boolean; + + @IsOptional() + @IsString() + @MaxLength(50) + timezone?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsBoolean() + isMain?: boolean; + + @IsOptional() + @IsObject() + operatingHours?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + +export class UpdateBranchDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory']) + branchType?: BranchType; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + geofenceRadius?: number; + + @IsOptional() + @IsBoolean() + geofenceEnabled?: boolean; + + @IsOptional() + @IsString() + @MaxLength(50) + timezone?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isMain?: boolean; + + @IsOptional() + @IsObject() + operatingHours?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + +export class AssignUserToBranchDto { + @IsUUID() + userId: string; + + @IsUUID() + branchId: string; + + @IsOptional() + @IsEnum(['primary', 'secondary', 'temporary', 'floating']) + assignmentType?: string; + + @IsOptional() + @IsEnum(['manager', 'supervisor', 'staff']) + branchRole?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; + + @IsOptional() + @IsString() + validUntil?: string; +} + +export class ValidateGeofenceDto { + @IsUUID() + branchId: string; + + @IsNumber() + latitude: number; + + @IsNumber() + longitude: number; +} diff --git a/src/modules/branches/dto/index.ts b/src/modules/branches/dto/index.ts new file mode 100644 index 0000000..2c6b163 --- /dev/null +++ b/src/modules/branches/dto/index.ts @@ -0,0 +1,11 @@ +export { + CreateBranchDto, + UpdateBranchDto, + AssignUserToBranchDto, + ValidateGeofenceDto, +} from './create-branch.dto'; + +export { + CreateBranchScheduleDto, + UpdateBranchScheduleDto, +} from './branch-schedule.dto'; diff --git a/src/modules/branches/entities/branch-inventory-settings.entity.ts b/src/modules/branches/entities/branch-inventory-settings.entity.ts new file mode 100644 index 0000000..6e769ff --- /dev/null +++ b/src/modules/branches/entities/branch-inventory-settings.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +/** + * Configuración de inventario por sucursal. + * Mapea a core.branch_inventory_settings (DDL: 03-core-branches.sql) + */ +@Entity({ name: 'branch_inventory_settings', schema: 'core' }) +export class BranchInventorySettings { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @OneToOne(() => Branch, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; + + // Almacén asociado (referencia externa a inventory.warehouses) + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Configuración de stock + @Column({ name: 'default_stock_min', type: 'integer', default: 0 }) + defaultStockMin: number; + + @Column({ name: 'default_stock_max', type: 'integer', default: 1000 }) + defaultStockMax: number; + + @Column({ name: 'auto_reorder_enabled', type: 'boolean', default: false }) + autoReorderEnabled: boolean; + + // Configuración de precios (referencia externa a sales.price_lists) + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + @Column({ name: 'allow_price_override', type: 'boolean', default: false }) + allowPriceOverride: boolean; + + @Column({ name: 'max_discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + maxDiscountPercent: number; + + // Configuración de impuestos + @Column({ name: 'tax_config', type: 'jsonb', default: {} }) + taxConfig: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/branches/entities/branch-payment-terminal.entity.ts b/src/modules/branches/entities/branch-payment-terminal.entity.ts new file mode 100644 index 0000000..1af5393 --- /dev/null +++ b/src/modules/branches/entities/branch-payment-terminal.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type TerminalProvider = 'clip' | 'mercadopago' | 'stripe'; +export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown'; + +@Entity({ name: 'branch_payment_terminals', schema: 'core' }) +@Unique(['branchId', 'terminalProvider', 'terminalId']) +export class BranchPaymentTerminal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + // Terminal + @Index() + @Column({ name: 'terminal_provider', type: 'varchar', length: 30 }) + terminalProvider: TerminalProvider; + + @Column({ name: 'terminal_id', type: 'varchar', length: 100 }) + terminalId: string; + + @Column({ name: 'terminal_name', type: 'varchar', length: 100, nullable: true }) + terminalName: string; + + // Credenciales (encriptadas) + @Column({ type: 'jsonb', default: {} }) + credentials: Record; + + // Configuracion + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Limites + @Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + dailyLimit: number; + + @Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + transactionLimit: number; + + // Ultima actividad + @Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true }) + lastTransactionAt: Date; + + @Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true }) + lastHealthCheckAt: Date; + + @Column({ name: 'health_status', type: 'varchar', length: 20, default: 'unknown' }) + healthStatus: HealthStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.paymentTerminals, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/entities/branch-schedule.entity.ts b/src/modules/branches/entities/branch-schedule.entity.ts new file mode 100644 index 0000000..a1de7d7 --- /dev/null +++ b/src/modules/branches/entities/branch-schedule.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type ScheduleType = 'regular' | 'holiday' | 'special'; + +@Entity({ name: 'branch_schedules', schema: 'core' }) +export class BranchSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + // Identificacion + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Column({ name: 'schedule_type', type: 'varchar', length: 30, default: 'regular' }) + scheduleType: ScheduleType; + + // Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica + @Index() + @Column({ name: 'day_of_week', type: 'integer', nullable: true }) + dayOfWeek: number; + + @Index() + @Column({ name: 'specific_date', type: 'date', nullable: true }) + specificDate: Date; + + // Horarios + @Column({ name: 'open_time', type: 'time' }) + openTime: string; + + @Column({ name: 'close_time', type: 'time' }) + closeTime: string; + + // Turnos (si aplica) + @Column({ type: 'jsonb', default: [] }) + shifts: Array<{ + name: string; + start: string; + end: string; + }>; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.schedules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/entities/branch.entity.ts b/src/modules/branches/entities/branch.entity.ts new file mode 100644 index 0000000..dcc596c --- /dev/null +++ b/src/modules/branches/entities/branch.entity.ts @@ -0,0 +1,158 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserBranchAssignment } from './user-branch-assignment.entity'; +import { BranchSchedule } from './branch-schedule.entity'; +import { BranchPaymentTerminal } from './branch-payment-terminal.entity'; + +export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory'; + +@Entity({ name: 'branches', schema: 'core' }) +@Unique(['tenantId', 'code']) +export class Branch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + // Tipo + @Index() + @Column({ name: 'branch_type', type: 'varchar', length: 30, default: 'store' }) + branchType: BranchType; + + // Contacto + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string; + + // Direccion + @Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Column({ name: 'geofence_radius', type: 'integer', default: 100 }) + geofenceRadius: number; // Radio en metros + + @Column({ name: 'geofence_enabled', type: 'boolean', default: true }) + geofenceEnabled: boolean; + + // Configuracion + @Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_main', type: 'boolean', default: false }) + isMain: boolean; // Sucursal principal/matriz + + // Horarios de operacion + @Column({ name: 'operating_hours', type: 'jsonb', default: {} }) + operatingHours: Record; + + // Configuraciones especificas + @Column({ type: 'jsonb', default: {} }) + settings: { + allowPos?: boolean; + allowWarehouse?: boolean; + allowCheckIn?: boolean; + [key: string]: any; + }; + + // Jerarquia (path materializado) + @Index() + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'integer', default: 0 }) + hierarchyLevel: 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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: Branch; + + @OneToMany(() => Branch, (branch) => branch.parent) + children: Branch[]; + + @OneToMany(() => UserBranchAssignment, (assignment) => assignment.branch) + userAssignments: UserBranchAssignment[]; + + @OneToMany(() => BranchSchedule, (schedule) => schedule.branch) + schedules: BranchSchedule[]; + + @OneToMany(() => BranchPaymentTerminal, (terminal) => terminal.branch) + paymentTerminals: BranchPaymentTerminal[]; +} diff --git a/src/modules/branches/entities/index.ts b/src/modules/branches/entities/index.ts new file mode 100644 index 0000000..ce1a718 --- /dev/null +++ b/src/modules/branches/entities/index.ts @@ -0,0 +1,5 @@ +export { Branch, BranchType } from './branch.entity'; +export { UserBranchAssignment, AssignmentType, BranchRole } from './user-branch-assignment.entity'; +export { BranchSchedule, ScheduleType } from './branch-schedule.entity'; +export { BranchPaymentTerminal, TerminalProvider, HealthStatus } from './branch-payment-terminal.entity'; +export { BranchInventorySettings } from './branch-inventory-settings.entity'; diff --git a/src/modules/branches/entities/user-branch-assignment.entity.ts b/src/modules/branches/entities/user-branch-assignment.entity.ts new file mode 100644 index 0000000..d2ccd55 --- /dev/null +++ b/src/modules/branches/entities/user-branch-assignment.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type AssignmentType = 'primary' | 'secondary' | 'temporary' | 'floating'; +export type BranchRole = 'manager' | 'supervisor' | 'staff'; + +@Entity({ name: 'user_branch_assignments', schema: 'core' }) +@Unique(['userId', 'branchId', 'assignmentType']) +export class UserBranchAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de asignacion + @Column({ name: 'assignment_type', type: 'varchar', length: 30, default: 'primary' }) + assignmentType: AssignmentType; + + // Rol en la sucursal + @Column({ name: 'branch_role', type: 'varchar', length: 50, nullable: true }) + branchRole: BranchRole; + + // Permisos especificos + @Column({ type: 'jsonb', default: [] }) + permissions: string[]; + + // Vigencia (para asignaciones temporales) + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil: Date; + + // Estado + @Index() + @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; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.userAssignments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/index.ts b/src/modules/branches/index.ts new file mode 100644 index 0000000..c68988b --- /dev/null +++ b/src/modules/branches/index.ts @@ -0,0 +1,5 @@ +export { BranchesModule, BranchesModuleOptions } from './branches.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/branches/services/branches.service.ts b/src/modules/branches/services/branches.service.ts new file mode 100644 index 0000000..51fefa8 --- /dev/null +++ b/src/modules/branches/services/branches.service.ts @@ -0,0 +1,435 @@ +import { Repository, FindOptionsWhere, ILike, IsNull, In } from 'typeorm'; +import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from '../entities'; +import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto'; + +export interface BranchSearchParams { + search?: string; + branchType?: string; + isActive?: boolean; + parentId?: string; + includeChildren?: boolean; + limit?: number; + offset?: number; +} + +export class BranchesService { + constructor( + private readonly branchRepository: Repository, + private readonly assignmentRepository: Repository, + private readonly scheduleRepository: Repository, + private readonly terminalRepository: Repository + ) {} + + // ============================================ + // BRANCH CRUD + // ============================================ + + async findAll(tenantId: string, params: BranchSearchParams = {}): Promise<{ data: Branch[]; total: number }> { + const { search, branchType, isActive, parentId, limit = 50, offset = 0 } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (branchType) where.branchType = branchType as any; + if (isActive !== undefined) where.isActive = isActive; + if (parentId) where.parentId = parentId; + if (parentId === null) where.parentId = IsNull(); + + const queryBuilder = this.branchRepository + .createQueryBuilder('branch') + .where('branch.tenant_id = :tenantId', { tenantId }) + .leftJoinAndSelect('branch.schedules', 'schedules') + .leftJoinAndSelect('branch.paymentTerminals', 'terminals'); + + if (search) { + queryBuilder.andWhere('(branch.name ILIKE :search OR branch.code ILIKE :search OR branch.city ILIKE :search)', { + search: `%${search}%`, + }); + } + + if (branchType) { + queryBuilder.andWhere('branch.branch_type = :branchType', { branchType }); + } + + if (isActive !== undefined) { + queryBuilder.andWhere('branch.is_active = :isActive', { isActive }); + } + + if (parentId) { + queryBuilder.andWhere('branch.parent_id = :parentId', { parentId }); + } else if (parentId === null) { + queryBuilder.andWhere('branch.parent_id IS NULL'); + } + + queryBuilder.orderBy('branch.hierarchy_path', 'ASC').addOrderBy('branch.name', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + async findOne(id: string): Promise { + return this.branchRepository.findOne({ + where: { id }, + relations: ['parent', 'children', 'schedules', 'paymentTerminals', 'userAssignments'], + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.branchRepository.findOne({ + where: { tenantId, code }, + relations: ['schedules', 'paymentTerminals'], + }); + } + + async create(tenantId: string, dto: CreateBranchDto, createdBy?: string): Promise { + // Check for duplicate code + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error(`Branch with code '${dto.code}' already exists`); + } + + // Build hierarchy path + let hierarchyPath = `/${dto.code}`; + let hierarchyLevel = 0; + + if (dto.parentId) { + const parent = await this.findOne(dto.parentId); + if (!parent) { + throw new Error('Parent branch not found'); + } + hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; + hierarchyLevel = parent.hierarchyLevel + 1; + } + + const branch = this.branchRepository.create({ + ...dto, + tenantId, + hierarchyPath, + hierarchyLevel, + createdBy, + }); + + return this.branchRepository.save(branch); + } + + async update(id: string, dto: UpdateBranchDto, updatedBy?: string): Promise { + const branch = await this.findOne(id); + if (!branch) return null; + + // If changing parent, update hierarchy + if (dto.parentId !== undefined && dto.parentId !== branch.parentId) { + if (dto.parentId) { + const newParent = await this.findOne(dto.parentId); + if (!newParent) { + throw new Error('New parent branch not found'); + } + + // Check for circular reference + if (newParent.hierarchyPath.includes(`/${branch.code}/`) || newParent.id === branch.id) { + throw new Error('Cannot create circular reference in branch hierarchy'); + } + + branch.hierarchyPath = `${newParent.hierarchyPath}/${branch.code}`; + branch.hierarchyLevel = newParent.hierarchyLevel + 1; + } else { + branch.hierarchyPath = `/${branch.code}`; + branch.hierarchyLevel = 0; + } + + // Update children hierarchy paths + await this.updateChildrenHierarchy(branch); + } + + Object.assign(branch, dto, { updatedBy }); + return this.branchRepository.save(branch); + } + + private async updateChildrenHierarchy(parent: Branch): Promise { + const children = await this.branchRepository.find({ + where: { parentId: parent.id }, + }); + + for (const child of children) { + child.hierarchyPath = `${parent.hierarchyPath}/${child.code}`; + child.hierarchyLevel = parent.hierarchyLevel + 1; + await this.branchRepository.save(child); + await this.updateChildrenHierarchy(child); + } + } + + async delete(id: string): Promise { + const branch = await this.findOne(id); + if (!branch) return false; + + // Check if has children + const childrenCount = await this.branchRepository.count({ where: { parentId: id } }); + if (childrenCount > 0) { + throw new Error('Cannot delete branch with children. Delete children first or move them to another parent.'); + } + + await this.branchRepository.softDelete(id); + return true; + } + + // ============================================ + // HIERARCHY + // ============================================ + + async getHierarchy(tenantId: string): Promise { + const branches = await this.branchRepository.find({ + where: { tenantId, isActive: true }, + order: { hierarchyPath: 'ASC' }, + }); + + return this.buildTree(branches); + } + + private buildTree(branches: Branch[], parentId: string | null = null): Branch[] { + return branches + .filter((b) => b.parentId === parentId) + .map((branch) => ({ + ...branch, + children: this.buildTree(branches, branch.id), + })); + } + + async getChildren(branchId: string, recursive: boolean = false): Promise { + if (!recursive) { + return this.branchRepository.find({ + where: { parentId: branchId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + const parent = await this.findOne(branchId); + if (!parent) return []; + + return this.branchRepository + .createQueryBuilder('branch') + .where('branch.hierarchy_path LIKE :path', { path: `${parent.hierarchyPath}/%` }) + .andWhere('branch.is_active = true') + .orderBy('branch.hierarchy_path', 'ASC') + .getMany(); + } + + async getParents(branchId: string): Promise { + const branch = await this.findOne(branchId); + if (!branch || !branch.hierarchyPath) return []; + + const codes = branch.hierarchyPath.split('/').filter((c) => c && c !== branch.code); + if (codes.length === 0) return []; + + return this.branchRepository.find({ + where: { tenantId: branch.tenantId, code: In(codes) }, + order: { hierarchyLevel: 'ASC' }, + }); + } + + // ============================================ + // USER ASSIGNMENTS + // ============================================ + + async assignUser(tenantId: string, dto: AssignUserToBranchDto, assignedBy?: string): Promise { + // Check if branch exists + const branch = await this.findOne(dto.branchId); + if (!branch || branch.tenantId !== tenantId) { + throw new Error('Branch not found'); + } + + // Check for existing assignment of same type + const existing = await this.assignmentRepository.findOne({ + where: { + userId: dto.userId, + branchId: dto.branchId, + assignmentType: (dto.assignmentType as any) ?? 'primary', + }, + }); + + if (existing) { + // Update existing + Object.assign(existing, { + branchRole: dto.branchRole ?? existing.branchRole, + permissions: dto.permissions ?? existing.permissions, + validUntil: dto.validUntil ? new Date(dto.validUntil) : existing.validUntil, + isActive: true, + }); + return this.assignmentRepository.save(existing); + } + + const assignment = this.assignmentRepository.create({ + ...dto, + tenantId, + validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined, + createdBy: assignedBy, + } as any); + + return this.assignmentRepository.save(assignment); + } + + async unassignUser(userId: string, branchId: string): Promise { + const result = await this.assignmentRepository.update({ userId, branchId }, { isActive: false }); + return (result.affected ?? 0) > 0; + } + + async getUserBranches(userId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { userId, isActive: true }, + relations: ['branch'], + }); + + return assignments.map((a) => a.branch).filter((b) => b != null); + } + + async getBranchUsers(branchId: string): Promise { + return this.assignmentRepository.find({ + where: { branchId, isActive: true }, + order: { branchRole: 'ASC' }, + }); + } + + async getPrimaryBranch(userId: string): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { userId, assignmentType: 'primary' as any, isActive: true }, + relations: ['branch'], + }); + + return assignment?.branch ?? null; + } + + // ============================================ + // GEOFENCING + // ============================================ + + async validateGeofence(branchId: string, latitude: number, longitude: number): Promise<{ valid: boolean; distance: number }> { + const branch = await this.findOne(branchId); + if (!branch) { + throw new Error('Branch not found'); + } + + if (!branch.geofenceEnabled) { + return { valid: true, distance: 0 }; + } + + if (!branch.latitude || !branch.longitude) { + return { valid: true, distance: 0 }; + } + + // Calculate distance using Haversine formula + const R = 6371000; // Earth's radius in meters + const dLat = this.toRad(latitude - branch.latitude); + const dLon = this.toRad(longitude - branch.longitude); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(branch.latitude)) * Math.cos(this.toRad(latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; + + return { + valid: distance <= branch.geofenceRadius, + distance: Math.round(distance), + }; + } + + private toRad(deg: number): number { + return deg * (Math.PI / 180); + } + + async findNearbyBranches(tenantId: string, latitude: number, longitude: number, radiusMeters: number = 5000): Promise { + // Use PostgreSQL's earthdistance extension if available, otherwise calculate in app + const branches = await this.branchRepository.find({ + where: { tenantId, isActive: true }, + }); + + return branches + .filter((b) => { + if (!b.latitude || !b.longitude) return false; + const result = this.calculateDistance(latitude, longitude, b.latitude, b.longitude); + return result <= radiusMeters; + }) + .sort((a, b) => { + const distA = this.calculateDistance(latitude, longitude, a.latitude!, a.longitude!); + const distB = this.calculateDistance(latitude, longitude, b.latitude!, b.longitude!); + return distA - distB; + }); + } + + private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const dLat = this.toRad(lat2 - lat1); + const dLon = this.toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + // ============================================ + // SCHEDULES + // ============================================ + + async addSchedule(branchId: string, dto: CreateBranchScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + branchId, + specificDate: dto.specificDate ? new Date(dto.specificDate) : undefined, + }); + + return this.scheduleRepository.save(schedule); + } + + async getSchedules(branchId: string): Promise { + return this.scheduleRepository.find({ + where: { branchId, isActive: true }, + order: { dayOfWeek: 'ASC', specificDate: 'ASC' }, + }); + } + + async isOpenNow(branchId: string): Promise { + const schedules = await this.getSchedules(branchId); + const now = new Date(); + const dayOfWeek = now.getDay(); + const currentTime = now.toTimeString().slice(0, 5); + + // Check for specific date schedule first + const today = now.toISOString().slice(0, 10); + const specificSchedule = schedules.find((s) => s.specificDate?.toISOString().slice(0, 10) === today); + + if (specificSchedule) { + return currentTime >= specificSchedule.openTime && currentTime <= specificSchedule.closeTime; + } + + // Check regular schedule + const regularSchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.scheduleType === 'regular'); + + if (regularSchedule) { + return currentTime >= regularSchedule.openTime && currentTime <= regularSchedule.closeTime; + } + + return false; + } + + // ============================================ + // MAIN BRANCH + // ============================================ + + async getMainBranch(tenantId: string): Promise { + return this.branchRepository.findOne({ + where: { tenantId, isMain: true, isActive: true }, + relations: ['schedules', 'paymentTerminals'], + }); + } + + async setAsMainBranch(branchId: string): Promise { + const branch = await this.findOne(branchId); + if (!branch) return null; + + // Unset current main branch + await this.branchRepository.update({ tenantId: branch.tenantId, isMain: true }, { isMain: false }); + + // Set new main branch + branch.isMain = true; + return this.branchRepository.save(branch); + } +} diff --git a/src/modules/branches/services/index.ts b/src/modules/branches/services/index.ts new file mode 100644 index 0000000..0db219e --- /dev/null +++ b/src/modules/branches/services/index.ts @@ -0,0 +1 @@ +export { BranchesService, BranchSearchParams } from './branches.service'; diff --git a/src/modules/dashboard/controllers/index.ts b/src/modules/dashboard/controllers/index.ts new file mode 100644 index 0000000..aeab30d --- /dev/null +++ b/src/modules/dashboard/controllers/index.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { DashboardService } from '../services'; + +export class DashboardController { + public router: Router; + + constructor(private readonly dashboardService: DashboardService) { + this.router = Router(); + + this.router.get('/', this.getDashboard.bind(this)); + this.router.get('/kpis', this.getKPIs.bind(this)); + this.router.get('/activity', this.getActivity.bind(this)); + this.router.get('/sales-chart', this.getSalesChart.bind(this)); + } + + private async getDashboard(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const [kpis, activity, salesChart] = await Promise.all([ + this.dashboardService.getKPIs(tenantId), + this.dashboardService.getActivity(tenantId), + this.dashboardService.getSalesChart(tenantId, 'month'), + ]); + + res.json({ + kpis, + activity, + salesChart, + timestamp: new Date().toISOString(), + }); + } catch (e) { + next(e); + } + } + + private async getKPIs(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const kpis = await this.dashboardService.getKPIs(tenantId); + res.json({ data: kpis }); + } catch (e) { + next(e); + } + } + + private async getActivity(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { limit } = req.query; + const activity = await this.dashboardService.getActivity( + tenantId, + limit ? parseInt(limit as string) : 5 + ); + + res.json({ data: activity }); + } catch (e) { + next(e); + } + } + + private async getSalesChart(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { period } = req.query; + const validPeriods = ['week', 'month', 'year']; + const selectedPeriod = validPeriods.includes(period as string) + ? (period as 'week' | 'month' | 'year') + : 'month'; + + const chartData = await this.dashboardService.getSalesChart(tenantId, selectedPeriod); + res.json({ data: chartData }); + } catch (e) { + next(e); + } + } +} diff --git a/src/modules/dashboard/dashboard.module.ts b/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000..bf8908d --- /dev/null +++ b/src/modules/dashboard/dashboard.module.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { DashboardService } from './services'; +import { DashboardController } from './controllers'; + +export interface DashboardModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class DashboardModule { + public router: Router; + public dashboardService: DashboardService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: DashboardModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + this.dashboardService = new DashboardService(this.dataSource); + } + + private initializeRoutes(): void { + const dashboardController = new DashboardController(this.dashboardService); + this.router.use(`${this.basePath}/dashboard`, dashboardController.router); + } + + // Dashboard module doesn't have its own entities - it uses data from other modules + static getEntities(): Function[] { + return []; + } +} diff --git a/src/modules/dashboard/index.ts b/src/modules/dashboard/index.ts new file mode 100644 index 0000000..04a093a --- /dev/null +++ b/src/modules/dashboard/index.ts @@ -0,0 +1,3 @@ +export { DashboardModule, DashboardModuleOptions } from './dashboard.module'; +export { DashboardService } from './services'; +export { DashboardController } from './controllers'; diff --git a/src/modules/dashboard/services/index.ts b/src/modules/dashboard/services/index.ts new file mode 100644 index 0000000..d879b28 --- /dev/null +++ b/src/modules/dashboard/services/index.ts @@ -0,0 +1,386 @@ +import { DataSource } from 'typeorm'; + +export interface DashboardKPIs { + sales: { + todayRevenue: number; + monthRevenue: number; + todayOrders: number; + monthOrders: number; + pendingOrders: number; + }; + inventory: { + totalProducts: number; + lowStockItems: number; + outOfStockItems: number; + pendingMovements: number; + }; + invoices: { + pendingInvoices: number; + overdueInvoices: number; + totalReceivable: number; + totalPayable: number; + }; + partners: { + totalCustomers: number; + totalSuppliers: number; + newCustomersMonth: number; + }; +} + +export interface DashboardActivity { + recentOrders: any[]; + recentInvoices: any[]; + recentMovements: any[]; + alerts: any[]; +} + +export class DashboardService { + constructor(private readonly dataSource: DataSource) {} + + async getKPIs(tenantId: string): Promise { + const [sales, inventory, invoices, partners] = await Promise.all([ + this.getSalesKPIs(tenantId), + this.getInventoryKPIs(tenantId), + this.getInvoiceKPIs(tenantId), + this.getPartnerKPIs(tenantId), + ]); + + return { sales, inventory, invoices, partners }; + } + + private async getSalesKPIs(tenantId: string): Promise { + const today = new Date(); + const startOfDay = new Date(today.setHours(0, 0, 0, 0)); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + + try { + // Today's sales + const todayQuery = ` + SELECT + COALESCE(SUM(total), 0) as revenue, + COUNT(*) as orders + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status NOT IN ('cancelled', 'draft') + AND order_date >= $2 + `; + const todayResult = await this.dataSource.query(todayQuery, [tenantId, startOfDay]); + + // Month's sales + const monthQuery = ` + SELECT + COALESCE(SUM(total), 0) as revenue, + COUNT(*) as orders + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status NOT IN ('cancelled', 'draft') + AND order_date >= $2 + `; + const monthResult = await this.dataSource.query(monthQuery, [tenantId, startOfMonth]); + + // Pending orders + const pendingQuery = ` + SELECT COUNT(*) as count + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status IN ('confirmed', 'processing') + `; + const pendingResult = await this.dataSource.query(pendingQuery, [tenantId]); + + return { + todayRevenue: parseFloat(todayResult[0]?.revenue) || 0, + monthRevenue: parseFloat(monthResult[0]?.revenue) || 0, + todayOrders: parseInt(todayResult[0]?.orders) || 0, + monthOrders: parseInt(monthResult[0]?.orders) || 0, + pendingOrders: parseInt(pendingResult[0]?.count) || 0, + }; + } catch { + return { + todayRevenue: 0, + monthRevenue: 0, + todayOrders: 0, + monthOrders: 0, + pendingOrders: 0, + }; + } + } + + private async getInventoryKPIs(tenantId: string): Promise { + try { + const stockQuery = ` + SELECT + COUNT(DISTINCT product_id) as total_products, + COUNT(CASE WHEN quantity_on_hand <= 0 THEN 1 END) as out_of_stock, + COUNT(CASE WHEN quantity_on_hand > 0 AND quantity_on_hand <= 10 THEN 1 END) as low_stock + FROM inventory.stock_levels + WHERE tenant_id = $1 + `; + const stockResult = await this.dataSource.query(stockQuery, [tenantId]); + + const movementsQuery = ` + SELECT COUNT(*) as count + FROM inventory.stock_movements + WHERE tenant_id = $1 + AND status = 'draft' + `; + const movementsResult = await this.dataSource.query(movementsQuery, [tenantId]); + + return { + totalProducts: parseInt(stockResult[0]?.total_products) || 0, + lowStockItems: parseInt(stockResult[0]?.low_stock) || 0, + outOfStockItems: parseInt(stockResult[0]?.out_of_stock) || 0, + pendingMovements: parseInt(movementsResult[0]?.count) || 0, + }; + } catch { + return { + totalProducts: 0, + lowStockItems: 0, + outOfStockItems: 0, + pendingMovements: 0, + }; + } + } + + private async getInvoiceKPIs(tenantId: string): Promise { + try { + const invoicesQuery = ` + SELECT + COUNT(CASE WHEN status IN ('validated', 'sent') THEN 1 END) as pending, + COUNT(CASE WHEN status IN ('validated', 'sent', 'partial') AND due_date < CURRENT_DATE THEN 1 END) as overdue, + COALESCE(SUM(CASE WHEN invoice_type = 'sale' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as receivable, + COALESCE(SUM(CASE WHEN invoice_type = 'purchase' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as payable + FROM billing.invoices + WHERE tenant_id = $1 + `; + const result = await this.dataSource.query(invoicesQuery, [tenantId]); + + return { + pendingInvoices: parseInt(result[0]?.pending) || 0, + overdueInvoices: parseInt(result[0]?.overdue) || 0, + totalReceivable: parseFloat(result[0]?.receivable) || 0, + totalPayable: parseFloat(result[0]?.payable) || 0, + }; + } catch { + return { + pendingInvoices: 0, + overdueInvoices: 0, + totalReceivable: 0, + totalPayable: 0, + }; + } + } + + private async getPartnerKPIs(tenantId: string): Promise { + const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + + try { + const partnersQuery = ` + SELECT + COUNT(CASE WHEN partner_type IN ('customer', 'both') THEN 1 END) as customers, + COUNT(CASE WHEN partner_type IN ('supplier', 'both') THEN 1 END) as suppliers, + COUNT(CASE WHEN partner_type IN ('customer', 'both') AND created_at >= $2 THEN 1 END) as new_customers + FROM partners.partners + WHERE tenant_id = $1 + AND deleted_at IS NULL + `; + const result = await this.dataSource.query(partnersQuery, [tenantId, startOfMonth]); + + return { + totalCustomers: parseInt(result[0]?.customers) || 0, + totalSuppliers: parseInt(result[0]?.suppliers) || 0, + newCustomersMonth: parseInt(result[0]?.new_customers) || 0, + }; + } catch { + return { + totalCustomers: 0, + totalSuppliers: 0, + newCustomersMonth: 0, + }; + } + } + + async getActivity(tenantId: string, limit: number = 5): Promise { + const [recentOrders, recentInvoices, recentMovements, alerts] = await Promise.all([ + this.getRecentOrders(tenantId, limit), + this.getRecentInvoices(tenantId, limit), + this.getRecentMovements(tenantId, limit), + this.getAlerts(tenantId), + ]); + + return { recentOrders, recentInvoices, recentMovements, alerts }; + } + + private async getRecentOrders(tenantId: string, limit: number): Promise { + try { + const query = ` + SELECT + id, + order_number, + partner_name, + total, + status, + order_date, + created_at + FROM sales.sales_orders + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + return await this.dataSource.query(query, [tenantId, limit]); + } catch { + return []; + } + } + + private async getRecentInvoices(tenantId: string, limit: number): Promise { + try { + const query = ` + SELECT + id, + invoice_number, + invoice_type, + partner_name, + total, + status, + invoice_date, + created_at + FROM billing.invoices + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + return await this.dataSource.query(query, [tenantId, limit]); + } catch { + return []; + } + } + + private async getRecentMovements(tenantId: string, limit: number): Promise { + try { + const query = ` + SELECT + id, + movement_number, + movement_type, + product_id, + quantity, + status, + created_at + FROM inventory.stock_movements + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + return await this.dataSource.query(query, [tenantId, limit]); + } catch { + return []; + } + } + + private async getAlerts(tenantId: string): Promise { + const alerts: any[] = []; + + try { + // Low stock alerts + const lowStockQuery = ` + SELECT COUNT(*) as count + FROM inventory.stock_levels + WHERE tenant_id = $1 + AND quantity_on_hand > 0 + AND quantity_on_hand <= 10 + `; + const lowStockResult = await this.dataSource.query(lowStockQuery, [tenantId]); + const lowStockCount = parseInt(lowStockResult[0]?.count) || 0; + + if (lowStockCount > 0) { + alerts.push({ + type: 'warning', + category: 'inventory', + message: `${lowStockCount} productos con stock bajo`, + count: lowStockCount, + }); + } + + // Overdue invoices alerts + const overdueQuery = ` + SELECT COUNT(*) as count + FROM billing.invoices + WHERE tenant_id = $1 + AND invoice_type = 'sale' + AND status IN ('validated', 'sent', 'partial') + AND due_date < CURRENT_DATE + `; + const overdueResult = await this.dataSource.query(overdueQuery, [tenantId]); + const overdueCount = parseInt(overdueResult[0]?.count) || 0; + + if (overdueCount > 0) { + alerts.push({ + type: 'error', + category: 'invoices', + message: `${overdueCount} facturas vencidas`, + count: overdueCount, + }); + } + + // Pending orders alerts + const pendingOrdersQuery = ` + SELECT COUNT(*) as count + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status IN ('confirmed') + AND order_date < CURRENT_DATE - INTERVAL '3 days' + `; + const pendingResult = await this.dataSource.query(pendingOrdersQuery, [tenantId]); + const pendingCount = parseInt(pendingResult[0]?.count) || 0; + + if (pendingCount > 0) { + alerts.push({ + type: 'warning', + category: 'sales', + message: `${pendingCount} pedidos pendientes hace mas de 3 dias`, + count: pendingCount, + }); + } + } catch { + // Ignore errors - tables might not exist yet + } + + return alerts; + } + + async getSalesChart( + tenantId: string, + period: 'week' | 'month' | 'year' = 'month' + ): Promise<{ labels: string[]; data: number[] }> { + try { + const intervals = { + week: { interval: '7 days', format: 'Dy', group: 'day' }, + month: { interval: '30 days', format: 'DD', group: 'day' }, + year: { interval: '12 months', format: 'Mon', group: 'month' }, + }; + + const config = intervals[period]; + + const query = ` + SELECT + TO_CHAR(order_date, '${config.format}') as label, + COALESCE(SUM(total), 0) as total + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status NOT IN ('cancelled', 'draft') + AND order_date >= CURRENT_DATE - INTERVAL '${config.interval}' + GROUP BY DATE_TRUNC('${config.group}', order_date), TO_CHAR(order_date, '${config.format}') + ORDER BY DATE_TRUNC('${config.group}', order_date) + `; + + const result = await this.dataSource.query(query, [tenantId]); + + return { + labels: result.map((r: any) => r.label), + data: result.map((r: any) => parseFloat(r.total) || 0), + }; + } catch { + return { labels: [], data: [] }; + } + } +} diff --git a/src/modules/feature-flags/controllers/feature-flags.controller.ts b/src/modules/feature-flags/controllers/feature-flags.controller.ts new file mode 100644 index 0000000..13c0e0c --- /dev/null +++ b/src/modules/feature-flags/controllers/feature-flags.controller.ts @@ -0,0 +1,367 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { FeatureFlagsService } from '../services/feature-flags.service'; + +export class FeatureFlagsController { + public router: Router; + + constructor(private readonly featureFlagsService: FeatureFlagsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Flag CRUD + this.router.get('/flags', this.findAllFlags.bind(this)); + this.router.get('/flags/all', this.findAllFlagsIncludingInactive.bind(this)); + this.router.get('/flags/tags/:tags', this.findFlagsByTags.bind(this)); + this.router.get('/flags/:id', this.findFlagById.bind(this)); + this.router.get('/flags/code/:code', this.findFlagByCode.bind(this)); + this.router.post('/flags', this.createFlag.bind(this)); + this.router.patch('/flags/:id', this.updateFlag.bind(this)); + this.router.delete('/flags/:id', this.deleteFlag.bind(this)); + this.router.patch('/flags/:id/toggle', this.toggleFlag.bind(this)); + this.router.get('/flags/:id/stats', this.getFlagStats.bind(this)); + + // Tenant Overrides + this.router.get('/flags/:flagId/overrides', this.findOverridesForFlag.bind(this)); + this.router.get('/tenants/:tenantId/overrides', this.findOverridesForTenant.bind(this)); + this.router.get('/overrides/:id', this.findOverrideById.bind(this)); + this.router.post('/overrides', this.createOverride.bind(this)); + this.router.patch('/overrides/:id', this.updateOverride.bind(this)); + this.router.delete('/overrides/:id', this.deleteOverride.bind(this)); + + // Evaluation + this.router.get('/evaluate/:code', this.evaluateFlag.bind(this)); + this.router.post('/evaluate', this.evaluateFlags.bind(this)); + this.router.get('/is-enabled/:code', this.isEnabled.bind(this)); + + // Maintenance + this.router.post('/maintenance/cleanup', this.cleanupExpiredOverrides.bind(this)); + } + + // ============================================ + // FLAGS + // ============================================ + + private async findAllFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const flags = await this.featureFlagsService.findAllFlags(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findAllFlagsIncludingInactive( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const flags = await this.featureFlagsService.findAllFlagsIncludingInactive(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findFlagById(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const flag = await this.featureFlagsService.findFlagById(id); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const flag = await this.featureFlagsService.findFlagByCode(code); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagsByTags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tags } = req.params; + const tagList = tags.split(','); + const flags = await this.featureFlagsService.findFlagsByTags(tagList); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async createFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.createFlag(req.body, userId); + res.status(201).json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async updateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.updateFlag(id, req.body, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async deleteFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { soft } = req.query; + const userId = req.headers['x-user-id'] as string; + + let result: boolean; + if (soft === 'true') { + const flag = await this.featureFlagsService.softDeleteFlag(id, userId); + result = flag !== null; + } else { + result = await this.featureFlagsService.deleteFlag(id); + } + + if (!result) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async toggleFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { enabled } = req.body; + const userId = req.headers['x-user-id'] as string; + + const flag = await this.featureFlagsService.toggleFlag(id, enabled, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async getFlagStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const stats = await this.featureFlagsService.getFlagStats(id); + + if (!stats.flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + // ============================================ + // OVERRIDES + // ============================================ + + private async findOverridesForFlag( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { flagId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForFlag(flagId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverridesForTenant( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { tenantId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForTenant(tenantId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverrideById( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.findOverrideById(id); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async createOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const override = await this.featureFlagsService.createOverride(req.body, userId); + res.status(201).json({ data: override }); + } catch (error) { + next(error); + } + } + + private async updateOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.updateOverride(id, req.body); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async deleteOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.featureFlagsService.deleteOverride(id); + + if (!deleted) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // EVALUATION + // ============================================ + + private async evaluateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const result = await this.featureFlagsService.evaluateFlag(code, tenantId); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async evaluateFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { flagCodes, tenantId } = req.body; + + if (!flagCodes || !Array.isArray(flagCodes)) { + res.status(400).json({ error: 'flagCodes array is required' }); + return; + } + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required' }); + return; + } + + const results = await this.featureFlagsService.evaluateFlags(flagCodes, tenantId); + res.json({ data: results, total: results.length }); + } catch (error) { + next(error); + } + } + + private async isEnabled(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const enabled = await this.featureFlagsService.isEnabled(code, tenantId); + res.json({ data: { code, enabled } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MAINTENANCE + // ============================================ + + private async cleanupExpiredOverrides( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const count = await this.featureFlagsService.cleanupExpiredOverrides(); + res.json({ data: { cleanedUp: count } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/feature-flags/controllers/index.ts b/src/modules/feature-flags/controllers/index.ts new file mode 100644 index 0000000..56046b6 --- /dev/null +++ b/src/modules/feature-flags/controllers/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsController } from './feature-flags.controller'; diff --git a/src/modules/feature-flags/dto/feature-flag.dto.ts b/src/modules/feature-flags/dto/feature-flag.dto.ts new file mode 100644 index 0000000..cc395f5 --- /dev/null +++ b/src/modules/feature-flags/dto/feature-flag.dto.ts @@ -0,0 +1,53 @@ +// ===================================================== +// DTOs: Feature Flags +// Modulo: MGN-019 +// Version: 1.0.0 +// ===================================================== + +export interface CreateFlagDto { + code: string; + name: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; +} + +export interface UpdateFlagDto { + name?: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; + isActive?: boolean; +} + +export interface CreateTenantOverrideDto { + flagId: string; + tenantId: string; + enabled: boolean; + reason?: string; + expiresAt?: Date; +} + +export interface UpdateTenantOverrideDto { + enabled?: boolean; + reason?: string; + expiresAt?: Date | null; +} + +export interface EvaluateFlagDto { + flagCode: string; + tenantId: string; +} + +export interface EvaluateFlagsDto { + flagCodes: string[]; + tenantId: string; +} + +export interface FlagEvaluationResult { + code: string; + enabled: boolean; + source: 'override' | 'global' | 'rollout' | 'default'; +} diff --git a/src/modules/feature-flags/dto/index.ts b/src/modules/feature-flags/dto/index.ts new file mode 100644 index 0000000..8cba2ff --- /dev/null +++ b/src/modules/feature-flags/dto/index.ts @@ -0,0 +1 @@ +export * from './feature-flag.dto'; diff --git a/src/modules/feature-flags/entities/flag.entity.ts b/src/modules/feature-flags/entities/flag.entity.ts new file mode 100644 index 0000000..779b16f --- /dev/null +++ b/src/modules/feature-flags/entities/flag.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + OneToMany, +} from 'typeorm'; +import { TenantOverride } from './tenant-override.entity'; + +@Entity({ name: 'flags', schema: 'feature_flags' }) +@Unique(['code']) +export class Flag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'enabled', type: 'boolean', default: false }) + enabled: boolean; + + @Column({ name: 'rollout_percentage', type: 'int', default: 100 }) + rolloutPercentage: number; + + @Column({ name: 'tags', type: 'text', array: true, nullable: true }) + tags: string[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TenantOverride, (override) => override.flag) + overrides: TenantOverride[]; +} diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts new file mode 100644 index 0000000..99afff4 --- /dev/null +++ b/src/modules/feature-flags/entities/index.ts @@ -0,0 +1,2 @@ +export { Flag } from './flag.entity'; +export { TenantOverride } from './tenant-override.entity'; diff --git a/src/modules/feature-flags/entities/tenant-override.entity.ts b/src/modules/feature-flags/entities/tenant-override.entity.ts new file mode 100644 index 0000000..eb65066 --- /dev/null +++ b/src/modules/feature-flags/entities/tenant-override.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ name: 'tenant_overrides', schema: 'feature_flags' }) +@Unique(['flagId', 'tenantId']) +export class TenantOverride { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'flag_id', type: 'uuid' }) + flagId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled: boolean; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/feature-flags/feature-flags.module.ts b/src/modules/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..82e814b --- /dev/null +++ b/src/modules/feature-flags/feature-flags.module.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { FeatureFlagsService } from './services'; +import { FeatureFlagsController } from './controllers'; +import { Flag, TenantOverride } from './entities'; + +export interface FeatureFlagsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class FeatureFlagsModule { + public router: Router; + public featureFlagsService: FeatureFlagsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: FeatureFlagsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const flagRepository = this.dataSource.getRepository(Flag); + const overrideRepository = this.dataSource.getRepository(TenantOverride); + + this.featureFlagsService = new FeatureFlagsService( + flagRepository, + overrideRepository + ); + } + + private initializeRoutes(): void { + const featureFlagsController = new FeatureFlagsController(this.featureFlagsService); + this.router.use(`${this.basePath}/feature-flags`, featureFlagsController.router); + } + + static getEntities(): Function[] { + return [Flag, TenantOverride]; + } +} diff --git a/src/modules/feature-flags/index.ts b/src/modules/feature-flags/index.ts new file mode 100644 index 0000000..2423724 --- /dev/null +++ b/src/modules/feature-flags/index.ts @@ -0,0 +1,5 @@ +export { FeatureFlagsModule, FeatureFlagsModuleOptions } from './feature-flags.module'; +export { FeatureFlagsService } from './services'; +export { FeatureFlagsController } from './controllers'; +export { Flag, TenantOverride } from './entities'; +export * from './dto'; diff --git a/src/modules/feature-flags/services/feature-flags.service.ts b/src/modules/feature-flags/services/feature-flags.service.ts new file mode 100644 index 0000000..5c8a86d --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.service.ts @@ -0,0 +1,345 @@ +import { Repository, In } from 'typeorm'; +import { createHash } from 'crypto'; +import { Flag, TenantOverride } from '../entities'; +import { + CreateFlagDto, + UpdateFlagDto, + CreateTenantOverrideDto, + UpdateTenantOverrideDto, + FlagEvaluationResult, +} from '../dto'; + +export class FeatureFlagsService { + constructor( + private readonly flagRepository: Repository, + private readonly overrideRepository: Repository + ) {} + + // ============================================ + // FLAGS - CRUD + // ============================================ + + async findAllFlags(): Promise { + return this.flagRepository.find({ + where: { isActive: true }, + order: { code: 'ASC' }, + }); + } + + async findAllFlagsIncludingInactive(): Promise { + return this.flagRepository.find({ + order: { code: 'ASC' }, + }); + } + + async findFlagById(id: string): Promise { + return this.flagRepository.findOne({ + where: { id }, + relations: ['overrides'], + }); + } + + async findFlagByCode(code: string): Promise { + return this.flagRepository.findOne({ + where: { code }, + relations: ['overrides'], + }); + } + + async findFlagsByTags(tags: string[]): Promise { + return this.flagRepository + .createQueryBuilder('flag') + .where('flag.is_active = true') + .andWhere('flag.tags && :tags', { tags }) + .orderBy('flag.code', 'ASC') + .getMany(); + } + + async createFlag(data: CreateFlagDto, createdBy?: string): Promise { + const flag = this.flagRepository.create({ + ...data, + createdBy, + }); + return this.flagRepository.save(flag); + } + + async updateFlag( + id: string, + data: UpdateFlagDto, + updatedBy?: string + ): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) return null; + + Object.assign(flag, data, { updatedBy }); + return this.flagRepository.save(flag); + } + + async deleteFlag(id: string): Promise { + const result = await this.flagRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async softDeleteFlag(id: string, updatedBy?: string): Promise { + return this.updateFlag(id, { isActive: false }, updatedBy); + } + + async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise { + return this.updateFlag(id, { enabled }, updatedBy); + } + + // ============================================ + // TENANT OVERRIDES - CRUD + // ============================================ + + async findOverridesForFlag(flagId: string): Promise { + return this.overrideRepository.find({ + where: { flagId }, + order: { createdAt: 'DESC' }, + }); + } + + async findOverridesForTenant(tenantId: string): Promise { + return this.overrideRepository.find({ + where: { tenantId }, + relations: ['flag'], + order: { createdAt: 'DESC' }, + }); + } + + async findOverride(flagId: string, tenantId: string): Promise { + return this.overrideRepository.findOne({ + where: { flagId, tenantId }, + relations: ['flag'], + }); + } + + async findOverrideById(id: string): Promise { + return this.overrideRepository.findOne({ + where: { id }, + relations: ['flag'], + }); + } + + async createOverride( + data: CreateTenantOverrideDto, + createdBy?: string + ): Promise { + const override = this.overrideRepository.create({ + ...data, + createdBy, + }); + return this.overrideRepository.save(override); + } + + async updateOverride( + id: string, + data: UpdateTenantOverrideDto + ): Promise { + const override = await this.overrideRepository.findOne({ where: { id } }); + if (!override) return null; + + Object.assign(override, data); + return this.overrideRepository.save(override); + } + + async deleteOverride(id: string): Promise { + const result = await this.overrideRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise { + const result = await this.overrideRepository.delete({ flagId, tenantId }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // FLAG EVALUATION + // ============================================ + + /** + * Evaluates a single flag for a tenant. + * Priority: tenant override > global flag > rollout > default (false) + */ + async evaluateFlag(flagCode: string, tenantId: string): Promise { + // 1. Find the flag + const flag = await this.flagRepository.findOne({ + where: { code: flagCode, isActive: true }, + }); + + if (!flag) { + return { code: flagCode, enabled: false, source: 'default' }; + } + + // 2. Check tenant override + const override = await this.overrideRepository.findOne({ + where: { flagId: flag.id, tenantId }, + }); + + if (override) { + // Check if override is expired + if (!override.expiresAt || override.expiresAt > new Date()) { + return { code: flagCode, enabled: override.enabled, source: 'override' }; + } + } + + // 3. Check global flag state + if (!flag.enabled) { + return { code: flagCode, enabled: false, source: 'global' }; + } + + // 4. Evaluate rollout percentage + if (flag.rolloutPercentage >= 100) { + return { code: flagCode, enabled: true, source: 'global' }; + } + + if (flag.rolloutPercentage <= 0) { + return { code: flagCode, enabled: false, source: 'rollout' }; + } + + // 5. Deterministic hash-based rollout + const bucket = this.calculateBucket(flagCode, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code: flagCode, enabled, source: 'rollout' }; + } + + /** + * Evaluates multiple flags for a tenant in a single call. + * More efficient than calling evaluateFlag multiple times. + */ + async evaluateFlags( + flagCodes: string[], + tenantId: string + ): Promise { + // Get all requested flags in one query + const flags = await this.flagRepository.find({ + where: { code: In(flagCodes), isActive: true }, + }); + + const flagMap = new Map(flags.map((f) => [f.code, f])); + + // Get all overrides for this tenant and these flags in one query + const flagIds = flags.map((f) => f.id); + const overrides = await this.overrideRepository.find({ + where: { flagId: In(flagIds), tenantId }, + }); + + const overrideMap = new Map(overrides.map((o) => [o.flagId, o])); + const now = new Date(); + + // Evaluate each flag + return flagCodes.map((code) => { + const flag = flagMap.get(code); + + if (!flag) { + return { code, enabled: false, source: 'default' as const }; + } + + const override = overrideMap.get(flag.id); + + if (override && (!override.expiresAt || override.expiresAt > now)) { + return { code, enabled: override.enabled, source: 'override' as const }; + } + + if (!flag.enabled) { + return { code, enabled: false, source: 'global' as const }; + } + + if (flag.rolloutPercentage >= 100) { + return { code, enabled: true, source: 'global' as const }; + } + + if (flag.rolloutPercentage <= 0) { + return { code, enabled: false, source: 'rollout' as const }; + } + + const bucket = this.calculateBucket(code, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code, enabled, source: 'rollout' as const }; + }); + } + + /** + * Quick boolean check for a single flag. + */ + async isEnabled(flagCode: string, tenantId: string): Promise { + const result = await this.evaluateFlag(flagCode, tenantId); + return result.enabled; + } + + // ============================================ + // MAINTENANCE + // ============================================ + + /** + * Removes expired overrides from the database. + * Should be called periodically via cron job. + */ + async cleanupExpiredOverrides(): Promise { + const now = new Date(); + const result = await this.overrideRepository + .createQueryBuilder() + .delete() + .where('expires_at IS NOT NULL') + .andWhere('expires_at < :now', { now }) + .execute(); + + return result.affected ?? 0; + } + + /** + * Gets statistics for a flag including override counts. + */ + async getFlagStats(flagId: string): Promise<{ + flag: Flag | null; + overrideCount: number; + enabledOverrides: number; + disabledOverrides: number; + }> { + const flag = await this.flagRepository.findOne({ where: { id: flagId } }); + + if (!flag) { + return { + flag: null, + overrideCount: 0, + enabledOverrides: 0, + disabledOverrides: 0, + }; + } + + const now = new Date(); + const overrides = await this.overrideRepository + .createQueryBuilder('o') + .where('o.flag_id = :flagId', { flagId }) + .andWhere('(o.expires_at IS NULL OR o.expires_at > :now)', { now }) + .getMany(); + + const enabledOverrides = overrides.filter((o) => o.enabled).length; + const disabledOverrides = overrides.filter((o) => !o.enabled).length; + + return { + flag, + overrideCount: overrides.length, + enabledOverrides, + disabledOverrides, + }; + } + + // ============================================ + // PRIVATE HELPERS + // ============================================ + + /** + * Calculates a deterministic bucket (0-99) for rollout evaluation. + * Uses MD5 hash of flag code + tenant ID for consistent results. + */ + private calculateBucket(flagCode: string, tenantId: string): number { + const input = `${flagCode}:${tenantId}`; + const hash = createHash('md5').update(input).digest('hex'); + // Take first 8 chars of hash and convert to number + const num = parseInt(hash.substring(0, 8), 16); + return Math.abs(num % 100); + } +} diff --git a/src/modules/feature-flags/services/index.ts b/src/modules/feature-flags/services/index.ts new file mode 100644 index 0000000..4415dc0 --- /dev/null +++ b/src/modules/feature-flags/services/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsService } from './feature-flags.service'; diff --git a/src/modules/inventory/controllers/index.ts b/src/modules/inventory/controllers/index.ts new file mode 100644 index 0000000..3f5eb53 --- /dev/null +++ b/src/modules/inventory/controllers/index.ts @@ -0,0 +1 @@ +export { InventoryController } from './inventory.controller'; diff --git a/src/modules/inventory/controllers/inventory.controller.ts b/src/modules/inventory/controllers/inventory.controller.ts new file mode 100644 index 0000000..b7efb39 --- /dev/null +++ b/src/modules/inventory/controllers/inventory.controller.ts @@ -0,0 +1,342 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { InventoryService } from '../services/inventory.service'; +import { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from '../dto'; + +export class InventoryController { + public router: Router; + + constructor(private readonly inventoryService: InventoryService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stock Levels + this.router.get('/stock', this.getStockLevels.bind(this)); + this.router.get('/stock/product/:productId', this.getStockByProduct.bind(this)); + this.router.get('/stock/warehouse/:warehouseId', this.getStockByWarehouse.bind(this)); + this.router.get( + '/stock/available/:productId/:warehouseId', + this.getAvailableStock.bind(this) + ); + + // Movements + this.router.get('/movements', this.getMovements.bind(this)); + this.router.get('/movements/:id', this.getMovement.bind(this)); + this.router.post('/movements', this.createMovement.bind(this)); + this.router.post('/movements/:id/confirm', this.confirmMovement.bind(this)); + this.router.post('/movements/:id/cancel', this.cancelMovement.bind(this)); + + // Operations + this.router.post('/adjust', this.adjustStock.bind(this)); + this.router.post('/transfer', this.transferStock.bind(this)); + this.router.post('/reserve', this.reserveStock.bind(this)); + this.router.post('/release', this.releaseReservation.bind(this)); + } + + // ==================== Stock Levels ==================== + + private async getStockLevels(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { + productId, + warehouseId, + locationId, + lotNumber, + hasStock, + lowStock, + limit, + offset, + } = req.query; + + const result = await this.inventoryService.getStockLevels({ + tenantId, + productId: productId as string, + warehouseId: warehouseId as string, + locationId: locationId as string, + lotNumber: lotNumber as string, + hasStock: hasStock ? hasStock === 'true' : undefined, + lowStock: lowStock ? lowStock === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async getStockByProduct(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { productId } = req.params; + const stock = await this.inventoryService.getStockByProduct(productId, tenantId); + res.json({ data: stock }); + } catch (error) { + next(error); + } + } + + private async getStockByWarehouse( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { warehouseId } = req.params; + const stock = await this.inventoryService.getStockByWarehouse(warehouseId, tenantId); + res.json({ data: stock }); + } catch (error) { + next(error); + } + } + + private async getAvailableStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { productId, warehouseId } = req.params; + const available = await this.inventoryService.getAvailableStock( + productId, + warehouseId, + tenantId + ); + res.json({ data: { available } }); + } catch (error) { + next(error); + } + } + + // ==================== Movements ==================== + + private async getMovements(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { + movementType, + productId, + warehouseId, + status, + referenceType, + referenceId, + fromDate, + toDate, + limit, + offset, + } = req.query; + + const result = await this.inventoryService.getMovements({ + tenantId, + movementType: movementType as string, + productId: productId as string, + warehouseId: warehouseId as string, + status: status as 'draft' | 'confirmed' | 'cancelled', + referenceType: referenceType as string, + referenceId: referenceId as string, + fromDate: fromDate ? new Date(fromDate as string) : undefined, + toDate: toDate ? new Date(toDate as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async getMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const movement = await this.inventoryService.getMovement(id, tenantId); + + if (!movement) { + res.status(404).json({ error: 'Movement not found' }); + return; + } + + res.json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async createMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateStockMovementDto = req.body; + const movement = await this.inventoryService.createMovement(tenantId, dto, userId); + res.status(201).json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async confirmMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const movement = await this.inventoryService.confirmMovement(id, tenantId, userId); + + if (!movement) { + res.status(404).json({ error: 'Movement not found' }); + return; + } + + res.json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async cancelMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const movement = await this.inventoryService.cancelMovement(id, tenantId); + + if (!movement) { + res.status(404).json({ error: 'Movement not found' }); + return; + } + + res.json({ data: movement }); + } catch (error) { + next(error); + } + } + + // ==================== Operations ==================== + + private async adjustStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: AdjustStockDto = req.body; + const movement = await this.inventoryService.adjustStock(tenantId, dto, userId); + res.status(201).json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async transferStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: TransferStockDto = req.body; + const movement = await this.inventoryService.transferStock(tenantId, dto, userId); + res.status(201).json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async reserveStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: ReserveStockDto = req.body; + await this.inventoryService.reserveStock(tenantId, dto); + res.json({ success: true }); + } catch (error) { + next(error); + } + } + + private async releaseReservation( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { productId, warehouseId, quantity } = req.body; + await this.inventoryService.releaseReservation(productId, warehouseId, quantity, tenantId); + res.json({ success: true }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/inventory/dto/create-inventory.dto.ts b/src/modules/inventory/dto/create-inventory.dto.ts new file mode 100644 index 0000000..2550261 --- /dev/null +++ b/src/modules/inventory/dto/create-inventory.dto.ts @@ -0,0 +1,192 @@ +import { + IsString, + IsOptional, + IsNumber, + IsUUID, + IsDateString, + MaxLength, + IsEnum, + Min, +} from 'class-validator'; + +export class CreateStockMovementDto { + @IsEnum(['receipt', 'shipment', 'transfer', 'adjustment', 'return', 'production', 'consumption']) + movementType: + | 'receipt' + | 'shipment' + | 'transfer' + | 'adjustment' + | 'return' + | 'production' + | 'consumption'; + + @IsUUID() + productId: string; + + @IsOptional() + @IsUUID() + sourceWarehouseId?: string; + + @IsOptional() + @IsUUID() + sourceLocationId?: string; + + @IsOptional() + @IsUUID() + destWarehouseId?: string; + + @IsOptional() + @IsUUID() + destLocationId?: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsOptional() + @IsString() + @MaxLength(20) + uom?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + serialNumber?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + unitCost?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + referenceType?: string; + + @IsOptional() + @IsUUID() + referenceId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + referenceNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + reason?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class AdjustStockDto { + @IsUUID() + productId: string; + + @IsUUID() + warehouseId: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsNumber() + newQuantity: number; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + serialNumber?: string; + + @IsString() + @MaxLength(100) + reason: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class TransferStockDto { + @IsUUID() + productId: string; + + @IsUUID() + sourceWarehouseId: string; + + @IsOptional() + @IsUUID() + sourceLocationId?: string; + + @IsUUID() + destWarehouseId: string; + + @IsOptional() + @IsUUID() + destLocationId?: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + serialNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class ReserveStockDto { + @IsUUID() + productId: string; + + @IsUUID() + warehouseId: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + referenceType?: string; + + @IsOptional() + @IsUUID() + referenceId?: string; +} diff --git a/src/modules/inventory/dto/index.ts b/src/modules/inventory/dto/index.ts new file mode 100644 index 0000000..2011421 --- /dev/null +++ b/src/modules/inventory/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from './create-inventory.dto'; diff --git a/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts index 5a7df30..5d42a11 100644 --- a/src/modules/inventory/entities/index.ts +++ b/src/modules/inventory/entities/index.ts @@ -1,11 +1,6 @@ -// Export all inventory entities -export * from './product.entity.js'; -export * from './warehouse.entity.js'; -export * from './location.entity.js'; -export * from './stock-quant.entity.js'; -export * from './lot.entity.js'; -export * from './picking.entity.js'; -export * from './stock-move.entity.js'; -export * from './inventory-adjustment.entity.js'; -export * from './inventory-adjustment-line.entity.js'; -export * from './stock-valuation-layer.entity.js'; +export { StockLevel } from './stock-level.entity'; +export { StockMovement } from './stock-movement.entity'; +export { InventoryCount } from './inventory-count.entity'; +export { InventoryCountLine } from './inventory-count-line.entity'; +export { TransferOrder } from './transfer-order.entity'; +export { TransferOrderLine } from './transfer-order-line.entity'; diff --git a/src/modules/inventory/entities/inventory-count-line.entity.ts b/src/modules/inventory/entities/inventory-count-line.entity.ts new file mode 100644 index 0000000..5aa1297 --- /dev/null +++ b/src/modules/inventory/entities/inventory-count-line.entity.ts @@ -0,0 +1,56 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { InventoryCount } from './inventory-count.entity'; + +@Entity({ name: 'inventory_count_lines', schema: 'inventory' }) +export class InventoryCountLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'count_id', type: 'uuid' }) + countId: string; + + @ManyToOne(() => InventoryCount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'count_id' }) + count: InventoryCount; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Index() + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + systemQuantity?: number; + + @Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + countedQuantity?: number; + + // Note: difference is GENERATED in DDL, but we calculate it in app layer + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Index() + @Column({ name: 'is_counted', type: 'boolean', default: false }) + isCounted: boolean; + + @Column({ name: 'counted_at', type: 'timestamptz', nullable: true }) + countedAt?: Date; + + @Column({ name: 'counted_by', type: 'uuid', nullable: true }) + countedBy?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/inventory/entities/inventory-count.entity.ts b/src/modules/inventory/entities/inventory-count.entity.ts new file mode 100644 index 0000000..229c5f0 --- /dev/null +++ b/src/modules/inventory/entities/inventory-count.entity.ts @@ -0,0 +1,53 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'inventory_counts', schema: 'inventory' }) +export class InventoryCount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ name: 'count_number', type: 'varchar', length: 30 }) + countNumber: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name?: string; + + @Index() + @Column({ name: 'count_type', type: 'varchar', length: 20, default: 'full' }) + countType: 'full' | 'partial' | 'cycle' | 'spot'; + + @Column({ name: 'scheduled_date', type: 'date', nullable: true }) + scheduledDate?: Date; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt?: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'in_progress' | 'completed' | 'cancelled'; + + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @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; +} diff --git a/src/modules/inventory/entities/stock-level.entity.ts b/src/modules/inventory/entities/stock-level.entity.ts new file mode 100644 index 0000000..7a29f95 --- /dev/null +++ b/src/modules/inventory/entities/stock-level.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'stock_levels', schema: 'inventory' }) +export class StockLevel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Index() + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId: string; + + // Cantidades + @Column({ name: 'quantity_on_hand', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityOnHand: number; + + @Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReserved: number; + + // quantity_available es calculado en DDL como GENERATED COLUMN, lo leemos aquí + @Column({ + name: 'quantity_available', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + }) + quantityAvailable: number; + + @Column({ name: 'quantity_incoming', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityIncoming: number; + + @Column({ name: 'quantity_outgoing', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityOutgoing: number; + + // Lote y serie + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber: string; + + @Index() + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Costo + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + unitCost: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + totalCost: number; + + // Ultima actividad + @Column({ name: 'last_movement_at', type: 'timestamptz', nullable: true }) + lastMovementAt: Date; + + @Column({ name: 'last_count_at', type: 'timestamptz', nullable: true }) + lastCountAt: Date; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/inventory/entities/stock-movement.entity.ts b/src/modules/inventory/entities/stock-movement.entity.ts new file mode 100644 index 0000000..424f4be --- /dev/null +++ b/src/modules/inventory/entities/stock-movement.entity.ts @@ -0,0 +1,122 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'stock_movements', schema: 'inventory' }) +export class StockMovement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de movimiento + @Index() + @Column({ name: 'movement_type', type: 'varchar', length: 20 }) + movementType: + | 'receipt' + | 'shipment' + | 'transfer' + | 'adjustment' + | 'return' + | 'production' + | 'consumption'; + + @Index() + @Column({ name: 'movement_number', type: 'varchar', length: 30 }) + movementNumber: string; + + // Producto + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + // Origen y destino + @Index() + @Column({ name: 'source_warehouse_id', type: 'uuid', nullable: true }) + sourceWarehouseId: string; + + @Column({ name: 'source_location_id', type: 'uuid', nullable: true }) + sourceLocationId: string; + + @Index() + @Column({ name: 'dest_warehouse_id', type: 'uuid', nullable: true }) + destWarehouseId: string; + + @Column({ name: 'dest_location_id', type: 'uuid', nullable: true }) + destLocationId: string; + + // Cantidad + @Column({ type: 'decimal', precision: 15, scale: 4 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + // Lote y serie + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Costo + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + unitCost: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + totalCost: number; + + // Referencia + @Index() + @Column({ name: 'reference_type', type: 'varchar', length: 30, nullable: true }) + referenceType: string; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId: string; + + @Column({ name: 'reference_number', type: 'varchar', length: 50, nullable: true }) + referenceNumber: string; + + // Razon (para ajustes) + @Column({ type: 'varchar', length: 100, nullable: true }) + reason: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'cancelled'; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt: Date; + + @Column({ name: 'confirmed_by', type: 'uuid', nullable: true }) + confirmedBy: string; + + // 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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/inventory/entities/transfer-order-line.entity.ts b/src/modules/inventory/entities/transfer-order-line.entity.ts new file mode 100644 index 0000000..a2a2133 --- /dev/null +++ b/src/modules/inventory/entities/transfer-order-line.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { TransferOrder } from './transfer-order.entity'; + +@Entity({ name: 'transfer_order_lines', schema: 'inventory' }) +export class TransferOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'transfer_id', type: 'uuid' }) + transferId: string; + + @ManyToOne(() => TransferOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'transfer_id' }) + transfer: TransferOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'source_location_id', type: 'uuid', nullable: true }) + sourceLocationId?: string; + + @Column({ name: 'dest_location_id', type: 'uuid', nullable: true }) + destLocationId?: string; + + @Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 }) + quantityRequested: number; + + @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityShipped: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReceived: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/inventory/entities/transfer-order.entity.ts b/src/modules/inventory/entities/transfer-order.entity.ts new file mode 100644 index 0000000..7deb1f0 --- /dev/null +++ b/src/modules/inventory/entities/transfer-order.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'transfer_orders', schema: 'inventory' }) +export class TransferOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'transfer_number', type: 'varchar', length: 30 }) + transferNumber: string; + + @Index() + @Column({ name: 'source_warehouse_id', type: 'uuid' }) + sourceWarehouseId: string; + + @Index() + @Column({ name: 'dest_warehouse_id', type: 'uuid' }) + destWarehouseId: string; + + @Column({ name: 'scheduled_date', type: 'date', nullable: true }) + scheduledDate?: Date; + + @Column({ name: 'shipped_at', type: 'timestamptz', nullable: true }) + shippedAt?: Date; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'shipped' | 'in_transit' | 'received' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/inventory/index.ts b/src/modules/inventory/index.ts index 40c84f5..25f38d4 100644 --- a/src/modules/inventory/index.ts +++ b/src/modules/inventory/index.ts @@ -1,16 +1,5 @@ -export * from './products.service.js'; -export * from './warehouses.service.js'; -export { - locationsService, - Location as InventoryLocation, - CreateLocationDto, - UpdateLocationDto, - LocationFilters -} from './locations.service.js'; -export * from './pickings.service.js'; -export * from './lots.service.js'; -export * from './adjustments.service.js'; -export * from './valuation.service.js'; -export * from './inventory.controller.js'; -export * from './valuation.controller.js'; -export { default as inventoryRoutes } from './inventory.routes.js'; +export { InventoryModule, InventoryModuleOptions } from './inventory.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/inventory/inventory.module.ts b/src/modules/inventory/inventory.module.ts new file mode 100644 index 0000000..178a301 --- /dev/null +++ b/src/modules/inventory/inventory.module.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { InventoryService } from './services'; +import { InventoryController } from './controllers'; +import { StockLevel, StockMovement } from './entities'; + +export interface InventoryModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class InventoryModule { + public router: Router; + public inventoryService: InventoryService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: InventoryModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const stockLevelRepository = this.dataSource.getRepository(StockLevel); + const movementRepository = this.dataSource.getRepository(StockMovement); + + this.inventoryService = new InventoryService( + stockLevelRepository, + movementRepository, + this.dataSource + ); + } + + private initializeRoutes(): void { + const inventoryController = new InventoryController(this.inventoryService); + this.router.use(`${this.basePath}/inventory`, inventoryController.router); + } + + static getEntities(): Function[] { + return [StockLevel, StockMovement]; + } +} diff --git a/src/modules/inventory/services/index.ts b/src/modules/inventory/services/index.ts new file mode 100644 index 0000000..d9ca5a2 --- /dev/null +++ b/src/modules/inventory/services/index.ts @@ -0,0 +1,5 @@ +export { + InventoryService, + StockSearchParams, + MovementSearchParams, +} from './inventory.service'; diff --git a/src/modules/inventory/services/inventory.service.ts b/src/modules/inventory/services/inventory.service.ts new file mode 100644 index 0000000..7a08332 --- /dev/null +++ b/src/modules/inventory/services/inventory.service.ts @@ -0,0 +1,470 @@ +import { Repository, FindOptionsWhere, ILike, DataSource } from 'typeorm'; +import { StockLevel, StockMovement } from '../entities'; +import { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from '../dto'; + +export interface StockSearchParams { + tenantId: string; + productId?: string; + warehouseId?: string; + locationId?: string; + lotNumber?: string; + hasStock?: boolean; + lowStock?: boolean; + limit?: number; + offset?: number; +} + +export interface MovementSearchParams { + tenantId: string; + movementType?: string; + productId?: string; + warehouseId?: string; + status?: 'draft' | 'confirmed' | 'cancelled'; + referenceType?: string; + referenceId?: string; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +export class InventoryService { + constructor( + private readonly stockLevelRepository: Repository, + private readonly movementRepository: Repository, + private readonly dataSource: DataSource + ) {} + + // ==================== Stock Levels ==================== + + async getStockLevels( + params: StockSearchParams + ): Promise<{ data: StockLevel[]; total: number }> { + const { + tenantId, + productId, + warehouseId, + locationId, + lotNumber, + hasStock, + lowStock, + limit = 50, + offset = 0, + } = params; + + const qb = this.stockLevelRepository + .createQueryBuilder('stock') + .where('stock.tenant_id = :tenantId', { tenantId }); + + if (productId) { + qb.andWhere('stock.product_id = :productId', { productId }); + } + + if (warehouseId) { + qb.andWhere('stock.warehouse_id = :warehouseId', { warehouseId }); + } + + if (locationId) { + qb.andWhere('stock.location_id = :locationId', { locationId }); + } + + if (lotNumber) { + qb.andWhere('stock.lot_number = :lotNumber', { lotNumber }); + } + + if (hasStock) { + qb.andWhere('stock.quantity_on_hand > 0'); + } + + if (lowStock) { + qb.andWhere('stock.quantity_on_hand <= 0'); + } + + const [data, total] = await qb + .orderBy('stock.product_id', 'ASC') + .addOrderBy('stock.warehouse_id', 'ASC') + .take(limit) + .skip(offset) + .getManyAndCount(); + + return { data, total }; + } + + async getStockByProduct( + productId: string, + tenantId: string + ): Promise { + return this.stockLevelRepository.find({ + where: { productId, tenantId }, + order: { warehouseId: 'ASC' }, + }); + } + + async getStockByWarehouse( + warehouseId: string, + tenantId: string + ): Promise { + return this.stockLevelRepository.find({ + where: { warehouseId, tenantId }, + order: { productId: 'ASC' }, + }); + } + + async getAvailableStock( + productId: string, + warehouseId: string, + tenantId: string + ): Promise { + const stock = await this.stockLevelRepository.findOne({ + where: { productId, warehouseId, tenantId }, + }); + return stock?.quantityAvailable ?? 0; + } + + // ==================== Stock Movements ==================== + + async getMovements( + params: MovementSearchParams + ): Promise<{ data: StockMovement[]; total: number }> { + const { + tenantId, + movementType, + productId, + warehouseId, + status, + referenceType, + referenceId, + fromDate, + toDate, + limit = 50, + offset = 0, + } = params; + + const qb = this.movementRepository + .createQueryBuilder('movement') + .where('movement.tenant_id = :tenantId', { tenantId }); + + if (movementType) { + qb.andWhere('movement.movement_type = :movementType', { movementType }); + } + + if (productId) { + qb.andWhere('movement.product_id = :productId', { productId }); + } + + if (warehouseId) { + qb.andWhere( + '(movement.source_warehouse_id = :warehouseId OR movement.dest_warehouse_id = :warehouseId)', + { warehouseId } + ); + } + + if (status) { + qb.andWhere('movement.status = :status', { status }); + } + + if (referenceType) { + qb.andWhere('movement.reference_type = :referenceType', { referenceType }); + } + + if (referenceId) { + qb.andWhere('movement.reference_id = :referenceId', { referenceId }); + } + + if (fromDate) { + qb.andWhere('movement.created_at >= :fromDate', { fromDate }); + } + + if (toDate) { + qb.andWhere('movement.created_at <= :toDate', { toDate }); + } + + const [data, total] = await qb + .orderBy('movement.created_at', 'DESC') + .take(limit) + .skip(offset) + .getManyAndCount(); + + return { data, total }; + } + + async getMovement(id: string, tenantId: string): Promise { + return this.movementRepository.findOne({ where: { id, tenantId } }); + } + + async createMovement( + tenantId: string, + dto: CreateStockMovementDto, + createdBy?: string + ): Promise { + // Generate movement number + const count = await this.movementRepository.count({ where: { tenantId } }); + const movementNumber = `MOV-${String(count + 1).padStart(6, '0')}`; + + const totalCost = dto.unitCost ? dto.unitCost * dto.quantity : undefined; + + const movement = this.movementRepository.create({ + ...dto, + tenantId, + movementNumber, + totalCost, + expiryDate: dto.expiryDate ? new Date(dto.expiryDate) : undefined, + createdBy, + }); + + return this.movementRepository.save(movement); + } + + async confirmMovement( + id: string, + tenantId: string, + confirmedBy: string + ): Promise { + const movement = await this.getMovement(id, tenantId); + if (!movement) return null; + + if (movement.status !== 'draft') { + throw new Error('Only draft movements can be confirmed'); + } + + // Update stock levels based on movement type + await this.applyMovementToStock(movement); + + movement.status = 'confirmed'; + movement.confirmedAt = new Date(); + movement.confirmedBy = confirmedBy; + + return this.movementRepository.save(movement); + } + + async cancelMovement(id: string, tenantId: string): Promise { + const movement = await this.getMovement(id, tenantId); + if (!movement) return null; + + if (movement.status === 'confirmed') { + throw new Error('Cannot cancel confirmed movement'); + } + + movement.status = 'cancelled'; + return this.movementRepository.save(movement); + } + + // ==================== Stock Operations ==================== + + async adjustStock( + tenantId: string, + dto: AdjustStockDto, + userId?: string + ): Promise { + const currentStock = await this.getStockLevel( + dto.productId, + dto.warehouseId, + dto.locationId, + dto.lotNumber, + dto.serialNumber, + tenantId + ); + + const currentQuantity = currentStock?.quantityOnHand ?? 0; + const difference = dto.newQuantity - currentQuantity; + + const movement = await this.createMovement( + tenantId, + { + movementType: 'adjustment', + productId: dto.productId, + destWarehouseId: dto.warehouseId, + destLocationId: dto.locationId, + quantity: Math.abs(difference), + lotNumber: dto.lotNumber, + serialNumber: dto.serialNumber, + reason: dto.reason, + notes: dto.notes, + }, + userId + ); + + return this.confirmMovement(movement.id, tenantId, userId || '') as Promise; + } + + async transferStock( + tenantId: string, + dto: TransferStockDto, + userId?: string + ): Promise { + // Verify available stock + const available = await this.getAvailableStock( + dto.productId, + dto.sourceWarehouseId, + tenantId + ); + + if (available < dto.quantity) { + throw new Error('Insufficient stock for transfer'); + } + + const movement = await this.createMovement( + tenantId, + { + movementType: 'transfer', + productId: dto.productId, + sourceWarehouseId: dto.sourceWarehouseId, + sourceLocationId: dto.sourceLocationId, + destWarehouseId: dto.destWarehouseId, + destLocationId: dto.destLocationId, + quantity: dto.quantity, + lotNumber: dto.lotNumber, + serialNumber: dto.serialNumber, + notes: dto.notes, + }, + userId + ); + + return this.confirmMovement(movement.id, tenantId, userId || '') as Promise; + } + + async reserveStock(tenantId: string, dto: ReserveStockDto): Promise { + const stock = await this.getStockLevel( + dto.productId, + dto.warehouseId, + dto.locationId, + dto.lotNumber, + undefined, + tenantId + ); + + if (!stock || stock.quantityAvailable < dto.quantity) { + throw new Error('Insufficient available stock for reservation'); + } + + stock.quantityReserved = Number(stock.quantityReserved) + dto.quantity; + await this.stockLevelRepository.save(stock); + + return true; + } + + async releaseReservation( + productId: string, + warehouseId: string, + quantity: number, + tenantId: string + ): Promise { + const stock = await this.stockLevelRepository.findOne({ + where: { productId, warehouseId, tenantId }, + }); + + if (!stock) return false; + + stock.quantityReserved = Math.max(0, Number(stock.quantityReserved) - quantity); + await this.stockLevelRepository.save(stock); + + return true; + } + + // ==================== Private Methods ==================== + + private async getStockLevel( + productId: string, + warehouseId: string, + locationId: string | undefined, + lotNumber: string | undefined, + serialNumber: string | undefined, + tenantId: string + ): Promise { + const where: FindOptionsWhere = { + productId, + warehouseId, + tenantId, + }; + + if (locationId) where.locationId = locationId; + if (lotNumber) where.lotNumber = lotNumber; + if (serialNumber) where.serialNumber = serialNumber; + + return this.stockLevelRepository.findOne({ where }); + } + + private async applyMovementToStock(movement: StockMovement): Promise { + const { movementType, productId, quantity, sourceWarehouseId, destWarehouseId, lotNumber } = + movement; + + // Decrease source stock + if (sourceWarehouseId && ['shipment', 'transfer', 'consumption'].includes(movementType)) { + await this.updateStockLevel( + productId, + sourceWarehouseId, + movement.sourceLocationId, + lotNumber, + movement.serialNumber, + movement.tenantId, + -quantity + ); + } + + // Increase destination stock + if (destWarehouseId && ['receipt', 'transfer', 'adjustment', 'return', 'production'].includes(movementType)) { + await this.updateStockLevel( + productId, + destWarehouseId, + movement.destLocationId, + lotNumber, + movement.serialNumber, + movement.tenantId, + quantity, + movement.unitCost + ); + } + } + + private async updateStockLevel( + productId: string, + warehouseId: string, + locationId: string | null, + lotNumber: string | null, + serialNumber: string | null, + tenantId: string, + quantityChange: number, + unitCost?: number + ): Promise { + let stock = await this.stockLevelRepository.findOne({ + where: { + productId, + warehouseId, + locationId: locationId || undefined, + lotNumber: lotNumber || undefined, + serialNumber: serialNumber || undefined, + tenantId, + }, + }); + + if (!stock) { + stock = this.stockLevelRepository.create({ + productId, + warehouseId, + locationId: locationId || undefined, + lotNumber: lotNumber || undefined, + serialNumber: serialNumber || undefined, + tenantId, + quantityOnHand: 0, + quantityReserved: 0, + quantityIncoming: 0, + quantityOutgoing: 0, + } as Partial); + } + + stock.quantityOnHand = Number(stock.quantityOnHand) + quantityChange; + stock.lastMovementAt = new Date(); + + if (unitCost !== undefined) { + stock.unitCost = unitCost; + stock.totalCost = stock.quantityOnHand * unitCost; + } + + await this.stockLevelRepository.save(stock); + } +} diff --git a/src/modules/invoices/controllers/index.ts b/src/modules/invoices/controllers/index.ts new file mode 100644 index 0000000..8eec907 --- /dev/null +++ b/src/modules/invoices/controllers/index.ts @@ -0,0 +1,129 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { InvoicesService } from '../services'; + +export class InvoicesController { + public router: Router; + constructor(private readonly invoicesService: InvoicesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/validate', this.validate.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { invoiceType, partnerId, status, limit, offset } = req.query; + const result = await this.invoicesService.findAllInvoices({ tenantId, invoiceType: invoiceType as string, partnerId: partnerId as string, status: status as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.findInvoice(req.params.id, tenantId); + if (!invoice) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: invoice }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.createInvoice(tenantId, req.body, userId); + res.status(201).json({ data: invoice }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.updateInvoice(req.params.id, tenantId, req.body, userId); + if (!invoice) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: invoice }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.invoicesService.deleteInvoice(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async validate(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.validateInvoice(req.params.id, tenantId, userId); + if (!invoice) { res.status(400).json({ error: 'Cannot validate' }); return; } + res.json({ data: invoice }); + } catch (e) { next(e); } + } +} + +export class PaymentsController { + public router: Router; + constructor(private readonly invoicesService: InvoicesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.post('/:id/confirm', this.confirm.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { partnerId, limit, offset } = req.query; + const result = await this.invoicesService.findAllPayments(tenantId, partnerId as string, limit ? parseInt(limit as string) : undefined, offset ? parseInt(offset as string) : undefined); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const payment = await this.invoicesService.findPayment(req.params.id, tenantId); + if (!payment) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: payment }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const payment = await this.invoicesService.createPayment(tenantId, req.body, userId); + res.status(201).json({ data: payment }); + } catch (e) { next(e); } + } + + private async confirm(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const payment = await this.invoicesService.confirmPayment(req.params.id, tenantId, userId); + if (!payment) { res.status(400).json({ error: 'Cannot confirm' }); return; } + res.json({ data: payment }); + } catch (e) { next(e); } + } +} diff --git a/src/modules/invoices/dto/index.ts b/src/modules/invoices/dto/index.ts new file mode 100644 index 0000000..0fd6a03 --- /dev/null +++ b/src/modules/invoices/dto/index.ts @@ -0,0 +1,59 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator'; + +export class CreateInvoiceDto { + @IsEnum(['sale', 'purchase', 'credit_note', 'debit_note']) invoiceType: 'sale' | 'purchase' | 'credit_note' | 'debit_note'; + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsString() @MaxLength(50) partnerTaxId?: string; + @IsOptional() @IsUUID() salesOrderId?: string; + @IsOptional() @IsUUID() purchaseOrderId?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsDateString() invoiceDate?: string; + @IsOptional() @IsDateString() dueDate?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() exchangeRate?: number; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() items?: CreateInvoiceItemDto[]; +} + +export class CreateInvoiceItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsOptional() @IsString() @MaxLength(20) satProductCode?: string; + @IsOptional() @IsString() @MaxLength(10) satUnitCode?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdateInvoiceDto { + @IsOptional() @IsDateString() dueDate?: string; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'validated', 'sent', 'partial', 'paid', 'cancelled', 'voided']) status?: string; +} + +export class CreatePaymentDto { + @IsEnum(['received', 'made']) paymentType: 'received' | 'made'; + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsNumber() @Min(0) amount: number; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() exchangeRate?: number; + @IsOptional() @IsDateString() paymentDate?: string; + @IsString() @MaxLength(50) paymentMethod: string; + @IsOptional() @IsString() @MaxLength(100) reference?: string; + @IsOptional() @IsUUID() bankAccountId?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() invoiceAllocations?: InvoiceAllocationDto[]; +} + +export class InvoiceAllocationDto { + @IsUUID() invoiceId: string; + @IsNumber() @Min(0) amount: number; +} diff --git a/src/modules/invoices/entities/index.ts b/src/modules/invoices/entities/index.ts new file mode 100644 index 0000000..4e9cd0d --- /dev/null +++ b/src/modules/invoices/entities/index.ts @@ -0,0 +1,4 @@ +export { Invoice } from './invoice.entity'; +export { InvoiceItem } from './invoice-item.entity'; +export { Payment } from './payment.entity'; +export { PaymentAllocation } from './payment-allocation.entity'; diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100644 index 0000000..38dc4cd --- /dev/null +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,78 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode?: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'withholding_rate', type: 'decimal', precision: 5, scale: 2, default: 0 }) + withholdingRate: number; + + @Column({ name: 'withholding_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts new file mode 100644 index 0000000..13ee790 --- /dev/null +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -0,0 +1,118 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'invoice_number', type: 'varchar', length: 30 }) + invoiceNumber: string; + + @Index() + @Column({ name: 'invoice_type', type: 'varchar', length: 20, default: 'sale' }) + invoiceType: 'sale' | 'purchase' | 'credit_note' | 'debit_note'; + + @Column({ name: 'sales_order_id', type: 'uuid', nullable: true }) + salesOrderId: string; + + @Column({ name: 'purchase_order_id', type: 'uuid', nullable: true }) + purchaseOrderId: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_tax_id', type: 'varchar', length: 50, nullable: true }) + partnerTaxId: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'invoice_date', type: 'date', default: () => 'CURRENT_DATE' }) + invoiceDate: Date; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate: Date; + + @Column({ name: 'payment_date', type: 'date', nullable: true }) + paymentDate: Date; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'withholding_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingTax: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountPaid: number; + + @Column({ name: 'amount_due', type: 'decimal', precision: 15, scale: 2, insert: false, update: false }) + amountDue: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'cancelled' | 'voided'; + + @Index() + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string; + + @Column({ name: 'cfdi_xml', type: 'text', nullable: true }) + cfdiXml: string; + + @Column({ name: 'cfdi_pdf_url', type: 'varchar', length: 500, nullable: true }) + cfdiPdfUrl: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/invoices/entities/payment-allocation.entity.ts b/src/modules/invoices/entities/payment-allocation.entity.ts new file mode 100644 index 0000000..9917804 --- /dev/null +++ b/src/modules/invoices/entities/payment-allocation.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Payment } from './payment.entity'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'payment_allocations', schema: 'billing' }) +export class PaymentAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'payment_id', type: 'uuid' }) + paymentId: string; + + @ManyToOne(() => Payment, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'payment_id' }) + payment: Payment; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'allocation_date', type: 'date', default: () => 'CURRENT_DATE' }) + allocationDate: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/invoices/entities/payment.entity.ts b/src/modules/invoices/entities/payment.entity.ts new file mode 100644 index 0000000..83ca2fa --- /dev/null +++ b/src/modules/invoices/entities/payment.entity.ts @@ -0,0 +1,73 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'payments', schema: 'billing' }) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'payment_number', type: 'varchar', length: 30 }) + paymentNumber: string; + + @Index() + @Column({ name: 'payment_type', type: 'varchar', length: 20, default: 'received' }) + paymentType: 'received' | 'made'; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ name: 'payment_date', type: 'date', default: () => 'CURRENT_DATE' }) + paymentDate: Date; + + @Index() + @Column({ name: 'payment_method', type: 'varchar', length: 50 }) + paymentMethod: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + reference: string; + + @Column({ name: 'bank_account_id', type: 'uuid', nullable: true }) + bankAccountId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'reconciled' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/invoices/index.ts b/src/modules/invoices/index.ts new file mode 100644 index 0000000..48f9e2f --- /dev/null +++ b/src/modules/invoices/index.ts @@ -0,0 +1,5 @@ +export { InvoicesModule, InvoicesModuleOptions } from './invoices.module'; +export * from './entities'; +export { InvoicesService } from './services'; +export { InvoicesController, PaymentsController } from './controllers'; +export * from './dto'; diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts new file mode 100644 index 0000000..08409ab --- /dev/null +++ b/src/modules/invoices/invoices.module.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { InvoicesService } from './services'; +import { InvoicesController, PaymentsController } from './controllers'; +import { Invoice, Payment } from './entities'; + +export interface InvoicesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class InvoicesModule { + public router: Router; + public invoicesService: InvoicesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: InvoicesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const invoiceRepository = this.dataSource.getRepository(Invoice); + const paymentRepository = this.dataSource.getRepository(Payment); + this.invoicesService = new InvoicesService(invoiceRepository, paymentRepository); + } + + private initializeRoutes(): void { + const invoicesController = new InvoicesController(this.invoicesService); + const paymentsController = new PaymentsController(this.invoicesService); + this.router.use(`${this.basePath}/invoices`, invoicesController.router); + this.router.use(`${this.basePath}/payments`, paymentsController.router); + } + + static getEntities(): Function[] { + return [Invoice, Payment]; + } +} diff --git a/src/modules/invoices/services/index.ts b/src/modules/invoices/services/index.ts new file mode 100644 index 0000000..f41b370 --- /dev/null +++ b/src/modules/invoices/services/index.ts @@ -0,0 +1,86 @@ +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Invoice, Payment } from '../entities'; +import { CreateInvoiceDto, UpdateInvoiceDto, CreatePaymentDto } from '../dto'; + +export interface InvoiceSearchParams { + tenantId: string; + invoiceType?: string; + partnerId?: string; + status?: string; + limit?: number; + offset?: number; +} + +export class InvoicesService { + constructor( + private readonly invoiceRepository: Repository, + private readonly paymentRepository: Repository + ) {} + + async findAllInvoices(params: InvoiceSearchParams): Promise<{ data: Invoice[]; total: number }> { + const { tenantId, invoiceType, partnerId, status, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (invoiceType) where.invoiceType = invoiceType as any; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + const [data, total] = await this.invoiceRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findInvoice(id: string, tenantId: string): Promise { + return this.invoiceRepository.findOne({ where: { id, tenantId } }); + } + + async createInvoice(tenantId: string, dto: CreateInvoiceDto, createdBy?: string): Promise { + const count = await this.invoiceRepository.count({ where: { tenantId, invoiceType: dto.invoiceType } }); + const prefix = dto.invoiceType === 'sale' ? 'FAC' : dto.invoiceType === 'purchase' ? 'FP' : dto.invoiceType === 'credit_note' ? 'NC' : 'ND'; + const invoiceNumber = `${prefix}-${String(count + 1).padStart(6, '0')}`; + const invoice = this.invoiceRepository.create({ ...dto, tenantId, invoiceNumber, createdBy, invoiceDate: dto.invoiceDate ? new Date(dto.invoiceDate) : new Date(), dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined }); + return this.invoiceRepository.save(invoice); + } + + async updateInvoice(id: string, tenantId: string, dto: UpdateInvoiceDto, updatedBy?: string): Promise { + const invoice = await this.findInvoice(id, tenantId); + if (!invoice) return null; + Object.assign(invoice, { ...dto, updatedBy }); + return this.invoiceRepository.save(invoice); + } + + async deleteInvoice(id: string, tenantId: string): Promise { + const result = await this.invoiceRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async validateInvoice(id: string, tenantId: string, userId?: string): Promise { + const invoice = await this.findInvoice(id, tenantId); + if (!invoice || invoice.status !== 'draft') return null; + invoice.status = 'validated'; + invoice.updatedBy = userId; + return this.invoiceRepository.save(invoice); + } + + async findAllPayments(tenantId: string, partnerId?: string, limit = 50, offset = 0): Promise<{ data: Payment[]; total: number }> { + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + const [data, total] = await this.paymentRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findPayment(id: string, tenantId: string): Promise { + return this.paymentRepository.findOne({ where: { id, tenantId } }); + } + + async createPayment(tenantId: string, dto: CreatePaymentDto, createdBy?: string): Promise { + const count = await this.paymentRepository.count({ where: { tenantId } }); + const paymentNumber = `PAG-${String(count + 1).padStart(6, '0')}`; + const payment = this.paymentRepository.create({ ...dto, tenantId, paymentNumber, createdBy, paymentDate: dto.paymentDate ? new Date(dto.paymentDate) : new Date() }); + return this.paymentRepository.save(payment); + } + + async confirmPayment(id: string, tenantId: string, userId?: string): Promise { + const payment = await this.findPayment(id, tenantId); + if (!payment || payment.status !== 'draft') return null; + payment.status = 'confirmed'; + return this.paymentRepository.save(payment); + } +} diff --git a/src/modules/mcp/controllers/index.ts b/src/modules/mcp/controllers/index.ts new file mode 100644 index 0000000..452c198 --- /dev/null +++ b/src/modules/mcp/controllers/index.ts @@ -0,0 +1 @@ +export { McpController } from './mcp.controller'; diff --git a/src/modules/mcp/controllers/mcp.controller.ts b/src/modules/mcp/controllers/mcp.controller.ts new file mode 100644 index 0000000..306ae92 --- /dev/null +++ b/src/modules/mcp/controllers/mcp.controller.ts @@ -0,0 +1,223 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { McpServerService } from '../services/mcp-server.service'; +import { McpContext, CallerType } from '../interfaces'; + +export class McpController { + public router: Router; + + constructor(private readonly mcpService: McpServerService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Tools + this.router.get('/tools', this.listTools.bind(this)); + this.router.get('/tools/:name', this.getTool.bind(this)); + this.router.post('/tools/call', this.callTool.bind(this)); + + // Resources + this.router.get('/resources', this.listResources.bind(this)); + this.router.get('/resources/*', this.getResource.bind(this)); + + // History / Audit + this.router.get('/tool-calls', this.getToolCallHistory.bind(this)); + this.router.get('/tool-calls/:id', this.getToolCallDetails.bind(this)); + this.router.get('/stats', this.getToolStats.bind(this)); + } + + // ============================================ + // TOOLS + // ============================================ + + private async listTools(req: Request, res: Response, next: NextFunction): Promise { + try { + const tools = this.mcpService.listTools(); + res.json({ data: tools, total: tools.length }); + } catch (error) { + next(error); + } + } + + private async getTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { name } = req.params; + const tool = this.mcpService.getTool(name); + + if (!tool) { + res.status(404).json({ error: 'Tool not found' }); + return; + } + + res.json({ data: tool }); + } catch (error) { + next(error); + } + } + + private async callTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tool, parameters } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const agentId = req.headers['x-agent-id'] as string; + const conversationId = req.headers['x-conversation-id'] as string; + + if (!tool) { + res.status(400).json({ error: 'tool name is required' }); + return; + } + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const context: McpContext = { + tenantId, + userId, + agentId, + conversationId, + callerType: (req.headers['x-caller-type'] as CallerType) || 'api', + permissions: this.extractPermissions(req), + }; + + const result = await this.mcpService.callTool(tool, parameters || {}, context); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + // ============================================ + // RESOURCES + // ============================================ + + private async listResources(req: Request, res: Response, next: NextFunction): Promise { + try { + const resources = this.mcpService.listResources(); + res.json({ data: resources, total: resources.length }); + } catch (error) { + next(error); + } + } + + private async getResource(req: Request, res: Response, next: NextFunction): Promise { + try { + const uri = 'erp://' + req.params[0]; + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const context: McpContext = { + tenantId, + userId, + callerType: 'api', + permissions: this.extractPermissions(req), + }; + + const content = await this.mcpService.getResource(uri, context); + res.json({ data: { uri, content } }); + } catch (error: any) { + if (error.message.includes('not found')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + // ============================================ + // HISTORY / AUDIT + // ============================================ + + private async getToolCallHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const filters = { + toolName: req.query.toolName as string, + status: req.query.status as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + const result = await this.mcpService.getCallHistory(tenantId, filters); + res.json(result); + } catch (error) { + next(error); + } + } + + private async getToolCallDetails(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const call = await this.mcpService.getCallDetails(id, tenantId); + + if (!call) { + res.status(404).json({ error: 'Tool call not found' }); + return; + } + + res.json({ data: call }); + } catch (error) { + next(error); + } + } + + private async getToolStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const startDate = req.query.startDate + ? new Date(req.query.startDate as string) + : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + const endDate = req.query.endDate + ? new Date(req.query.endDate as string) + : new Date(); + + const stats = await this.mcpService.getToolStats(tenantId, startDate, endDate); + res.json({ data: stats, total: stats.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // HELPERS + // ============================================ + + private extractPermissions(req: Request): string[] { + const permHeader = req.headers['x-permissions'] as string; + if (!permHeader) return []; + + try { + return JSON.parse(permHeader); + } catch { + return permHeader.split(',').map((p) => p.trim()); + } + } +} diff --git a/src/modules/mcp/dto/index.ts b/src/modules/mcp/dto/index.ts new file mode 100644 index 0000000..06ba2ec --- /dev/null +++ b/src/modules/mcp/dto/index.ts @@ -0,0 +1 @@ +export * from './mcp.dto'; diff --git a/src/modules/mcp/dto/mcp.dto.ts b/src/modules/mcp/dto/mcp.dto.ts new file mode 100644 index 0000000..b586736 --- /dev/null +++ b/src/modules/mcp/dto/mcp.dto.ts @@ -0,0 +1,66 @@ +// ===================================================== +// DTOs: MCP Server +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +import { ToolCallStatus } from '../entities'; +import { CallerType } from '../interfaces'; + +// ============================================ +// Tool Call DTOs +// ============================================ + +export interface CallToolDto { + tool: string; + parameters?: Record; +} + +export interface ToolCallResultDto { + success: boolean; + toolName: string; + result?: any; + error?: string; + callId: string; +} + +export interface StartCallData { + tenantId: string; + toolName: string; + parameters: Record; + agentId?: string; + conversationId?: string; + callerType: CallerType; + userId?: string; +} + +// ============================================ +// History & Filters DTOs +// ============================================ + +export interface CallHistoryFilters { + toolName?: string; + status?: ToolCallStatus; + startDate?: Date; + endDate?: Date; + page: number; + limit: number; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; +} + +// ============================================ +// Resource DTOs +// ============================================ + +export interface ResourceContentDto { + uri: string; + name: string; + mimeType: string; + content: any; +} diff --git a/src/modules/mcp/entities/index.ts b/src/modules/mcp/entities/index.ts new file mode 100644 index 0000000..f9c8658 --- /dev/null +++ b/src/modules/mcp/entities/index.ts @@ -0,0 +1,2 @@ +export { ToolCall, ToolCallStatus } from './tool-call.entity'; +export { ToolCallResult, ResultType } from './tool-call-result.entity'; diff --git a/src/modules/mcp/entities/tool-call-result.entity.ts b/src/modules/mcp/entities/tool-call-result.entity.ts new file mode 100644 index 0000000..b4ab2b2 --- /dev/null +++ b/src/modules/mcp/entities/tool-call-result.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ToolCall } from './tool-call.entity'; + +export type ResultType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' | 'error'; + +@Entity({ name: 'tool_call_results', schema: 'ai' }) +export class ToolCallResult { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tool_call_id', type: 'uuid' }) + toolCallId: string; + + @Column({ type: 'jsonb', nullable: true }) + result: any; + + @Column({ name: 'result_type', type: 'varchar', length: 20, default: 'object' }) + resultType: ResultType; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string; + + @Column({ name: 'tokens_used', type: 'int', nullable: true }) + tokensUsed: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @OneToOne(() => ToolCall, (call) => call.result, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tool_call_id' }) + toolCall: ToolCall; +} diff --git a/src/modules/mcp/entities/tool-call.entity.ts b/src/modules/mcp/entities/tool-call.entity.ts new file mode 100644 index 0000000..8aee11c --- /dev/null +++ b/src/modules/mcp/entities/tool-call.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + OneToOne, +} from 'typeorm'; +import { ToolCallResult } from './tool-call-result.entity'; +import { CallerType } from '../interfaces'; + +export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'timeout'; + +@Entity({ name: 'tool_calls', schema: 'ai' }) +export class ToolCall { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'agent_id', type: 'uuid', nullable: true }) + agentId: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Index() + @Column({ name: 'tool_name', type: 'varchar', length: 100 }) + toolName: string; + + @Column({ type: 'jsonb', default: {} }) + parameters: Record; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ToolCallStatus; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs: number; + + @Column({ name: 'started_at', type: 'timestamptz' }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'called_by_user_id', type: 'uuid', nullable: true }) + calledByUserId: string; + + @Column({ name: 'caller_type', type: 'varchar', length: 20, default: 'agent' }) + callerType: CallerType; + + @Column({ name: 'caller_context', type: 'varchar', length: 100, nullable: true }) + callerContext: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @OneToOne(() => ToolCallResult, (result) => result.toolCall) + result: ToolCallResult; +} diff --git a/src/modules/mcp/index.ts b/src/modules/mcp/index.ts new file mode 100644 index 0000000..f83290f --- /dev/null +++ b/src/modules/mcp/index.ts @@ -0,0 +1,7 @@ +export { McpModule, McpModuleOptions } from './mcp.module'; +export { McpServerService, ToolRegistryService, ToolLoggerService } from './services'; +export { McpController } from './controllers'; +export { ToolCall, ToolCallResult, ToolCallStatus, ResultType } from './entities'; +export * from './interfaces'; +export * from './dto'; +export * from './tools'; diff --git a/src/modules/mcp/interfaces/index.ts b/src/modules/mcp/interfaces/index.ts new file mode 100644 index 0000000..d612fa9 --- /dev/null +++ b/src/modules/mcp/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './mcp-tool.interface'; +export * from './mcp-context.interface'; +export * from './mcp-resource.interface'; diff --git a/src/modules/mcp/interfaces/mcp-context.interface.ts b/src/modules/mcp/interfaces/mcp-context.interface.ts new file mode 100644 index 0000000..69488c4 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-context.interface.ts @@ -0,0 +1,17 @@ +// ===================================================== +// Interfaces: MCP Context +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +export type CallerType = 'agent' | 'api' | 'webhook' | 'system' | 'test'; + +export interface McpContext { + tenantId: string; + userId?: string; + agentId?: string; + conversationId?: string; + callerType: CallerType; + permissions: string[]; + metadata?: Record; +} diff --git a/src/modules/mcp/interfaces/mcp-resource.interface.ts b/src/modules/mcp/interfaces/mcp-resource.interface.ts new file mode 100644 index 0000000..e678ab3 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-resource.interface.ts @@ -0,0 +1,18 @@ +// ===================================================== +// Interfaces: MCP Resource +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +import { McpContext } from './mcp-context.interface'; + +export interface McpResource { + uri: string; + name: string; + description: string; + mimeType: string; +} + +export interface McpResourceWithHandler extends McpResource { + handler: (context: McpContext) => Promise; +} diff --git a/src/modules/mcp/interfaces/mcp-tool.interface.ts b/src/modules/mcp/interfaces/mcp-tool.interface.ts new file mode 100644 index 0000000..155f8d7 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-tool.interface.ts @@ -0,0 +1,62 @@ +// ===================================================== +// Interfaces: MCP Tool +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +import { McpContext } from './mcp-context.interface'; + +export interface JSONSchema { + type: string; + properties?: Record; + required?: string[]; + items?: JSONSchemaProperty; + description?: string; +} + +export interface JSONSchemaProperty { + type: string; + description?: string; + format?: string; + enum?: string[]; + minimum?: number; + maximum?: number; + default?: any; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; +} + +export interface RateLimitConfig { + maxCalls: number; + windowMs: number; + perTenant?: boolean; +} + +export type ToolCategory = + | 'products' + | 'inventory' + | 'orders' + | 'customers' + | 'fiados' + | 'system'; + +export interface McpToolDefinition { + name: string; + description: string; + parameters: JSONSchema; + returns: JSONSchema; + category: ToolCategory; + permissions?: string[]; + rateLimit?: RateLimitConfig; +} + +export type McpToolHandler = ( + params: TParams, + context: McpContext +) => Promise; + +export interface McpToolProvider { + getTools(): McpToolDefinition[]; + getHandler(toolName: string): McpToolHandler | undefined; +} diff --git a/src/modules/mcp/mcp.module.ts b/src/modules/mcp/mcp.module.ts new file mode 100644 index 0000000..453911f --- /dev/null +++ b/src/modules/mcp/mcp.module.ts @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { McpServerService, ToolRegistryService, ToolLoggerService } from './services'; +import { McpController } from './controllers'; +import { ToolCall, ToolCallResult } from './entities'; +import { + ProductsToolsService, + InventoryToolsService, + OrdersToolsService, + CustomersToolsService, + FiadosToolsService, +} from './tools'; + +export interface McpModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class McpModule { + public router: Router; + public mcpService: McpServerService; + public toolRegistry: ToolRegistryService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: McpModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + // Repositories + const toolCallRepository = this.dataSource.getRepository(ToolCall); + const toolCallResultRepository = this.dataSource.getRepository(ToolCallResult); + + // Tool Logger + const toolLogger = new ToolLoggerService(toolCallRepository, toolCallResultRepository); + + // Tool Registry + this.toolRegistry = new ToolRegistryService(); + + // Register tool providers + this.toolRegistry.registerProvider(new ProductsToolsService()); + this.toolRegistry.registerProvider(new InventoryToolsService()); + this.toolRegistry.registerProvider(new OrdersToolsService()); + this.toolRegistry.registerProvider(new CustomersToolsService()); + this.toolRegistry.registerProvider(new FiadosToolsService()); + + // MCP Server Service + this.mcpService = new McpServerService(this.toolRegistry, toolLogger); + } + + private initializeRoutes(): void { + const mcpController = new McpController(this.mcpService); + this.router.use(`${this.basePath}/mcp`, mcpController.router); + } + + static getEntities(): Function[] { + return [ToolCall, ToolCallResult]; + } +} diff --git a/src/modules/mcp/services/index.ts b/src/modules/mcp/services/index.ts new file mode 100644 index 0000000..562464d --- /dev/null +++ b/src/modules/mcp/services/index.ts @@ -0,0 +1,3 @@ +export { McpServerService } from './mcp-server.service'; +export { ToolRegistryService } from './tool-registry.service'; +export { ToolLoggerService } from './tool-logger.service'; diff --git a/src/modules/mcp/services/mcp-server.service.ts b/src/modules/mcp/services/mcp-server.service.ts new file mode 100644 index 0000000..8aa66e9 --- /dev/null +++ b/src/modules/mcp/services/mcp-server.service.ts @@ -0,0 +1,197 @@ +import { ToolRegistryService } from './tool-registry.service'; +import { ToolLoggerService } from './tool-logger.service'; +import { + McpToolDefinition, + McpContext, + McpResource, + McpResourceWithHandler, +} from '../interfaces'; +import { ToolCallResultDto, CallHistoryFilters, PaginatedResult } from '../dto'; +import { ToolCall } from '../entities'; + +export class McpServerService { + private resources: Map = new Map(); + + constructor( + private readonly toolRegistry: ToolRegistryService, + private readonly toolLogger: ToolLoggerService + ) { + this.initializeResources(); + } + + // ============================================ + // TOOLS + // ============================================ + + listTools(): McpToolDefinition[] { + return this.toolRegistry.getAllTools(); + } + + getTool(name: string): McpToolDefinition | null { + return this.toolRegistry.getTool(name); + } + + async callTool( + toolName: string, + params: Record, + context: McpContext + ): Promise { + // 1. Get tool definition + const tool = this.toolRegistry.getTool(toolName); + if (!tool) { + return { + success: false, + toolName, + error: `Tool '${toolName}' not found`, + callId: '', + }; + } + + // 2. Check permissions + if (tool.permissions && tool.permissions.length > 0) { + const hasPermission = tool.permissions.some((p) => + context.permissions.includes(p) + ); + if (!hasPermission) { + return { + success: false, + toolName, + error: `Missing permissions for tool '${toolName}'`, + callId: '', + }; + } + } + + // 3. Start logging + const callId = await this.toolLogger.startCall({ + tenantId: context.tenantId, + toolName, + parameters: params, + agentId: context.agentId, + conversationId: context.conversationId, + callerType: context.callerType, + userId: context.userId, + }); + + try { + // 4. Get and execute handler + const handler = this.toolRegistry.getHandler(toolName); + if (!handler) { + await this.toolLogger.failCall(callId, 'Handler not found', 'HANDLER_NOT_FOUND'); + return { + success: false, + toolName, + error: `Handler for tool '${toolName}' not found`, + callId, + }; + } + + const result = await handler(params, context); + + // 5. Log success + await this.toolLogger.completeCall(callId, result); + + return { + success: true, + toolName, + result, + callId, + }; + } catch (error: any) { + // 6. Log error + await this.toolLogger.failCall( + callId, + error.message || 'Execution error', + error.code || 'EXECUTION_ERROR' + ); + + return { + success: false, + toolName, + error: error.message || 'Tool execution failed', + callId, + }; + } + } + + // ============================================ + // RESOURCES + // ============================================ + + listResources(): McpResource[] { + return Array.from(this.resources.values()).map(({ handler, ...resource }) => resource); + } + + async getResource(uri: string, context: McpContext): Promise { + const resource = this.resources.get(uri); + if (!resource) { + throw new Error(`Resource '${uri}' not found`); + } + + return resource.handler(context); + } + + private initializeResources(): void { + // Business config resource + this.resources.set('erp://config/business', { + uri: 'erp://config/business', + name: 'Business Configuration', + description: 'Basic business information and settings', + mimeType: 'application/json', + handler: async (context) => ({ + tenantId: context.tenantId, + message: 'Business configuration - connect to tenant config service', + // TODO: Connect to actual tenant config service + }), + }); + + // Categories catalog resource + this.resources.set('erp://catalog/categories', { + uri: 'erp://catalog/categories', + name: 'Product Categories', + description: 'List of product categories', + mimeType: 'application/json', + handler: async (context) => ({ + tenantId: context.tenantId, + categories: [], + message: 'Categories catalog - connect to products service', + // TODO: Connect to actual products service + }), + }); + + // Inventory summary resource + this.resources.set('erp://inventory/summary', { + uri: 'erp://inventory/summary', + name: 'Inventory Summary', + description: 'Summary of current inventory status', + mimeType: 'application/json', + handler: async (context) => ({ + tenantId: context.tenantId, + totalProducts: 0, + totalValue: 0, + lowStockCount: 0, + message: 'Inventory summary - connect to inventory service', + // TODO: Connect to actual inventory service + }), + }); + } + + // ============================================ + // HISTORY / AUDIT + // ============================================ + + async getCallHistory( + tenantId: string, + filters: CallHistoryFilters + ): Promise> { + return this.toolLogger.getCallHistory(tenantId, filters); + } + + async getCallDetails(id: string, tenantId: string): Promise { + return this.toolLogger.getCallById(id, tenantId); + } + + async getToolStats(tenantId: string, startDate: Date, endDate: Date) { + return this.toolLogger.getToolStats(tenantId, startDate, endDate); + } +} diff --git a/src/modules/mcp/services/tool-logger.service.ts b/src/modules/mcp/services/tool-logger.service.ts new file mode 100644 index 0000000..797ba79 --- /dev/null +++ b/src/modules/mcp/services/tool-logger.service.ts @@ -0,0 +1,171 @@ +import { Repository } from 'typeorm'; +import { ToolCall, ToolCallResult, ResultType } from '../entities'; +import { StartCallData, CallHistoryFilters, PaginatedResult } from '../dto'; + +export class ToolLoggerService { + constructor( + private readonly toolCallRepo: Repository, + private readonly resultRepo: Repository + ) {} + + async startCall(data: StartCallData): Promise { + const call = this.toolCallRepo.create({ + tenantId: data.tenantId, + toolName: data.toolName, + parameters: data.parameters, + agentId: data.agentId, + conversationId: data.conversationId, + callerType: data.callerType, + calledByUserId: data.userId, + status: 'running', + startedAt: new Date(), + }); + + const saved = await this.toolCallRepo.save(call); + return saved.id; + } + + async completeCall(callId: string, result: any): Promise { + const call = await this.toolCallRepo.findOne({ where: { id: callId } }); + if (!call) return; + + const duration = Date.now() - call.startedAt.getTime(); + + await this.toolCallRepo.update(callId, { + status: 'success', + completedAt: new Date(), + durationMs: duration, + }); + + await this.resultRepo.save({ + toolCallId: callId, + result, + resultType: this.getResultType(result), + }); + } + + async failCall(callId: string, errorMessage: string, errorCode: string): Promise { + const call = await this.toolCallRepo.findOne({ where: { id: callId } }); + if (!call) return; + + const duration = Date.now() - call.startedAt.getTime(); + + await this.toolCallRepo.update(callId, { + status: 'error', + completedAt: new Date(), + durationMs: duration, + }); + + await this.resultRepo.save({ + toolCallId: callId, + resultType: 'error', + errorMessage, + errorCode, + }); + } + + async timeoutCall(callId: string): Promise { + const call = await this.toolCallRepo.findOne({ where: { id: callId } }); + if (!call) return; + + const duration = Date.now() - call.startedAt.getTime(); + + await this.toolCallRepo.update(callId, { + status: 'timeout', + completedAt: new Date(), + durationMs: duration, + }); + + await this.resultRepo.save({ + toolCallId: callId, + resultType: 'error', + errorMessage: 'Tool execution timed out', + errorCode: 'TIMEOUT', + }); + } + + async getCallHistory( + tenantId: string, + filters: CallHistoryFilters + ): Promise> { + const qb = this.toolCallRepo + .createQueryBuilder('tc') + .leftJoinAndSelect('tc.result', 'result') + .where('tc.tenant_id = :tenantId', { tenantId }); + + if (filters.toolName) { + qb.andWhere('tc.tool_name = :toolName', { toolName: filters.toolName }); + } + + if (filters.status) { + qb.andWhere('tc.status = :status', { status: filters.status }); + } + + if (filters.startDate) { + qb.andWhere('tc.created_at >= :startDate', { startDate: filters.startDate }); + } + + if (filters.endDate) { + qb.andWhere('tc.created_at <= :endDate', { endDate: filters.endDate }); + } + + qb.orderBy('tc.created_at', 'DESC'); + qb.skip((filters.page - 1) * filters.limit); + qb.take(filters.limit); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total, page: filters.page, limit: filters.limit }; + } + + async getCallById(id: string, tenantId: string): Promise { + return this.toolCallRepo.findOne({ + where: { id, tenantId }, + relations: ['result'], + }); + } + + async getToolStats( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ + toolName: string; + totalCalls: number; + successfulCalls: number; + failedCalls: number; + avgDurationMs: number; + }[]> { + const result = await this.toolCallRepo + .createQueryBuilder('tc') + .select('tc.tool_name', 'toolName') + .addSelect('COUNT(*)', 'totalCalls') + .addSelect("COUNT(*) FILTER (WHERE tc.status = 'success')", 'successfulCalls') + .addSelect("COUNT(*) FILTER (WHERE tc.status = 'error')", 'failedCalls') + .addSelect('AVG(tc.duration_ms)', 'avgDurationMs') + .where('tc.tenant_id = :tenantId', { tenantId }) + .andWhere('tc.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('tc.tool_name') + .orderBy('totalCalls', 'DESC') + .getRawMany(); + + return result.map((r) => ({ + toolName: r.toolName, + totalCalls: parseInt(r.totalCalls) || 0, + successfulCalls: parseInt(r.successfulCalls) || 0, + failedCalls: parseInt(r.failedCalls) || 0, + avgDurationMs: parseFloat(r.avgDurationMs) || 0, + })); + } + + private getResultType(result: any): ResultType { + if (result === null) return 'null'; + if (Array.isArray(result)) return 'array'; + const type = typeof result; + if (type === 'object') return 'object'; + if (type === 'string') return 'string'; + if (type === 'number') return 'number'; + if (type === 'boolean') return 'boolean'; + return 'object'; + } +} diff --git a/src/modules/mcp/services/tool-registry.service.ts b/src/modules/mcp/services/tool-registry.service.ts new file mode 100644 index 0000000..8661f3b --- /dev/null +++ b/src/modules/mcp/services/tool-registry.service.ts @@ -0,0 +1,53 @@ +import { + McpToolDefinition, + McpToolHandler, + McpToolProvider, + ToolCategory, +} from '../interfaces'; + +export class ToolRegistryService { + private tools: Map = new Map(); + private handlers: Map = new Map(); + private providers: McpToolProvider[] = []; + + registerProvider(provider: McpToolProvider): void { + this.providers.push(provider); + const tools = provider.getTools(); + + for (const tool of tools) { + this.tools.set(tool.name, tool); + const handler = provider.getHandler(tool.name); + if (handler) { + this.handlers.set(tool.name, handler); + } + } + } + + getAllTools(): McpToolDefinition[] { + return Array.from(this.tools.values()); + } + + getTool(name: string): McpToolDefinition | null { + return this.tools.get(name) || null; + } + + getHandler(name: string): McpToolHandler | null { + return this.handlers.get(name) || null; + } + + getToolsByCategory(category: ToolCategory): McpToolDefinition[] { + return Array.from(this.tools.values()).filter((t) => t.category === category); + } + + hasTool(name: string): boolean { + return this.tools.has(name); + } + + getCategories(): ToolCategory[] { + const categories = new Set(); + for (const tool of this.tools.values()) { + categories.add(tool.category); + } + return Array.from(categories); + } +} diff --git a/src/modules/mcp/tools/customers-tools.service.ts b/src/modules/mcp/tools/customers-tools.service.ts new file mode 100644 index 0000000..daa5298 --- /dev/null +++ b/src/modules/mcp/tools/customers-tools.service.ts @@ -0,0 +1,94 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Customers Tools Service + * Provides MCP tools for customer management. + * + * TODO: Connect to actual CustomersService when available. + */ +export class CustomersToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'search_customers', + description: 'Busca clientes por nombre, telefono o email', + category: 'customers', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Texto de busqueda' }, + limit: { type: 'number', description: 'Limite de resultados', default: 10 }, + }, + required: ['query'], + }, + returns: { type: 'array' }, + }, + { + name: 'get_customer_balance', + description: 'Obtiene el saldo actual de un cliente', + category: 'customers', + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + }, + required: ['customer_id'], + }, + returns: { + type: 'object', + properties: { + balance: { type: 'number' }, + credit_limit: { type: 'number' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + search_customers: this.searchCustomers.bind(this), + get_customer_balance: this.getCustomerBalance.bind(this), + }; + return handlers[toolName]; + } + + private async searchCustomers( + params: { query: string; limit?: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual customers service + return [ + { + id: 'customer-1', + name: 'Juan Perez', + phone: '+52 55 1234 5678', + email: 'juan@example.com', + balance: 500.00, + credit_limit: 5000.00, + message: 'Conectar a CustomersService real', + }, + ]; + } + + private async getCustomerBalance( + params: { customer_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual customers service + return { + customer_id: params.customer_id, + customer_name: 'Cliente ejemplo', + balance: 500.00, + credit_limit: 5000.00, + available_credit: 4500.00, + last_purchase: new Date().toISOString(), + message: 'Conectar a CustomersService real', + }; + } +} diff --git a/src/modules/mcp/tools/fiados-tools.service.ts b/src/modules/mcp/tools/fiados-tools.service.ts new file mode 100644 index 0000000..6e34982 --- /dev/null +++ b/src/modules/mcp/tools/fiados-tools.service.ts @@ -0,0 +1,216 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Fiados (Credit) Tools Service + * Provides MCP tools for credit/fiado management. + * + * TODO: Connect to actual FiadosService when available. + */ +export class FiadosToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'get_fiado_balance', + description: 'Consulta el saldo de credito de un cliente', + category: 'fiados', + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + }, + required: ['customer_id'], + }, + returns: { type: 'object' }, + }, + { + name: 'create_fiado', + description: 'Registra una venta a credito (fiado)', + category: 'fiados', + permissions: ['fiados.create'], + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + amount: { type: 'number', minimum: 0.01 }, + order_id: { type: 'string', format: 'uuid' }, + description: { type: 'string' }, + }, + required: ['customer_id', 'amount'], + }, + returns: { type: 'object' }, + }, + { + name: 'register_fiado_payment', + description: 'Registra un abono a la cuenta de credito', + category: 'fiados', + permissions: ['fiados.payment'], + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + amount: { type: 'number', minimum: 0.01 }, + payment_method: { type: 'string', enum: ['cash', 'card', 'transfer'] }, + }, + required: ['customer_id', 'amount'], + }, + returns: { type: 'object' }, + }, + { + name: 'check_fiado_eligibility', + description: 'Verifica si un cliente puede comprar a credito', + category: 'fiados', + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + amount: { type: 'number', minimum: 0.01 }, + }, + required: ['customer_id', 'amount'], + }, + returns: { + type: 'object', + properties: { + eligible: { type: 'boolean' }, + reason: { type: 'string' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + get_fiado_balance: this.getFiadoBalance.bind(this), + create_fiado: this.createFiado.bind(this), + register_fiado_payment: this.registerFiadoPayment.bind(this), + check_fiado_eligibility: this.checkFiadoEligibility.bind(this), + }; + return handlers[toolName]; + } + + private async getFiadoBalance( + params: { customer_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + return { + customer_id: params.customer_id, + customer_name: 'Cliente ejemplo', + balance: 1500.00, + credit_limit: 5000.00, + available_credit: 3500.00, + pending_fiados: [ + { id: 'fiado-1', amount: 500.00, date: '2026-01-10', status: 'pending' }, + { id: 'fiado-2', amount: 1000.00, date: '2026-01-05', status: 'pending' }, + ], + recent_payments: [], + message: 'Conectar a FiadosService real', + }; + } + + private async createFiado( + params: { customer_id: string; amount: number; order_id?: string; description?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + // First check eligibility + const eligibility = await this.checkFiadoEligibility( + { customer_id: params.customer_id, amount: params.amount }, + context + ); + + if (!eligibility.eligible) { + throw new Error(eligibility.reason); + } + + return { + fiado_id: 'fiado-' + Date.now(), + customer_id: params.customer_id, + amount: params.amount, + order_id: params.order_id, + description: params.description, + due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days + new_balance: 1500.00 + params.amount, + remaining_credit: 3500.00 - params.amount, + created_by: context.userId, + created_at: new Date().toISOString(), + message: 'Conectar a FiadosService real', + }; + } + + private async registerFiadoPayment( + params: { customer_id: string; amount: number; payment_method?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + return { + payment_id: 'payment-' + Date.now(), + customer_id: params.customer_id, + amount: params.amount, + payment_method: params.payment_method || 'cash', + previous_balance: 1500.00, + new_balance: 1500.00 - params.amount, + fiados_paid: [], + created_by: context.userId, + created_at: new Date().toISOString(), + message: 'Conectar a FiadosService real', + }; + } + + private async checkFiadoEligibility( + params: { customer_id: string; amount: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + const mockBalance = 1500.00; + const mockCreditLimit = 5000.00; + const mockAvailableCredit = mockCreditLimit - mockBalance; + const hasOverdue = false; + + if (hasOverdue) { + return { + eligible: false, + reason: 'Cliente tiene saldo vencido', + current_balance: mockBalance, + credit_limit: mockCreditLimit, + available_credit: mockAvailableCredit, + requested_amount: params.amount, + has_overdue: true, + suggestions: ['Solicitar pago del saldo vencido antes de continuar'], + }; + } + + if (params.amount > mockAvailableCredit) { + return { + eligible: false, + reason: 'Monto excede credito disponible', + current_balance: mockBalance, + credit_limit: mockCreditLimit, + available_credit: mockAvailableCredit, + requested_amount: params.amount, + has_overdue: false, + suggestions: [ + `Reducir el monto a $${mockAvailableCredit.toFixed(2)}`, + 'Solicitar aumento de limite de credito', + ], + }; + } + + return { + eligible: true, + reason: 'Cliente con credito disponible', + current_balance: mockBalance, + credit_limit: mockCreditLimit, + available_credit: mockAvailableCredit, + requested_amount: params.amount, + has_overdue: false, + suggestions: [], + message: 'Conectar a FiadosService real', + }; + } +} diff --git a/src/modules/mcp/tools/index.ts b/src/modules/mcp/tools/index.ts new file mode 100644 index 0000000..795912b --- /dev/null +++ b/src/modules/mcp/tools/index.ts @@ -0,0 +1,5 @@ +export { ProductsToolsService } from './products-tools.service'; +export { InventoryToolsService } from './inventory-tools.service'; +export { OrdersToolsService } from './orders-tools.service'; +export { CustomersToolsService } from './customers-tools.service'; +export { FiadosToolsService } from './fiados-tools.service'; diff --git a/src/modules/mcp/tools/inventory-tools.service.ts b/src/modules/mcp/tools/inventory-tools.service.ts new file mode 100644 index 0000000..76a45ca --- /dev/null +++ b/src/modules/mcp/tools/inventory-tools.service.ts @@ -0,0 +1,154 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Inventory Tools Service + * Provides MCP tools for inventory management. + * + * TODO: Connect to actual InventoryService when available. + */ +export class InventoryToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'check_stock', + description: 'Consulta el stock actual de productos', + category: 'inventory', + parameters: { + type: 'object', + properties: { + product_ids: { type: 'array', description: 'IDs de productos a consultar' }, + warehouse_id: { type: 'string', description: 'ID del almacen' }, + }, + }, + returns: { type: 'array' }, + }, + { + name: 'get_low_stock_products', + description: 'Lista productos que estan por debajo del minimo de stock', + category: 'inventory', + parameters: { + type: 'object', + properties: { + threshold: { type: 'number', description: 'Umbral de stock bajo' }, + }, + }, + returns: { type: 'array' }, + }, + { + name: 'record_inventory_movement', + description: 'Registra un movimiento de inventario (entrada, salida, ajuste)', + category: 'inventory', + permissions: ['inventory.write'], + parameters: { + type: 'object', + properties: { + product_id: { type: 'string', format: 'uuid' }, + quantity: { type: 'number' }, + movement_type: { type: 'string', enum: ['in', 'out', 'adjustment'] }, + reason: { type: 'string' }, + }, + required: ['product_id', 'quantity', 'movement_type'], + }, + returns: { type: 'object' }, + }, + { + name: 'get_inventory_value', + description: 'Calcula el valor total del inventario', + category: 'inventory', + parameters: { + type: 'object', + properties: { + warehouse_id: { type: 'string', description: 'ID del almacen (opcional)' }, + }, + }, + returns: { + type: 'object', + properties: { + total_value: { type: 'number' }, + items_count: { type: 'number' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + check_stock: this.checkStock.bind(this), + get_low_stock_products: this.getLowStockProducts.bind(this), + record_inventory_movement: this.recordInventoryMovement.bind(this), + get_inventory_value: this.getInventoryValue.bind(this), + }; + return handlers[toolName]; + } + + private async checkStock( + params: { product_ids?: string[]; warehouse_id?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + return [ + { + product_id: 'sample-1', + product_name: 'Producto ejemplo', + stock: 100, + warehouse_id: params.warehouse_id || 'default', + message: 'Conectar a InventoryService real', + }, + ]; + } + + private async getLowStockProducts( + params: { threshold?: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + const threshold = params.threshold || 10; + return [ + { + product_id: 'low-stock-1', + product_name: 'Producto bajo stock', + current_stock: 5, + min_stock: threshold, + shortage: threshold - 5, + message: 'Conectar a InventoryService real', + }, + ]; + } + + private async recordInventoryMovement( + params: { product_id: string; quantity: number; movement_type: string; reason?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + return { + movement_id: 'mov-' + Date.now(), + product_id: params.product_id, + quantity: params.quantity, + movement_type: params.movement_type, + reason: params.reason, + recorded_by: context.userId, + recorded_at: new Date().toISOString(), + message: 'Conectar a InventoryService real', + }; + } + + private async getInventoryValue( + params: { warehouse_id?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + return { + total_value: 150000.00, + items_count: 500, + warehouse_id: params.warehouse_id || 'all', + currency: 'MXN', + message: 'Conectar a InventoryService real', + }; + } +} diff --git a/src/modules/mcp/tools/orders-tools.service.ts b/src/modules/mcp/tools/orders-tools.service.ts new file mode 100644 index 0000000..facc0b0 --- /dev/null +++ b/src/modules/mcp/tools/orders-tools.service.ts @@ -0,0 +1,139 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Orders Tools Service + * Provides MCP tools for order management. + * + * TODO: Connect to actual OrdersService when available. + */ +export class OrdersToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'create_order', + description: 'Crea un nuevo pedido', + category: 'orders', + permissions: ['orders.create'], + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid', description: 'ID del cliente' }, + items: { + type: 'array', + description: 'Items del pedido', + items: { + type: 'object', + properties: { + product_id: { type: 'string' }, + quantity: { type: 'number' }, + unit_price: { type: 'number' }, + }, + }, + }, + payment_method: { type: 'string', enum: ['cash', 'card', 'transfer', 'fiado'] }, + notes: { type: 'string' }, + }, + required: ['customer_id', 'items'], + }, + returns: { type: 'object' }, + }, + { + name: 'get_order_status', + description: 'Consulta el estado de un pedido', + category: 'orders', + parameters: { + type: 'object', + properties: { + order_id: { type: 'string', format: 'uuid' }, + }, + required: ['order_id'], + }, + returns: { type: 'object' }, + }, + { + name: 'update_order_status', + description: 'Actualiza el estado de un pedido', + category: 'orders', + permissions: ['orders.update'], + parameters: { + type: 'object', + properties: { + order_id: { type: 'string', format: 'uuid' }, + status: { + type: 'string', + enum: ['pending', 'confirmed', 'preparing', 'ready', 'delivered', 'cancelled'], + }, + }, + required: ['order_id', 'status'], + }, + returns: { type: 'object' }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + create_order: this.createOrder.bind(this), + get_order_status: this.getOrderStatus.bind(this), + update_order_status: this.updateOrderStatus.bind(this), + }; + return handlers[toolName]; + } + + private async createOrder( + params: { customer_id: string; items: any[]; payment_method?: string; notes?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual orders service + const subtotal = params.items.reduce((sum, item) => sum + (item.quantity * (item.unit_price || 0)), 0); + return { + order_id: 'order-' + Date.now(), + customer_id: params.customer_id, + items: params.items, + subtotal, + tax: subtotal * 0.16, + total: subtotal * 1.16, + payment_method: params.payment_method || 'cash', + status: 'pending', + created_by: context.userId, + created_at: new Date().toISOString(), + message: 'Conectar a OrdersService real', + }; + } + + private async getOrderStatus( + params: { order_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual orders service + return { + order_id: params.order_id, + status: 'pending', + customer_name: 'Cliente ejemplo', + total: 1160.00, + items_count: 3, + created_at: new Date().toISOString(), + message: 'Conectar a OrdersService real', + }; + } + + private async updateOrderStatus( + params: { order_id: string; status: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual orders service + return { + order_id: params.order_id, + previous_status: 'pending', + new_status: params.status, + updated_by: context.userId, + updated_at: new Date().toISOString(), + message: 'Conectar a OrdersService real', + }; + } +} diff --git a/src/modules/mcp/tools/products-tools.service.ts b/src/modules/mcp/tools/products-tools.service.ts new file mode 100644 index 0000000..92c3e44 --- /dev/null +++ b/src/modules/mcp/tools/products-tools.service.ts @@ -0,0 +1,128 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Products Tools Service + * Provides MCP tools for product management. + * + * TODO: Connect to actual ProductsService when available. + */ +export class ProductsToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'list_products', + description: 'Lista productos filtrados por categoria, nombre o precio', + category: 'products', + parameters: { + type: 'object', + properties: { + category: { type: 'string', description: 'Filtrar por categoria' }, + search: { type: 'string', description: 'Buscar por nombre' }, + min_price: { type: 'number', description: 'Precio minimo' }, + max_price: { type: 'number', description: 'Precio maximo' }, + limit: { type: 'number', description: 'Limite de resultados', default: 20 }, + }, + }, + returns: { + type: 'array', + items: { type: 'object' }, + }, + }, + { + name: 'get_product_details', + description: 'Obtiene detalles completos de un producto', + category: 'products', + parameters: { + type: 'object', + properties: { + product_id: { type: 'string', format: 'uuid', description: 'ID del producto' }, + }, + required: ['product_id'], + }, + returns: { type: 'object' }, + }, + { + name: 'check_product_availability', + description: 'Verifica si hay stock suficiente de un producto', + category: 'products', + parameters: { + type: 'object', + properties: { + product_id: { type: 'string', format: 'uuid', description: 'ID del producto' }, + quantity: { type: 'number', minimum: 1, description: 'Cantidad requerida' }, + }, + required: ['product_id', 'quantity'], + }, + returns: { + type: 'object', + properties: { + available: { type: 'boolean' }, + current_stock: { type: 'number' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + list_products: this.listProducts.bind(this), + get_product_details: this.getProductDetails.bind(this), + check_product_availability: this.checkProductAvailability.bind(this), + }; + return handlers[toolName]; + } + + private async listProducts( + params: { category?: string; search?: string; min_price?: number; max_price?: number; limit?: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual products service + return [ + { + id: 'sample-product-1', + name: 'Producto de ejemplo 1', + price: 99.99, + stock: 50, + category: params.category || 'general', + message: 'Conectar a ProductsService real', + }, + ]; + } + + private async getProductDetails( + params: { product_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual products service + return { + id: params.product_id, + name: 'Producto de ejemplo', + description: 'Descripcion del producto', + sku: 'SKU-001', + price: 99.99, + stock: 50, + message: 'Conectar a ProductsService real', + }; + } + + private async checkProductAvailability( + params: { product_id: string; quantity: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + const mockStock = 50; + return { + available: mockStock >= params.quantity, + current_stock: mockStock, + requested_quantity: params.quantity, + shortage: Math.max(0, params.quantity - mockStock), + message: 'Conectar a InventoryService real', + }; + } +} diff --git a/src/modules/mobile/entities/index.ts b/src/modules/mobile/entities/index.ts new file mode 100644 index 0000000..a109c57 --- /dev/null +++ b/src/modules/mobile/entities/index.ts @@ -0,0 +1,6 @@ +export { MobileSession, MobileSessionStatus } from './mobile-session.entity'; +export { OfflineSyncQueue, SyncOperation, SyncStatus, ConflictResolution } from './offline-sync-queue.entity'; +export { SyncConflict, ConflictType, ConflictResolutionType } from './sync-conflict.entity'; +export { PushToken, PushProvider } from './push-token.entity'; +export { PushNotificationLog, NotificationStatus, NotificationCategory } from './push-notification-log.entity'; +export { PaymentTransaction, PaymentSourceType, PaymentMethod, PaymentStatus, CardType } from './payment-transaction.entity'; diff --git a/src/modules/mobile/entities/mobile-session.entity.ts b/src/modules/mobile/entities/mobile-session.entity.ts new file mode 100644 index 0000000..1fa12be --- /dev/null +++ b/src/modules/mobile/entities/mobile-session.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type MobileSessionStatus = 'active' | 'paused' | 'expired' | 'terminated'; + +@Entity({ name: 'mobile_sessions', schema: 'mobile' }) +export class MobileSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Estado de la sesion + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: MobileSessionStatus; + + // Perfil activo + @Column({ name: 'active_profile_id', type: 'uuid', nullable: true }) + activeProfileId: string; + + @Column({ name: 'active_profile_code', type: 'varchar', length: 10, nullable: true }) + activeProfileCode: string; + + // Modo de operacion + @Column({ name: 'is_offline_mode', type: 'boolean', default: false }) + isOfflineMode: boolean; + + @Column({ name: 'offline_since', type: 'timestamptz', nullable: true }) + offlineSince: Date; + + // Sincronizacion + @Column({ name: 'last_sync_at', type: 'timestamptz', nullable: true }) + lastSyncAt: Date; + + @Column({ name: 'pending_sync_count', type: 'integer', default: 0 }) + pendingSyncCount: number; + + // Ubicacion + @Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + lastLatitude: number; + + @Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + lastLongitude: number; + + @Column({ name: 'last_location_at', type: 'timestamptz', nullable: true }) + lastLocationAt: Date; + + // Metadata + @Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true }) + appVersion: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; // ios, android + + @Column({ name: 'os_version', type: 'varchar', length: 20, nullable: true }) + osVersion: string; + + // Tiempos + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_activity_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastActivityAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'ended_at', type: 'timestamptz', nullable: true }) + endedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/offline-sync-queue.entity.ts b/src/modules/mobile/entities/offline-sync-queue.entity.ts new file mode 100644 index 0000000..90bdf13 --- /dev/null +++ b/src/modules/mobile/entities/offline-sync-queue.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type SyncOperation = 'create' | 'update' | 'delete'; +export type SyncStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'conflict'; +export type ConflictResolution = 'local_wins' | 'server_wins' | 'merged' | 'manual'; + +@Entity({ name: 'offline_sync_queue', schema: 'mobile' }) +export class OfflineSyncQueue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + // Operacion + @Column({ name: 'entity_type', type: 'varchar', length: 50 }) + entityType: string; // sale, attendance, inventory_count, etc. + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ type: 'varchar', length: 20 }) + operation: SyncOperation; + + // Datos + @Column({ type: 'jsonb' }) + payload: Record; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Orden y dependencias + @Index() + @Column({ name: 'sequence_number', type: 'bigint' }) + sequenceNumber: number; + + @Column({ name: 'depends_on', type: 'uuid', nullable: true }) + dependsOn: string; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: SyncStatus; + + // Procesamiento + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount: number; + + @Column({ name: 'max_retries', type: 'integer', default: 3 }) + maxRetries: number; + + @Column({ name: 'last_error', type: 'text', nullable: true }) + lastError: string; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + // Conflicto + @Column({ name: 'conflict_data', type: 'jsonb', nullable: true }) + conflictData: Record; + + @Column({ name: 'conflict_resolved_at', type: 'timestamptz', nullable: true }) + conflictResolvedAt: Date; + + @Column({ name: 'conflict_resolution', type: 'varchar', length: 20, nullable: true }) + conflictResolution: ConflictResolution; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/payment-transaction.entity.ts b/src/modules/mobile/entities/payment-transaction.entity.ts new file mode 100644 index 0000000..b9da1a4 --- /dev/null +++ b/src/modules/mobile/entities/payment-transaction.entity.ts @@ -0,0 +1,115 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type PaymentSourceType = 'sale' | 'invoice' | 'subscription'; +export type PaymentMethod = 'card' | 'contactless' | 'qr' | 'link'; +export type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' | 'cancelled'; +export type CardType = 'credit' | 'debit'; + +@Entity({ name: 'payment_transactions', schema: 'mobile' }) +export class PaymentTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + // Referencia al documento origen + @Index() + @Column({ name: 'source_type', type: 'varchar', length: 30 }) + sourceType: PaymentSourceType; + + @Column({ name: 'source_id', type: 'uuid' }) + sourceId: string; + + // Terminal de pago + @Column({ name: 'terminal_provider', type: 'varchar', length: 30 }) + terminalProvider: string; // clip, mercadopago, stripe + + @Column({ name: 'terminal_id', type: 'varchar', length: 100, nullable: true }) + terminalId: string; + + // Transaccion + @Index() + @Column({ name: 'external_transaction_id', type: 'varchar', length: 255, nullable: true }) + externalTransactionId: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'tip_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + tipAmount: number; + + @Column({ name: 'total_amount', type: 'decimal', precision: 12, scale: 2 }) + totalAmount: number; + + // Metodo de pago + @Column({ name: 'payment_method', type: 'varchar', length: 30 }) + paymentMethod: PaymentMethod; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string; // visa, mastercard, amex + + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_type', type: 'varchar', length: 20, nullable: true }) + cardType: CardType; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: PaymentStatus; + + @Column({ name: 'failure_reason', type: 'text', nullable: true }) + failureReason: string; + + // Tiempos + @Column({ name: 'initiated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + initiatedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + // Metadata del proveedor + @Column({ name: 'provider_response', type: 'jsonb', default: {} }) + providerResponse: Record; + + // Recibo + @Column({ name: 'receipt_url', type: 'text', nullable: true }) + receiptUrl: string; + + @Column({ name: 'receipt_sent', type: 'boolean', default: false }) + receiptSent: boolean; + + @Column({ name: 'receipt_sent_to', type: 'varchar', length: 255, nullable: true }) + receiptSentTo: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/push-notification-log.entity.ts b/src/modules/mobile/entities/push-notification-log.entity.ts new file mode 100644 index 0000000..195bf7c --- /dev/null +++ b/src/modules/mobile/entities/push-notification-log.entity.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PushToken } from './push-token.entity'; + +export type NotificationStatus = 'sent' | 'delivered' | 'failed' | 'read'; +export type NotificationCategory = 'attendance' | 'sale' | 'inventory' | 'alert' | 'system'; + +/** + * Log de notificaciones push enviadas. + * Mapea a mobile.push_notifications_log (DDL: 04-mobile.sql) + */ +@Entity({ name: 'push_notifications_log', schema: 'mobile' }) +export class PushNotificationLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Destino + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'push_token_id', type: 'uuid', nullable: true }) + pushTokenId: string; + + @ManyToOne(() => PushToken) + @JoinColumn({ name: 'push_token_id' }) + pushToken: PushToken; + + // Notificación + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + body: string; + + @Column({ type: 'jsonb', default: {} }) + data: Record; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + category: NotificationCategory; + + // Envío + @Column({ name: 'sent_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + sentAt: Date; + + @Column({ name: 'provider_message_id', type: 'varchar', length: 255, nullable: true }) + providerMessageId: string; + + // Estado + @Column({ type: 'varchar', length: 20, default: 'sent' }) + status: NotificationStatus; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/mobile/entities/push-token.entity.ts b/src/modules/mobile/entities/push-token.entity.ts new file mode 100644 index 0000000..d6e04a3 --- /dev/null +++ b/src/modules/mobile/entities/push-token.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type PushProvider = 'firebase' | 'apns' | 'fcm'; + +@Entity({ name: 'push_tokens', schema: 'mobile' }) +@Unique(['deviceId', 'platform']) +export class PushToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Token + @Column({ type: 'text' }) + token: string; + + @Column({ type: 'varchar', length: 20 }) + platform: string; // ios, android + + @Column({ type: 'varchar', length: 30, default: 'firebase' }) + provider: PushProvider; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid: boolean; + + @Column({ name: 'invalid_reason', type: 'text', nullable: true }) + invalidReason: string; + + // Topics suscritos + @Column({ name: 'subscribed_topics', type: 'text', array: true, default: [] }) + subscribedTopics: string[]; + + // Ultima actividad + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/sync-conflict.entity.ts b/src/modules/mobile/entities/sync-conflict.entity.ts new file mode 100644 index 0000000..a2fe214 --- /dev/null +++ b/src/modules/mobile/entities/sync-conflict.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { OfflineSyncQueue } from './offline-sync-queue.entity'; + +export type ConflictType = 'data_conflict' | 'version_conflict' | 'delete_conflict' | 'constraint_conflict'; +export type ConflictResolutionType = 'local_wins' | 'server_wins' | 'merged' | 'manual'; + +/** + * Entidad para registrar conflictos de sincronizacion offline. + * Mapea a mobile.sync_conflicts (DDL: 04-mobile.sql) + */ +@Entity({ name: 'sync_conflicts', schema: 'mobile' }) +export class SyncConflict { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'sync_queue_id', type: 'uuid' }) + syncQueueId: string; + + @ManyToOne(() => OfflineSyncQueue, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sync_queue_id' }) + syncQueue: OfflineSyncQueue; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'conflict_type', type: 'varchar', length: 30 }) + conflictType: ConflictType; + + @Column({ name: 'local_data', type: 'jsonb' }) + localData: Record; + + @Column({ name: 'server_data', type: 'jsonb' }) + serverData: Record; + + @Column({ type: 'varchar', length: 20, nullable: true }) + resolution: ConflictResolutionType; + + @Column({ name: 'merged_data', type: 'jsonb', nullable: true }) + mergedData: Record; + + @Column({ name: 'resolved_by', type: 'uuid', nullable: true }) + resolvedBy: string; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/notifications/controllers/index.ts b/src/modules/notifications/controllers/index.ts new file mode 100644 index 0000000..ecc26de --- /dev/null +++ b/src/modules/notifications/controllers/index.ts @@ -0,0 +1 @@ +export { NotificationsController } from './notifications.controller'; diff --git a/src/modules/notifications/controllers/notifications.controller.ts b/src/modules/notifications/controllers/notifications.controller.ts new file mode 100644 index 0000000..e7db98d --- /dev/null +++ b/src/modules/notifications/controllers/notifications.controller.ts @@ -0,0 +1,257 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { NotificationsService } from '../services/notifications.service'; + +export class NotificationsController { + public router: Router; + + constructor(private readonly notificationsService: NotificationsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Channels + this.router.get('/channels', this.findAllChannels.bind(this)); + this.router.get('/channels/:code', this.findChannelByCode.bind(this)); + + // Templates + this.router.get('/templates', this.findAllTemplates.bind(this)); + this.router.get('/templates/:code', this.findTemplateByCode.bind(this)); + this.router.post('/templates', this.createTemplate.bind(this)); + + // Preferences + this.router.get('/preferences', this.getPreferences.bind(this)); + this.router.patch('/preferences', this.updatePreferences.bind(this)); + + // Notifications + this.router.post('/', this.createNotification.bind(this)); + this.router.get('/pending', this.findPendingNotifications.bind(this)); + this.router.patch('/:id/status', this.updateNotificationStatus.bind(this)); + + // In-App Notifications + this.router.get('/in-app', this.findInAppNotifications.bind(this)); + this.router.get('/in-app/unread-count', this.getUnreadCount.bind(this)); + this.router.post('/in-app/:id/read', this.markAsRead.bind(this)); + this.router.post('/in-app/read-all', this.markAllAsRead.bind(this)); + this.router.post('/in-app', this.createInAppNotification.bind(this)); + } + + // ============================================ + // CHANNELS + // ============================================ + + private async findAllChannels(req: Request, res: Response, next: NextFunction): Promise { + try { + const channels = await this.notificationsService.findAllChannels(); + res.json({ data: channels }); + } catch (error) { + next(error); + } + } + + private async findChannelByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const channel = await this.notificationsService.findChannelByCode(code); + + if (!channel) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + + res.json({ data: channel }); + } catch (error) { + next(error); + } + } + + // ============================================ + // TEMPLATES + // ============================================ + + private async findAllTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const templates = await this.notificationsService.findAllTemplates(tenantId); + res.json({ data: templates, total: templates.length }); + } catch (error) { + next(error); + } + } + + private async findTemplateByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const channelType = req.query.channelType as string; + + if (!channelType) { + res.status(400).json({ error: 'channelType query parameter is required' }); + return; + } + + const template = await this.notificationsService.findTemplateByCode(code, channelType, tenantId); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async createTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const template = await this.notificationsService.createTemplate(tenantId, req.body, userId); + res.status(201).json({ data: template }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PREFERENCES + // ============================================ + + private async getPreferences(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const preferences = await this.notificationsService.getPreferences(userId, tenantId); + res.json({ data: preferences }); + } catch (error) { + next(error); + } + } + + private async updatePreferences(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const preferences = await this.notificationsService.updatePreferences(userId, tenantId, req.body); + res.json({ data: preferences }); + } catch (error) { + next(error); + } + } + + // ============================================ + // NOTIFICATIONS + // ============================================ + + private async createNotification(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + const notification = await this.notificationsService.createNotification(tenantId, req.body); + res.status(201).json({ data: notification }); + } catch (error) { + next(error); + } + } + + private async findPendingNotifications(req: Request, res: Response, next: NextFunction): Promise { + try { + const limit = parseInt(req.query.limit as string) || 100; + const notifications = await this.notificationsService.findPendingNotifications(limit); + res.json({ data: notifications, total: notifications.length }); + } catch (error) { + next(error); + } + } + + private async updateNotificationStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, errorMessage } = req.body; + + const notification = await this.notificationsService.updateNotificationStatus(id, status, errorMessage); + + if (!notification) { + res.status(404).json({ error: 'Notification not found' }); + return; + } + + res.json({ data: notification }); + } catch (error) { + next(error); + } + } + + // ============================================ + // IN-APP NOTIFICATIONS + // ============================================ + + private async findInAppNotifications(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const includeRead = req.query.includeRead === 'true'; + + const notifications = await this.notificationsService.findInAppNotifications(userId, tenantId, includeRead); + res.json({ data: notifications, total: notifications.length }); + } catch (error) { + next(error); + } + } + + private async getUnreadCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const count = await this.notificationsService.getUnreadCount(userId, tenantId); + res.json({ data: { unreadCount: count } }); + } catch (error) { + next(error); + } + } + + private async markAsRead(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const marked = await this.notificationsService.markAsRead(id); + + if (!marked) { + res.status(404).json({ error: 'Notification not found or already read' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async markAllAsRead(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const count = await this.notificationsService.markAllAsRead(userId, tenantId); + res.json({ data: { markedCount: count } }); + } catch (error) { + next(error); + } + } + + private async createInAppNotification(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId, ...data } = req.body; + + const notification = await this.notificationsService.createInAppNotification(tenantId, userId, data); + res.status(201).json({ data: notification }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/notifications/dto/index.ts b/src/modules/notifications/dto/index.ts new file mode 100644 index 0000000..eef54b6 --- /dev/null +++ b/src/modules/notifications/dto/index.ts @@ -0,0 +1,8 @@ +export { + CreateNotificationTemplateDto, + UpdateNotificationTemplateDto, + UpdateNotificationPreferenceDto, + CreateNotificationDto, + UpdateNotificationStatusDto, + CreateInAppNotificationDto, +} from './notification.dto'; diff --git a/src/modules/notifications/dto/notification.dto.ts b/src/modules/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..bd80a9d --- /dev/null +++ b/src/modules/notifications/dto/notification.dto.ts @@ -0,0 +1,256 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsEnum, + IsEmail, + MaxLength, + MinLength, +} from 'class-validator'; + +// ============================================ +// TEMPLATE DTOs +// ============================================ + +export class CreateNotificationTemplateDto { + @IsString() + @MinLength(2) + @MaxLength(50) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsString() + @MaxLength(20) + channelType: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + subject: string; + + @IsString() + bodyTemplate: string; + + @IsOptional() + @IsString() + htmlTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateNotificationTemplateDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + subject?: string; + + @IsOptional() + @IsString() + bodyTemplate?: string; + + @IsOptional() + @IsString() + htmlTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// PREFERENCE DTOs +// ============================================ + +export class UpdateNotificationPreferenceDto { + @IsOptional() + @IsBoolean() + emailEnabled?: boolean; + + @IsOptional() + @IsBoolean() + smsEnabled?: boolean; + + @IsOptional() + @IsBoolean() + pushEnabled?: boolean; + + @IsOptional() + @IsBoolean() + inAppEnabled?: boolean; + + @IsOptional() + @IsBoolean() + whatsappEnabled?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + mutedCategories?: string[]; + + @IsOptional() + @IsObject() + quietHours?: { + enabled: boolean; + startTime?: string; + endTime?: string; + timezone?: string; + }; + + @IsOptional() + @IsString() + @MaxLength(10) + language?: string; + + @IsOptional() + @IsString() + digestFrequency?: string; +} + +// ============================================ +// NOTIFICATION DTOs +// ============================================ + +export class CreateNotificationDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(50) + channelType: string; + + @IsOptional() + @IsUUID() + templateId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + subject?: string; + + @IsString() + content: string; + + @IsOptional() + @IsString() + htmlContent?: string; + + @IsOptional() + @IsObject() + templateData?: Record; + + @IsOptional() + @IsString() + @MaxLength(10) + priority?: string; + + @IsOptional() + @IsString() + scheduledFor?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateNotificationStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +// ============================================ +// IN-APP NOTIFICATION DTOs +// ============================================ + +export class CreateInAppNotificationDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(200) + title: string; + + @IsString() + message: string; + + @IsOptional() + @IsString() + @MaxLength(30) + type?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + priority?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + actionUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + actionLabel?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsObject() + data?: Record; + + @IsOptional() + @IsString() + expiresAt?: string; +} diff --git a/src/modules/notifications/entities/channel.entity.ts b/src/modules/notifications/entities/channel.entity.ts new file mode 100644 index 0000000..8b00b62 --- /dev/null +++ b/src/modules/notifications/entities/channel.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type ChannelType = 'email' | 'sms' | 'push' | 'whatsapp' | 'in_app' | 'webhook'; + +@Entity({ name: 'channels', schema: 'notifications' }) +export class Channel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 30 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'provider', type: 'varchar', length: 50, nullable: true }) + provider: string; + + @Column({ name: 'provider_config', type: 'jsonb', default: {} }) + providerConfig: Record; + + @Column({ name: 'rate_limit_per_minute', type: 'int', default: 60 }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', default: 1000 }) + rateLimitPerHour: number; + + @Column({ name: 'rate_limit_per_day', type: 'int', default: 10000 }) + rateLimitPerDay: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/in-app-notification.entity.ts b/src/modules/notifications/entities/in-app-notification.entity.ts new file mode 100644 index 0000000..44b26d9 --- /dev/null +++ b/src/modules/notifications/entities/in-app-notification.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type InAppCategory = 'info' | 'success' | 'warning' | 'error' | 'task'; +export type InAppPriority = 'low' | 'normal' | 'high' | 'urgent'; +export type InAppActionType = 'link' | 'modal' | 'function'; + +@Entity({ name: 'in_app_notifications', schema: 'notifications' }) +export class InAppNotification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'title', type: 'varchar', length: 200 }) + title: string; + + @Column({ name: 'message', type: 'text' }) + message: string; + + @Column({ name: 'icon', type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'color', type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ name: 'action_type', type: 'varchar', length: 30, nullable: true }) + actionType: InAppActionType; + + @Column({ name: 'action_url', type: 'text', nullable: true }) + actionUrl: string; + + @Column({ name: 'action_data', type: 'jsonb', default: {} }) + actionData: Record; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: InAppCategory; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Index() + @Column({ name: 'is_read', type: 'boolean', default: false }) + isRead: boolean; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'is_archived', type: 'boolean', default: false }) + isArchived: boolean; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: InAppPriority; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/notifications/entities/index.ts b/src/modules/notifications/entities/index.ts new file mode 100644 index 0000000..78ee483 --- /dev/null +++ b/src/modules/notifications/entities/index.ts @@ -0,0 +1,6 @@ +export { Channel, ChannelType } from './channel.entity'; +export { NotificationTemplate, TemplateTranslation, TemplateCategory } from './template.entity'; +export { NotificationPreference, DigestFrequency } from './preference.entity'; +export { Notification, NotificationStatus, NotificationPriority } from './notification.entity'; +export { NotificationBatch, BatchStatus, AudienceType } from './notification-batch.entity'; +export { InAppNotification, InAppCategory, InAppPriority, InAppActionType } from './in-app-notification.entity'; diff --git a/src/modules/notifications/entities/notification-batch.entity.ts b/src/modules/notifications/entities/notification-batch.entity.ts new file mode 100644 index 0000000..4873006 --- /dev/null +++ b/src/modules/notifications/entities/notification-batch.entity.ts @@ -0,0 +1,88 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ChannelType } from './channel.entity'; +import { NotificationTemplate } from './template.entity'; + +export type BatchStatus = 'draft' | 'scheduled' | 'processing' | 'completed' | 'failed' | 'cancelled'; +export type AudienceType = 'all_users' | 'segment' | 'custom'; + +@Entity({ name: 'notification_batches', schema: 'notifications' }) +export class NotificationBatch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ name: 'variables', type: 'jsonb', default: {} }) + variables: Record; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'draft' }) + status: BatchStatus; + + @Column({ name: 'total_recipients', type: 'int', default: 0 }) + totalRecipients: number; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => NotificationTemplate, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; +} diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..2fb0744 --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ChannelType, Channel } from './channel.entity'; +import { NotificationTemplate } from './template.entity'; + +export type NotificationStatus = 'pending' | 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'cancelled'; +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'notifications', schema: 'notifications' }) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'recipient_email', type: 'varchar', length: 255, nullable: true }) + recipientEmail: string; + + @Column({ name: 'recipient_phone', type: 'varchar', length: 20, nullable: true }) + recipientPhone: string; + + @Column({ name: 'recipient_device_id', type: 'uuid', nullable: true }) + recipientDeviceId: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_code', type: 'varchar', length: 100, nullable: true }) + templateCode: string; + + @Index() + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'channel_id', type: 'uuid', nullable: true }) + channelId: string; + + @Column({ name: 'subject', type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body', type: 'text' }) + body: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'variables', type: 'jsonb', default: {} }) + variables: Record; + + @Index() + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: NotificationPriority; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: NotificationStatus; + + @Column({ name: 'queued_at', type: 'timestamptz', nullable: true }) + queuedAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'failed_at', type: 'timestamptz', nullable: true }) + failedAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'retry_count', type: 'int', default: 0 }) + retryCount: number; + + @Column({ name: 'max_retries', type: 'int', default: 3 }) + maxRetries: number; + + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'provider_message_id', type: 'varchar', length: 255, nullable: true }) + providerMessageId: string; + + @Column({ name: 'provider_response', type: 'jsonb', default: {} }) + providerResponse: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => NotificationTemplate, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; + + @ManyToOne(() => Channel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'channel_id' }) + channel: Channel; +} diff --git a/src/modules/notifications/entities/preference.entity.ts b/src/modules/notifications/entities/preference.entity.ts new file mode 100644 index 0000000..a1fc7de --- /dev/null +++ b/src/modules/notifications/entities/preference.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly'; + +@Entity({ name: 'preferences', schema: 'notifications' }) +@Unique(['userId', 'tenantId']) +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'global_enabled', type: 'boolean', default: true }) + globalEnabled: boolean; + + @Column({ name: 'quiet_hours_start', type: 'time', nullable: true }) + quietHoursStart: string; + + @Column({ name: 'quiet_hours_end', type: 'time', nullable: true }) + quietHoursEnd: string; + + @Column({ name: 'timezone', type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ name: 'email_enabled', type: 'boolean', default: true }) + emailEnabled: boolean; + + @Column({ name: 'sms_enabled', type: 'boolean', default: true }) + smsEnabled: boolean; + + @Column({ name: 'push_enabled', type: 'boolean', default: true }) + pushEnabled: boolean; + + @Column({ name: 'whatsapp_enabled', type: 'boolean', default: false }) + whatsappEnabled: boolean; + + @Column({ name: 'in_app_enabled', type: 'boolean', default: true }) + inAppEnabled: boolean; + + @Column({ name: 'category_preferences', type: 'jsonb', default: {} }) + categoryPreferences: Record; + + @Column({ name: 'digest_frequency', type: 'varchar', length: 20, default: 'instant' }) + digestFrequency: DigestFrequency; + + @Column({ name: 'digest_day', type: 'int', nullable: true }) + digestDay: number; + + @Column({ name: 'digest_hour', type: 'int', default: 9 }) + digestHour: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/template.entity.ts b/src/modules/notifications/entities/template.entity.ts new file mode 100644 index 0000000..e5be08f --- /dev/null +++ b/src/modules/notifications/entities/template.entity.ts @@ -0,0 +1,118 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { ChannelType } from './channel.entity'; + +export type TemplateCategory = 'system' | 'marketing' | 'transactional' | 'alert'; + +@Entity({ name: 'templates', schema: 'notifications' }) +@Unique(['tenantId', 'code', 'channelType']) +export class NotificationTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: TemplateCategory; + + @Index() + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'subject', type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body_template', type: 'text' }) + bodyTemplate: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'available_variables', type: 'jsonb', default: [] }) + availableVariables: string[]; + + @Column({ name: 'default_locale', type: 'varchar', length: 10, default: 'es-MX' }) + defaultLocale: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TemplateTranslation, (translation) => translation.template) + translations: TemplateTranslation[]; +} + +@Entity({ name: 'template_translations', schema: 'notifications' }) +@Unique(['templateId', 'locale']) +export class TemplateTranslation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ name: 'locale', type: 'varchar', length: 10 }) + locale: string; + + @Column({ name: 'subject', type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body_template', type: 'text' }) + bodyTemplate: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => NotificationTemplate, (template) => template.translations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; +} diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts new file mode 100644 index 0000000..ce95f7e --- /dev/null +++ b/src/modules/notifications/index.ts @@ -0,0 +1,5 @@ +export { NotificationsModule, NotificationsModuleOptions } from './notifications.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..0409174 --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -0,0 +1,68 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { NotificationsService } from './services'; +import { NotificationsController } from './controllers'; +import { + Channel, + NotificationTemplate, + NotificationTemplateTranslation, + NotificationPreference, + Notification, + NotificationBatch, + InAppNotification, +} from './entities'; + +export interface NotificationsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class NotificationsModule { + public router: Router; + public notificationsService: NotificationsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: NotificationsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const channelRepository = this.dataSource.getRepository(Channel); + const templateRepository = this.dataSource.getRepository(NotificationTemplate); + const preferenceRepository = this.dataSource.getRepository(NotificationPreference); + const notificationRepository = this.dataSource.getRepository(Notification); + const batchRepository = this.dataSource.getRepository(NotificationBatch); + const inAppRepository = this.dataSource.getRepository(InAppNotification); + + this.notificationsService = new NotificationsService( + channelRepository, + templateRepository, + preferenceRepository, + notificationRepository, + batchRepository, + inAppRepository + ); + } + + private initializeRoutes(): void { + const notificationsController = new NotificationsController(this.notificationsService); + this.router.use(`${this.basePath}/notifications`, notificationsController.router); + } + + static getEntities(): Function[] { + return [ + Channel, + NotificationTemplate, + NotificationTemplateTranslation, + NotificationPreference, + Notification, + NotificationBatch, + InAppNotification, + ]; + } +} diff --git a/src/modules/notifications/services/index.ts b/src/modules/notifications/services/index.ts new file mode 100644 index 0000000..cf62754 --- /dev/null +++ b/src/modules/notifications/services/index.ts @@ -0,0 +1 @@ +export { NotificationsService } from './notifications.service'; diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts new file mode 100644 index 0000000..e54d653 --- /dev/null +++ b/src/modules/notifications/services/notifications.service.ts @@ -0,0 +1,215 @@ +import { Repository, FindOptionsWhere } from 'typeorm'; +import { + Channel, + NotificationTemplate, + NotificationPreference, + Notification, + NotificationBatch, + InAppNotification, +} from '../entities'; + +export class NotificationsService { + constructor( + private readonly channelRepository: Repository, + private readonly templateRepository: Repository, + private readonly preferenceRepository: Repository, + private readonly notificationRepository: Repository, + private readonly batchRepository: Repository, + private readonly inAppRepository: Repository + ) {} + + // ============================================ + // CHANNELS + // ============================================ + + async findAllChannels(): Promise { + return this.channelRepository.find({ + where: { isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findChannelByCode(code: string): Promise { + return this.channelRepository.findOne({ where: { code } }); + } + + // ============================================ + // TEMPLATES + // ============================================ + + async findAllTemplates(tenantId: string): Promise { + return this.templateRepository.find({ + where: [{ tenantId }, { tenantId: undefined, isActive: true }], + relations: ['translations'], + order: { name: 'ASC' }, + }); + } + + async findTemplateByCode( + code: string, + channelType: string, + tenantId?: string + ): Promise { + const where: FindOptionsWhere[] = tenantId + ? [{ code, channelType, tenantId }, { code, channelType, tenantId: undefined }] + : [{ code, channelType }]; + + return this.templateRepository.findOne({ + where, + relations: ['translations'], + order: { tenantId: 'DESC' }, + }); + } + + async createTemplate( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const template = this.templateRepository.create({ + ...data, + tenantId, + createdBy, + }); + return this.templateRepository.save(template); + } + + // ============================================ + // PREFERENCES + // ============================================ + + async getPreferences(userId: string, tenantId: string): Promise { + return this.preferenceRepository.findOne({ + where: { userId, tenantId }, + }); + } + + async updatePreferences( + userId: string, + tenantId: string, + data: Partial + ): Promise { + let preferences = await this.getPreferences(userId, tenantId); + + if (!preferences) { + preferences = this.preferenceRepository.create({ + userId, + tenantId, + ...data, + }); + } else { + Object.assign(preferences, data); + } + + return this.preferenceRepository.save(preferences); + } + + // ============================================ + // NOTIFICATIONS + // ============================================ + + async createNotification( + tenantId: string, + data: Partial + ): Promise { + const notification = this.notificationRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.notificationRepository.save(notification); + } + + async findPendingNotifications(limit: number = 100): Promise { + return this.notificationRepository.find({ + where: { status: 'pending' }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + async updateNotificationStatus( + id: string, + status: string, + errorMessage?: string + ): Promise { + const notification = await this.notificationRepository.findOne({ where: { id } }); + if (!notification) return null; + + notification.status = status as any; + if (errorMessage) notification.errorMessage = errorMessage; + if (status === 'sent') notification.sentAt = new Date(); + if (status === 'delivered') notification.deliveredAt = new Date(); + if (status === 'failed') notification.failedAt = new Date(); + + return this.notificationRepository.save(notification); + } + + // ============================================ + // IN-APP NOTIFICATIONS + // ============================================ + + async findInAppNotifications( + userId: string, + tenantId: string, + includeRead: boolean = false + ): Promise { + const where: FindOptionsWhere = { + userId, + tenantId, + isArchived: false, + }; + + if (!includeRead) { + where.isRead = false; + } + + return this.inAppRepository.find({ + where, + order: { createdAt: 'DESC' }, + take: 50, + }); + } + + async getUnreadCount(userId: string, tenantId: string): Promise { + return this.inAppRepository.count({ + where: { + userId, + tenantId, + isRead: false, + isArchived: false, + }, + }); + } + + async markAsRead(id: string): Promise { + const notification = await this.inAppRepository.findOne({ where: { id } }); + if (!notification || notification.isRead) return false; + + notification.isRead = true; + notification.readAt = new Date(); + await this.inAppRepository.save(notification); + return true; + } + + async markAllAsRead(userId: string, tenantId: string): Promise { + const result = await this.inAppRepository.update( + { userId, tenantId, isRead: false }, + { isRead: true, readAt: new Date() } + ); + return result.affected || 0; + } + + async createInAppNotification( + tenantId: string, + userId: string, + data: Partial + ): Promise { + const notification = this.inAppRepository.create({ + ...data, + tenantId, + userId, + }); + return this.inAppRepository.save(notification); + } +} diff --git a/src/modules/partners/controllers/index.ts b/src/modules/partners/controllers/index.ts new file mode 100644 index 0000000..66e2ab7 --- /dev/null +++ b/src/modules/partners/controllers/index.ts @@ -0,0 +1 @@ +export { PartnersController } from './partners.controller'; diff --git a/src/modules/partners/controllers/partners.controller.ts b/src/modules/partners/controllers/partners.controller.ts new file mode 100644 index 0000000..1afaec1 --- /dev/null +++ b/src/modules/partners/controllers/partners.controller.ts @@ -0,0 +1,348 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { PartnersService } from '../services/partners.service'; +import { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from '../dto'; + +export class PartnersController { + public router: Router; + + constructor(private readonly partnersService: PartnersService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Partners + this.router.get('/', this.findAll.bind(this)); + this.router.get('/customers', this.getCustomers.bind(this)); + this.router.get('/suppliers', this.getSuppliers.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Addresses + this.router.get('/:id/addresses', this.getAddresses.bind(this)); + this.router.post('/:id/addresses', this.createAddress.bind(this)); + this.router.delete('/:id/addresses/:addressId', this.deleteAddress.bind(this)); + + // Contacts + this.router.get('/:id/contacts', this.getContacts.bind(this)); + this.router.post('/:id/contacts', this.createContact.bind(this)); + this.router.delete('/:id/contacts/:contactId', this.deleteContact.bind(this)); + + // Bank Accounts + this.router.get('/:id/bank-accounts', this.getBankAccounts.bind(this)); + this.router.post('/:id/bank-accounts', this.createBankAccount.bind(this)); + this.router.delete('/:id/bank-accounts/:accountId', this.deleteBankAccount.bind(this)); + this.router.post('/:id/bank-accounts/:accountId/verify', this.verifyBankAccount.bind(this)); + } + + // ==================== Partners ==================== + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { search, partnerType, category, isActive, salesRepId, limit, offset } = req.query; + + const result = await this.partnersService.findAll({ + tenantId, + search: search as string, + partnerType: partnerType as 'customer' | 'supplier' | 'both', + category: category as string, + isActive: isActive ? isActive === 'true' : undefined, + salesRepId: salesRepId as string, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const partner = await this.partnersService.findOne(id, tenantId); + + if (!partner) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { code } = req.params; + const partner = await this.partnersService.findByCode(code, tenantId); + + if (!partner) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreatePartnerDto = req.body; + const partner = await this.partnersService.create(tenantId, dto, userId); + res.status(201).json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdatePartnerDto = req.body; + const partner = await this.partnersService.update(id, tenantId, dto, userId); + + if (!partner) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.partnersService.delete(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getCustomers(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const customers = await this.partnersService.getCustomers(tenantId); + res.json({ data: customers }); + } catch (error) { + next(error); + } + } + + private async getSuppliers(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const suppliers = await this.partnersService.getSuppliers(tenantId); + res.json({ data: suppliers }); + } catch (error) { + next(error); + } + } + + // ==================== Addresses ==================== + + private async getAddresses(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const addresses = await this.partnersService.getAddresses(id); + res.json({ data: addresses }); + } catch (error) { + next(error); + } + } + + private async createAddress(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreatePartnerAddressDto = { ...req.body, partnerId: id }; + const address = await this.partnersService.createAddress(dto); + res.status(201).json({ data: address }); + } catch (error) { + next(error); + } + } + + private async deleteAddress(req: Request, res: Response, next: NextFunction): Promise { + try { + const { addressId } = req.params; + const deleted = await this.partnersService.deleteAddress(addressId); + + if (!deleted) { + res.status(404).json({ error: 'Address not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ==================== Contacts ==================== + + private async getContacts(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const contacts = await this.partnersService.getContacts(id); + res.json({ data: contacts }); + } catch (error) { + next(error); + } + } + + private async createContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreatePartnerContactDto = { ...req.body, partnerId: id }; + const contact = await this.partnersService.createContact(dto); + res.status(201).json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async deleteContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { contactId } = req.params; + const deleted = await this.partnersService.deleteContact(contactId); + + if (!deleted) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ==================== Bank Accounts ==================== + + private async getBankAccounts(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const bankAccounts = await this.partnersService.getBankAccounts(id); + res.json({ data: bankAccounts }); + } catch (error) { + next(error); + } + } + + private async createBankAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreatePartnerBankAccountDto = { ...req.body, partnerId: id }; + const bankAccount = await this.partnersService.createBankAccount(dto); + res.status(201).json({ data: bankAccount }); + } catch (error) { + next(error); + } + } + + private async deleteBankAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId } = req.params; + const deleted = await this.partnersService.deleteBankAccount(accountId); + + if (!deleted) { + res.status(404).json({ error: 'Bank account not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async verifyBankAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId } = req.params; + const bankAccount = await this.partnersService.verifyBankAccount(accountId); + + if (!bankAccount) { + res.status(404).json({ error: 'Bank account not found' }); + return; + } + + res.json({ data: bankAccount }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/partners/dto/create-partner.dto.ts b/src/modules/partners/dto/create-partner.dto.ts new file mode 100644 index 0000000..275501a --- /dev/null +++ b/src/modules/partners/dto/create-partner.dto.ts @@ -0,0 +1,389 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsEmail, + IsNumber, + IsArray, + IsUUID, + MaxLength, + IsEnum, + Min, + Max, +} from 'class-validator'; + +export class CreatePartnerDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(200) + displayName: string; + + @IsOptional() + @IsString() + @MaxLength(200) + legalName?: string; + + @IsOptional() + @IsEnum(['customer', 'supplier', 'both']) + partnerType?: 'customer' | 'supplier' | 'both'; + + @IsOptional() + @IsString() + @MaxLength(20) + taxId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + taxRegime?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + cfdiUse?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mobile?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + website?: string; + + @IsOptional() + @IsNumber() + @Min(0) + paymentTermDays?: number; + + @IsOptional() + @IsNumber() + @Min(0) + creditLimit?: number; + + @IsOptional() + @IsUUID() + priceListId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + discountPercent?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsUUID() + salesRepId?: string; +} + +export class UpdatePartnerDto { + @IsOptional() + @IsString() + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + displayName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + legalName?: string; + + @IsOptional() + @IsEnum(['customer', 'supplier', 'both']) + partnerType?: 'customer' | 'supplier' | 'both'; + + @IsOptional() + @IsString() + @MaxLength(20) + taxId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + taxRegime?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + cfdiUse?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mobile?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + website?: string; + + @IsOptional() + @IsNumber() + @Min(0) + paymentTermDays?: number; + + @IsOptional() + @IsNumber() + @Min(0) + creditLimit?: number; + + @IsOptional() + @IsUUID() + priceListId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + discountPercent?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsUUID() + salesRepId?: string; +} + +export class CreatePartnerAddressDto { + @IsUUID() + partnerId: string; + + @IsOptional() + @IsEnum(['billing', 'shipping', 'both']) + addressType?: 'billing' | 'shipping' | 'both'; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + label?: string; + + @IsString() + @MaxLength(200) + street: string; + + @IsOptional() + @IsString() + @MaxLength(20) + exteriorNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + interiorNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + neighborhood?: string; + + @IsString() + @MaxLength(100) + city: string; + + @IsOptional() + @IsString() + @MaxLength(100) + municipality?: string; + + @IsString() + @MaxLength(100) + state: string; + + @IsString() + @MaxLength(10) + postalCode: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsString() + reference?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; +} + +export class CreatePartnerContactDto { + @IsUUID() + partnerId: string; + + @IsString() + @MaxLength(200) + fullName: string; + + @IsOptional() + @IsString() + @MaxLength(100) + position?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + department?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mobile?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + extension?: string; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; + + @IsOptional() + @IsBoolean() + isBillingContact?: boolean; + + @IsOptional() + @IsBoolean() + isShippingContact?: boolean; + + @IsOptional() + @IsBoolean() + receivesNotifications?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CreatePartnerBankAccountDto { + @IsUUID() + partnerId: string; + + @IsString() + @MaxLength(100) + bankName: string; + + @IsOptional() + @IsString() + @MaxLength(10) + bankCode?: string; + + @IsString() + @MaxLength(30) + accountNumber: string; + + @IsOptional() + @IsString() + @MaxLength(20) + clabe?: string; + + @IsOptional() + @IsEnum(['checking', 'savings']) + accountType?: 'checking' | 'savings'; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + beneficiaryName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + beneficiaryTaxId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + swiftCode?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/modules/partners/dto/index.ts b/src/modules/partners/dto/index.ts new file mode 100644 index 0000000..ef0bc75 --- /dev/null +++ b/src/modules/partners/dto/index.ts @@ -0,0 +1,7 @@ +export { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from './create-partner.dto'; diff --git a/src/modules/partners/entities/index.ts b/src/modules/partners/entities/index.ts index d64c144..59192e9 100644 --- a/src/modules/partners/entities/index.ts +++ b/src/modules/partners/entities/index.ts @@ -1 +1,4 @@ -export { Partner, PartnerType } from './partner.entity.js'; +export { Partner } from './partner.entity'; +export { PartnerAddress } from './partner-address.entity'; +export { PartnerContact } from './partner-contact.entity'; +export { PartnerBankAccount } from './partner-bank-account.entity'; diff --git a/src/modules/partners/entities/partner-address.entity.ts b/src/modules/partners/entities/partner-address.entity.ts new file mode 100644 index 0000000..566becc --- /dev/null +++ b/src/modules/partners/entities/partner-address.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_addresses', schema: 'partners' }) +export class PartnerAddress { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Tipo de direccion + @Index() + @Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' }) + addressType: 'billing' | 'shipping' | 'both'; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + // Direccion + @Column({ type: 'varchar', length: 100, nullable: true }) + label: string; + + @Column({ type: 'varchar', length: 200 }) + street: string; + + @Column({ name: 'exterior_number', type: 'varchar', length: 20, nullable: true }) + exteriorNumber: string; + + @Column({ name: 'interior_number', type: 'varchar', length: 20, nullable: true }) + interiorNumber: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + neighborhood: string; + + @Column({ type: 'varchar', length: 100 }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + municipality: string; + + @Column({ type: 'varchar', length: 100 }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 10 }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Referencia + @Column({ type: 'text', nullable: true }) + reference: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/partners/entities/partner-bank-account.entity.ts b/src/modules/partners/entities/partner-bank-account.entity.ts new file mode 100644 index 0000000..a5cce38 --- /dev/null +++ b/src/modules/partners/entities/partner-bank-account.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_bank_accounts', schema: 'partners' }) +export class PartnerBankAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Banco + @Column({ name: 'bank_name', type: 'varchar', length: 100 }) + bankName: string; + + @Column({ name: 'bank_code', type: 'varchar', length: 10, nullable: true }) + bankCode: string; + + // Cuenta + @Column({ name: 'account_number', type: 'varchar', length: 30 }) + accountNumber: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + clabe: string; + + @Column({ name: 'account_type', type: 'varchar', length: 20, default: 'checking' }) + accountType: 'checking' | 'savings'; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Titular + @Column({ name: 'beneficiary_name', type: 'varchar', length: 200, nullable: true }) + beneficiaryName: string; + + @Column({ name: 'beneficiary_tax_id', type: 'varchar', length: 20, nullable: true }) + beneficiaryTaxId: string; + + // Swift para transferencias internacionales + @Column({ name: 'swift_code', type: 'varchar', length: 20, nullable: true }) + swiftCode: string; + + // Flags + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/partners/entities/partner-contact.entity.ts b/src/modules/partners/entities/partner-contact.entity.ts new file mode 100644 index 0000000..d4479fe --- /dev/null +++ b/src/modules/partners/entities/partner-contact.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_contacts', schema: 'partners' }) +export class PartnerContact { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Datos del contacto + @Column({ name: 'full_name', type: 'varchar', length: 200 }) + fullName: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + position: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + department: string; + + // Contacto + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + mobile: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + extension: string; + + // Flags + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @Column({ name: 'is_billing_contact', type: 'boolean', default: false }) + isBillingContact: boolean; + + @Column({ name: 'is_shipping_contact', type: 'boolean', default: false }) + isShippingContact: boolean; + + @Column({ name: 'receives_notifications', type: 'boolean', default: true }) + receivesNotifications: boolean; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/partners/entities/partner.entity.ts b/src/modules/partners/entities/partner.entity.ts index 5f59f9d..3173892 100644 --- a/src/modules/partners/entities/partner.entity.ts +++ b/src/modules/partners/entities/partner.entity.ts @@ -4,129 +4,115 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, ManyToOne, + OneToMany, JoinColumn, } from 'typeorm'; -import { Tenant } from '../../auth/entities/tenant.entity.js'; -import { Company } from '../../auth/entities/company.entity.js'; -export type PartnerType = 'person' | 'company'; - -@Entity({ schema: 'core', name: 'partners' }) -@Index('idx_partners_tenant_id', ['tenantId']) -@Index('idx_partners_company_id', ['companyId']) -@Index('idx_partners_parent_id', ['parentId']) -@Index('idx_partners_active', ['tenantId', 'active'], { where: 'deleted_at IS NULL' }) -@Index('idx_partners_is_customer', ['tenantId', 'isCustomer'], { where: 'deleted_at IS NULL AND is_customer = true' }) -@Index('idx_partners_is_supplier', ['tenantId', 'isSupplier'], { where: 'deleted_at IS NULL AND is_supplier = true' }) -@Index('idx_partners_is_employee', ['tenantId', 'isEmployee'], { where: 'deleted_at IS NULL AND is_employee = true' }) -@Index('idx_partners_tax_id', ['taxId']) -@Index('idx_partners_email', ['email']) +@Entity({ name: 'partners', schema: 'partners' }) export class Partner { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; - @Column({ type: 'varchar', length: 255, nullable: false }) - name: string; + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20, unique: true }) + code: string; - @Column({ type: 'varchar', length: 255, nullable: true, name: 'legal_name' }) - legalName: string | null; + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; - @Column({ - type: 'varchar', - length: 20, - nullable: false, - default: 'person', - name: 'partner_type', - }) - partnerType: PartnerType; + @Column({ name: 'legal_name', type: 'varchar', length: 200, nullable: true }) + legalName: string; - @Column({ type: 'boolean', default: false, name: 'is_customer' }) - isCustomer: boolean; + // Tipo de partner + @Index() + @Column({ name: 'partner_type', type: 'varchar', length: 20, default: 'customer' }) + partnerType: 'customer' | 'supplier' | 'both'; - @Column({ type: 'boolean', default: false, name: 'is_supplier' }) - isSupplier: boolean; + // Fiscal + @Index() + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; - @Column({ type: 'boolean', default: false, name: 'is_employee' }) - isEmployee: boolean; + @Column({ name: 'tax_regime', type: 'varchar', length: 100, nullable: true }) + taxRegime: string; - @Column({ type: 'boolean', default: false, name: 'is_company' }) - isCompany: boolean; + @Column({ name: 'cfdi_use', type: 'varchar', length: 10, nullable: true }) + cfdiUse: string; + // Contacto principal @Column({ type: 'varchar', length: 255, nullable: true }) - email: string | null; + email: string; + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + mobile: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + website: string; + + // Terminos de pago + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'credit_limit', type: 'decimal', precision: 15, scale: 2, default: 0 }) + creditLimit: number; + + @Column({ name: 'current_balance', type: 'decimal', precision: 15, scale: 2, default: 0 }) + currentBalance: number; + + // Lista de precios + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + // Descuentos + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + // Categoria @Column({ type: 'varchar', length: 50, nullable: true }) - phone: string | null; + category: string; - @Column({ type: 'varchar', length: 50, nullable: true }) - mobile: string | null; - - @Column({ type: 'varchar', length: 255, nullable: true }) - website: string | null; - - @Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' }) - taxId: string | null; - - @Column({ type: 'uuid', nullable: true, name: 'company_id' }) - companyId: string | null; - - @Column({ type: 'uuid', nullable: true, name: 'parent_id' }) - parentId: string | null; - - @Column({ type: 'uuid', nullable: true, name: 'currency_id' }) - currencyId: string | null; + @Column({ type: 'text', array: true, default: '{}' }) + tags: string[]; + // Notas @Column({ type: 'text', nullable: true }) - notes: string | null; + notes: string; - @Column({ type: 'boolean', default: true }) - active: boolean; + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; - // Relaciones - @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'tenant_id' }) - tenant: Tenant; + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; - @ManyToOne(() => Company, { nullable: true }) - @JoinColumn({ name: 'company_id' }) - company: Company | null; + // Vendedor asignado + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; - @ManyToOne(() => Partner, (partner) => partner.children, { nullable: true }) - @JoinColumn({ name: 'parent_id' }) - parentPartner: Partner | null; - - children: Partner[]; - - // Auditoría - @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; - @Column({ type: 'uuid', nullable: true, name: 'created_by' }) - createdBy: string | null; + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; - @UpdateDateColumn({ - name: 'updated_at', - type: 'timestamp', - nullable: true, - }) - updatedAt: Date | null; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; - @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) - updatedBy: string | null; + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; - @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) - deletedAt: Date | null; - - @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) - deletedBy: string | null; - - // Virtual fields for joined data - companyName?: string; - currencyCode?: string; - parentName?: string; + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; } diff --git a/src/modules/partners/index.ts b/src/modules/partners/index.ts index 4a0be6c..df1c997 100644 --- a/src/modules/partners/index.ts +++ b/src/modules/partners/index.ts @@ -1,6 +1,5 @@ -export * from './entities/index.js'; -export * from './partners.service.js'; -export * from './partners.controller.js'; -export * from './ranking.service.js'; -export * from './ranking.controller.js'; -export { default as partnersRoutes } from './partners.routes.js'; +export { PartnersModule, PartnersModuleOptions } from './partners.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/partners/partners.module.ts b/src/modules/partners/partners.module.ts new file mode 100644 index 0000000..8e6e8c8 --- /dev/null +++ b/src/modules/partners/partners.module.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { PartnersService } from './services'; +import { PartnersController } from './controllers'; +import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from './entities'; + +export interface PartnersModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PartnersModule { + public router: Router; + public partnersService: PartnersService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: PartnersModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const partnerRepository = this.dataSource.getRepository(Partner); + const addressRepository = this.dataSource.getRepository(PartnerAddress); + const contactRepository = this.dataSource.getRepository(PartnerContact); + const bankAccountRepository = this.dataSource.getRepository(PartnerBankAccount); + + this.partnersService = new PartnersService( + partnerRepository, + addressRepository, + contactRepository, + bankAccountRepository + ); + } + + private initializeRoutes(): void { + const partnersController = new PartnersController(this.partnersService); + this.router.use(`${this.basePath}/partners`, partnersController.router); + } + + static getEntities(): Function[] { + return [Partner, PartnerAddress, PartnerContact, PartnerBankAccount]; + } +} diff --git a/src/modules/partners/services/index.ts b/src/modules/partners/services/index.ts new file mode 100644 index 0000000..bd0ac0d --- /dev/null +++ b/src/modules/partners/services/index.ts @@ -0,0 +1 @@ +export { PartnersService, PartnerSearchParams } from './partners.service'; diff --git a/src/modules/partners/services/partners.service.ts b/src/modules/partners/services/partners.service.ts new file mode 100644 index 0000000..cac026d --- /dev/null +++ b/src/modules/partners/services/partners.service.ts @@ -0,0 +1,266 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from '../entities'; +import { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from '../dto'; + +export interface PartnerSearchParams { + tenantId: string; + search?: string; + partnerType?: 'customer' | 'supplier' | 'both'; + category?: string; + isActive?: boolean; + salesRepId?: string; + limit?: number; + offset?: number; +} + +export class PartnersService { + constructor( + private readonly partnerRepository: Repository, + private readonly addressRepository: Repository, + private readonly contactRepository: Repository, + private readonly bankAccountRepository: Repository + ) {} + + // ==================== Partners ==================== + + async findAll(params: PartnerSearchParams): Promise<{ data: Partner[]; total: number }> { + const { + tenantId, + search, + partnerType, + category, + isActive, + salesRepId, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (partnerType) { + baseWhere.partnerType = partnerType; + } + + if (category) { + baseWhere.category = category; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (salesRepId) { + baseWhere.salesRepId = salesRepId; + } + + if (search) { + where.push( + { ...baseWhere, displayName: ILike(`%${search}%`) }, + { ...baseWhere, legalName: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) }, + { ...baseWhere, taxId: ILike(`%${search}%`) }, + { ...baseWhere, email: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.partnerRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { displayName: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { id, tenantId } }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { code, tenantId } }); + } + + async findByTaxId(taxId: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { taxId, tenantId } }); + } + + async create(tenantId: string, dto: CreatePartnerDto, createdBy?: string): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A partner with this code already exists'); + } + + // Check for existing tax ID + if (dto.taxId) { + const existingTaxId = await this.findByTaxId(dto.taxId, tenantId); + if (existingTaxId) { + throw new Error('A partner with this tax ID already exists'); + } + } + + const partner = this.partnerRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.partnerRepository.save(partner); + } + + async update( + id: string, + tenantId: string, + dto: UpdatePartnerDto, + updatedBy?: string + ): Promise { + const partner = await this.findOne(id, tenantId); + if (!partner) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== partner.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('A partner with this code already exists'); + } + } + + // If changing tax ID, check for duplicates + if (dto.taxId && dto.taxId !== partner.taxId) { + const existing = await this.findByTaxId(dto.taxId, tenantId); + if (existing && existing.id !== id) { + throw new Error('A partner with this tax ID already exists'); + } + } + + Object.assign(partner, { + ...dto, + updatedBy, + }); + + return this.partnerRepository.save(partner); + } + + async delete(id: string, tenantId: string): Promise { + const partner = await this.findOne(id, tenantId); + if (!partner) return false; + + const result = await this.partnerRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getCustomers(tenantId: string): Promise { + return this.partnerRepository.find({ + where: [ + { tenantId, partnerType: 'customer', isActive: true }, + { tenantId, partnerType: 'both', isActive: true }, + ], + order: { displayName: 'ASC' }, + }); + } + + async getSuppliers(tenantId: string): Promise { + return this.partnerRepository.find({ + where: [ + { tenantId, partnerType: 'supplier', isActive: true }, + { tenantId, partnerType: 'both', isActive: true }, + ], + order: { displayName: 'ASC' }, + }); + } + + // ==================== Addresses ==================== + + async getAddresses(partnerId: string): Promise { + return this.addressRepository.find({ + where: { partnerId }, + order: { isDefault: 'DESC', addressType: 'ASC' }, + }); + } + + async createAddress(dto: CreatePartnerAddressDto): Promise { + // If setting as default, unset other defaults of same type + if (dto.isDefault) { + await this.addressRepository.update( + { partnerId: dto.partnerId, addressType: dto.addressType }, + { isDefault: false } + ); + } + + const address = this.addressRepository.create(dto); + return this.addressRepository.save(address); + } + + async deleteAddress(id: string): Promise { + const result = await this.addressRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // ==================== Contacts ==================== + + async getContacts(partnerId: string): Promise { + return this.contactRepository.find({ + where: { partnerId }, + order: { isPrimary: 'DESC', fullName: 'ASC' }, + }); + } + + async createContact(dto: CreatePartnerContactDto): Promise { + // If setting as primary, unset other primaries + if (dto.isPrimary) { + await this.contactRepository.update({ partnerId: dto.partnerId }, { isPrimary: false }); + } + + const contact = this.contactRepository.create(dto); + return this.contactRepository.save(contact); + } + + async deleteContact(id: string): Promise { + const result = await this.contactRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // ==================== Bank Accounts ==================== + + async getBankAccounts(partnerId: string): Promise { + return this.bankAccountRepository.find({ + where: { partnerId }, + order: { isDefault: 'DESC', bankName: 'ASC' }, + }); + } + + async createBankAccount(dto: CreatePartnerBankAccountDto): Promise { + // If setting as default, unset other defaults + if (dto.isDefault) { + await this.bankAccountRepository.update({ partnerId: dto.partnerId }, { isDefault: false }); + } + + const bankAccount = this.bankAccountRepository.create(dto); + return this.bankAccountRepository.save(bankAccount); + } + + async deleteBankAccount(id: string): Promise { + const result = await this.bankAccountRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async verifyBankAccount(id: string): Promise { + const bankAccount = await this.bankAccountRepository.findOne({ where: { id } }); + if (!bankAccount) return null; + + bankAccount.isVerified = true; + bankAccount.verifiedAt = new Date(); + + return this.bankAccountRepository.save(bankAccount); + } +} diff --git a/src/modules/payment-terminals/controllers/index.ts b/src/modules/payment-terminals/controllers/index.ts new file mode 100644 index 0000000..5aff8eb --- /dev/null +++ b/src/modules/payment-terminals/controllers/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals Controllers Index + */ + +export { TerminalsController } from './terminals.controller'; +export { TransactionsController } from './transactions.controller'; diff --git a/src/modules/payment-terminals/controllers/terminals.controller.ts b/src/modules/payment-terminals/controllers/terminals.controller.ts new file mode 100644 index 0000000..8749190 --- /dev/null +++ b/src/modules/payment-terminals/controllers/terminals.controller.ts @@ -0,0 +1,192 @@ +/** + * Terminals Controller + * + * REST API endpoints for terminal management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TerminalsService } from '../services'; +import { CreateTerminalDto, UpdateTerminalDto } from '../dto'; + +// Extend Request to include tenant info +interface AuthenticatedRequest extends Request { + tenantId?: string; + userId?: string; +} + +export class TerminalsController { + public router: Router; + private service: TerminalsService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new TerminalsService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Branch terminals + this.router.get('/branch/:branchId', this.getByBranch.bind(this)); + this.router.get('/branch/:branchId/primary', this.getPrimary.bind(this)); + + // Terminal CRUD + this.router.get('/:id', this.getById.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.put('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Terminal actions + this.router.get('/:id/health', this.checkHealth.bind(this)); + this.router.post('/:id/set-primary', this.setPrimary.bind(this)); + + // Health check batch (for scheduled job) + this.router.post('/health-check-batch', this.healthCheckBatch.bind(this)); + } + + /** + * GET /payment-terminals/branch/:branchId + * Get terminals for branch + */ + private async getByBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminals = await this.service.findByBranch(req.params.branchId); + res.json({ data: terminals }); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-terminals/branch/:branchId/primary + * Get primary terminal for branch + */ + private async getPrimary(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminal = await this.service.findPrimaryTerminal(req.params.branchId); + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-terminals/:id + * Get terminal by ID + */ + private async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminal = await this.service.findById(req.params.id); + + if (!terminal) { + res.status(404).json({ error: 'Terminal not found' }); + return; + } + + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-terminals + * Create new terminal + */ + private async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: CreateTerminalDto = req.body; + const terminal = await this.service.create(req.tenantId!, dto); + res.status(201).json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * PUT /payment-terminals/:id + * Update terminal + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateTerminalDto = req.body; + const terminal = await this.service.update(req.params.id, dto); + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /payment-terminals/:id + * Delete terminal (soft delete) + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + await this.service.delete(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-terminals/:id/health + * Check terminal health + */ + private async checkHealth(req: Request, res: Response, next: NextFunction): Promise { + try { + const health = await this.service.checkHealth(req.params.id); + res.json({ data: health }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-terminals/:id/set-primary + * Set terminal as primary for branch + */ + private async setPrimary(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminal = await this.service.setPrimary(req.params.id); + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-terminals/health-check-batch + * Run health check on all terminals needing check (scheduled job endpoint) + */ + private async healthCheckBatch(req: Request, res: Response, next: NextFunction): Promise { + try { + const maxAgeMinutes = parseInt(req.query.maxAgeMinutes as string) || 30; + const terminals = await this.service.findTerminalsNeedingHealthCheck(maxAgeMinutes); + + const results: { terminalId: string; status: string; message: string }[] = []; + + for (const terminal of terminals) { + try { + const health = await this.service.checkHealth(terminal.id); + results.push({ + terminalId: terminal.id, + status: health.status, + message: health.message, + }); + } catch (error: any) { + results.push({ + terminalId: terminal.id, + status: 'error', + message: error.message, + }); + } + } + + res.json({ data: { checked: results.length, results } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/payment-terminals/controllers/transactions.controller.ts b/src/modules/payment-terminals/controllers/transactions.controller.ts new file mode 100644 index 0000000..7b736c0 --- /dev/null +++ b/src/modules/payment-terminals/controllers/transactions.controller.ts @@ -0,0 +1,163 @@ +/** + * Transactions Controller + * + * REST API endpoints for payment transactions + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TransactionsService } from '../services'; +import { ProcessPaymentDto, ProcessRefundDto, SendReceiptDto, TransactionFilterDto } from '../dto'; + +// Extend Request to include tenant info +interface AuthenticatedRequest extends Request { + tenantId?: string; + userId?: string; +} + +export class TransactionsController { + public router: Router; + private service: TransactionsService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new TransactionsService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stats + this.router.get('/stats', this.getStats.bind(this)); + + // Payment processing + this.router.post('/charge', this.processPayment.bind(this)); + this.router.post('/refund', this.processRefund.bind(this)); + + // Transaction queries + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:id', this.getById.bind(this)); + + // Actions + this.router.post('/:id/receipt', this.sendReceipt.bind(this)); + } + + /** + * GET /payment-transactions/stats + * Get transaction statistics + */ + private async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const filter: TransactionFilterDto = { + branchId: req.query.branchId as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const stats = await this.service.getStats(req.tenantId!, filter); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-transactions/charge + * Process a payment + */ + private async processPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: ProcessPaymentDto = req.body; + const result = await this.service.processPayment(req.tenantId!, req.userId!, dto); + + if (result.success) { + res.status(201).json({ data: result }); + } else { + res.status(400).json({ data: result }); + } + } catch (error) { + next(error); + } + } + + /** + * POST /payment-transactions/refund + * Process a refund + */ + private async processRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: ProcessRefundDto = req.body; + const result = await this.service.processRefund(req.tenantId!, req.userId!, dto); + + if (result.success) { + res.json({ data: result }); + } else { + res.status(400).json({ data: result }); + } + } catch (error) { + next(error); + } + } + + /** + * GET /payment-transactions + * Get transactions with filters + */ + private async getAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const filter: TransactionFilterDto = { + branchId: req.query.branchId as string, + userId: req.query.userId as string, + status: req.query.status as any, + sourceType: req.query.sourceType as any, + terminalProvider: req.query.terminalProvider as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string) : undefined, + }; + + const result = await this.service.findAll(req.tenantId!, filter); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-transactions/:id + * Get transaction by ID + */ + private async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const transaction = await this.service.findById(req.params.id, req.tenantId!); + + if (!transaction) { + res.status(404).json({ error: 'Transaction not found' }); + return; + } + + res.json({ data: transaction }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-transactions/:id/receipt + * Send receipt for transaction + */ + private async sendReceipt(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: SendReceiptDto = req.body; + const result = await this.service.sendReceipt(req.params.id, req.tenantId!, dto); + + if (result.success) { + res.json({ success: true }); + } else { + res.status(400).json({ success: false, error: result.error }); + } + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/payment-terminals/dto/index.ts b/src/modules/payment-terminals/dto/index.ts new file mode 100644 index 0000000..02c5c6e --- /dev/null +++ b/src/modules/payment-terminals/dto/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals DTOs Index + */ + +export * from './terminal.dto'; +export * from './transaction.dto'; diff --git a/src/modules/payment-terminals/dto/terminal.dto.ts b/src/modules/payment-terminals/dto/terminal.dto.ts new file mode 100644 index 0000000..00b8fca --- /dev/null +++ b/src/modules/payment-terminals/dto/terminal.dto.ts @@ -0,0 +1,47 @@ +/** + * Terminal DTOs + */ + +import { TerminalProvider, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity'; + +export class CreateTerminalDto { + branchId: string; + terminalProvider: TerminalProvider; + terminalId: string; + terminalName?: string; + credentials?: Record; + isPrimary?: boolean; + dailyLimit?: number; + transactionLimit?: number; +} + +export class UpdateTerminalDto { + terminalName?: string; + credentials?: Record; + isPrimary?: boolean; + isActive?: boolean; + dailyLimit?: number; + transactionLimit?: number; +} + +export class TerminalHealthCheckDto { + terminalId: string; + status: HealthStatus; + message?: string; + responseTime?: number; +} + +export class TerminalResponseDto { + id: string; + branchId: string; + terminalProvider: TerminalProvider; + terminalId: string; + terminalName?: string; + isPrimary: boolean; + isActive: boolean; + dailyLimit?: number; + transactionLimit?: number; + healthStatus: HealthStatus; + lastTransactionAt?: Date; + lastHealthCheckAt?: Date; +} diff --git a/src/modules/payment-terminals/dto/transaction.dto.ts b/src/modules/payment-terminals/dto/transaction.dto.ts new file mode 100644 index 0000000..0a1bfe5 --- /dev/null +++ b/src/modules/payment-terminals/dto/transaction.dto.ts @@ -0,0 +1,75 @@ +/** + * Transaction DTOs + */ + +import { PaymentSourceType, PaymentMethod, PaymentStatus } from '../../mobile/entities/payment-transaction.entity'; + +export class ProcessPaymentDto { + terminalId: string; + amount: number; + currency?: string; + tipAmount?: number; + sourceType: PaymentSourceType; + sourceId: string; + description?: string; + customerEmail?: string; + customerPhone?: string; +} + +export class PaymentResultDto { + success: boolean; + transactionId?: string; + externalTransactionId?: string; + amount: number; + totalAmount: number; + tipAmount: number; + currency: string; + status: PaymentStatus; + paymentMethod?: PaymentMethod; + cardBrand?: string; + cardLastFour?: string; + receiptUrl?: string; + error?: string; + errorCode?: string; +} + +export class ProcessRefundDto { + transactionId: string; + amount?: number; // Partial refund if provided + reason?: string; +} + +export class RefundResultDto { + success: boolean; + refundId?: string; + amount: number; + status: 'pending' | 'completed' | 'failed'; + error?: string; +} + +export class SendReceiptDto { + email?: string; + phone?: string; +} + +export class TransactionFilterDto { + branchId?: string; + userId?: string; + status?: PaymentStatus; + startDate?: Date; + endDate?: Date; + sourceType?: PaymentSourceType; + terminalProvider?: string; + limit?: number; + offset?: number; +} + +export class TransactionStatsDto { + total: number; + totalAmount: number; + byStatus: Record; + byProvider: Record; + byPaymentMethod: Record; + averageAmount: number; + successRate: number; +} diff --git a/src/modules/payment-terminals/index.ts b/src/modules/payment-terminals/index.ts new file mode 100644 index 0000000..6794513 --- /dev/null +++ b/src/modules/payment-terminals/index.ts @@ -0,0 +1,15 @@ +/** + * Payment Terminals Module Index + */ + +// Module +export { PaymentTerminalsModule, PaymentTerminalsModuleOptions } from './payment-terminals.module'; + +// DTOs +export * from './dto'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/payment-terminals/payment-terminals.module.ts b/src/modules/payment-terminals/payment-terminals.module.ts new file mode 100644 index 0000000..b807407 --- /dev/null +++ b/src/modules/payment-terminals/payment-terminals.module.ts @@ -0,0 +1,46 @@ +/** + * Payment Terminals Module + * + * Module registration for payment terminals and transactions + */ + +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { TerminalsController, TransactionsController } from './controllers'; + +export interface PaymentTerminalsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PaymentTerminalsModule { + public router: Router; + private terminalsController: TerminalsController; + private transactionsController: TransactionsController; + + constructor(options: PaymentTerminalsModuleOptions) { + const { dataSource, basePath = '' } = options; + + this.router = Router(); + + // Initialize controllers + this.terminalsController = new TerminalsController(dataSource); + this.transactionsController = new TransactionsController(dataSource); + + // Register routes + this.router.use(`${basePath}/payment-terminals`, this.terminalsController.router); + this.router.use(`${basePath}/payment-transactions`, this.transactionsController.router); + } + + /** + * Get all entities for this module (for TypeORM configuration) + */ + static getEntities() { + return [ + require('../branches/entities/branch-payment-terminal.entity').BranchPaymentTerminal, + require('../mobile/entities/payment-transaction.entity').PaymentTransaction, + ]; + } +} + +export default PaymentTerminalsModule; diff --git a/src/modules/payment-terminals/services/index.ts b/src/modules/payment-terminals/services/index.ts new file mode 100644 index 0000000..b15f539 --- /dev/null +++ b/src/modules/payment-terminals/services/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals Services Index + */ + +export { TerminalsService } from './terminals.service'; +export { TransactionsService } from './transactions.service'; diff --git a/src/modules/payment-terminals/services/terminals.service.ts b/src/modules/payment-terminals/services/terminals.service.ts new file mode 100644 index 0000000..16ed00e --- /dev/null +++ b/src/modules/payment-terminals/services/terminals.service.ts @@ -0,0 +1,224 @@ +/** + * Terminals Service + * + * Service for managing payment terminals + */ + +import { Repository, DataSource } from 'typeorm'; +import { BranchPaymentTerminal, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity'; +import { CreateTerminalDto, UpdateTerminalDto, TerminalResponseDto } from '../dto'; + +export class TerminalsService { + private terminalRepository: Repository; + + constructor(private dataSource: DataSource) { + this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); + } + + /** + * Create a new terminal + */ + async create(tenantId: string, dto: CreateTerminalDto): Promise { + // If setting as primary, unset other primary terminals for this branch + if (dto.isPrimary) { + await this.terminalRepository.update( + { branchId: dto.branchId, isPrimary: true }, + { isPrimary: false } + ); + } + + const terminal = this.terminalRepository.create({ + branchId: dto.branchId, + terminalProvider: dto.terminalProvider, + terminalId: dto.terminalId, + terminalName: dto.terminalName, + credentials: dto.credentials || {}, + isPrimary: dto.isPrimary || false, + dailyLimit: dto.dailyLimit, + transactionLimit: dto.transactionLimit, + isActive: true, + healthStatus: 'unknown', + }); + + return this.terminalRepository.save(terminal); + } + + /** + * Find terminals by branch + */ + async findByBranch(branchId: string): Promise { + const terminals = await this.terminalRepository.find({ + where: { branchId, isActive: true }, + order: { isPrimary: 'DESC', createdAt: 'ASC' }, + }); + + return terminals.map(this.toResponseDto); + } + + /** + * Find primary terminal for branch + */ + async findPrimaryTerminal(branchId: string): Promise { + return this.terminalRepository.findOne({ + where: { branchId, isPrimary: true, isActive: true }, + }); + } + + /** + * Find terminal by ID + */ + async findById(id: string): Promise { + return this.terminalRepository.findOne({ where: { id } }); + } + + /** + * Update terminal + */ + async update(id: string, dto: UpdateTerminalDto): Promise { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + // If setting as primary, unset other primary terminals for this branch + if (dto.isPrimary && !terminal.isPrimary) { + await this.terminalRepository.update( + { branchId: terminal.branchId, isPrimary: true }, + { isPrimary: false } + ); + } + + Object.assign(terminal, dto); + return this.terminalRepository.save(terminal); + } + + /** + * Delete terminal (soft delete by deactivating) + */ + async delete(id: string): Promise { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + terminal.isActive = false; + await this.terminalRepository.save(terminal); + } + + /** + * Set terminal as primary + */ + async setPrimary(id: string): Promise { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + // Unset other primary terminals + await this.terminalRepository.update( + { branchId: terminal.branchId, isPrimary: true }, + { isPrimary: false } + ); + + terminal.isPrimary = true; + return this.terminalRepository.save(terminal); + } + + /** + * Check terminal health + */ + async checkHealth(id: string): Promise<{ status: HealthStatus; message: string }> { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + // Simulate health check based on provider + // In production, this would make an actual API call to the provider + let status: HealthStatus = 'healthy'; + let message = 'Terminal is operational'; + + try { + // Simulate provider health check + switch (terminal.terminalProvider) { + case 'clip': + // Check Clip terminal status + break; + case 'mercadopago': + // Check MercadoPago terminal status + break; + case 'stripe': + // Check Stripe terminal status + break; + } + } catch (error: any) { + status = 'offline'; + message = error.message || 'Failed to connect to terminal'; + } + + // Update terminal health status + terminal.healthStatus = status; + terminal.lastHealthCheckAt = new Date(); + await this.terminalRepository.save(terminal); + + return { status, message }; + } + + /** + * Update health status (called after transactions) + */ + async updateHealthStatus(id: string, status: HealthStatus): Promise { + await this.terminalRepository.update(id, { + healthStatus: status, + lastHealthCheckAt: new Date(), + }); + } + + /** + * Update last transaction timestamp + */ + async updateLastTransaction(id: string): Promise { + await this.terminalRepository.update(id, { + lastTransactionAt: new Date(), + healthStatus: 'healthy', // If transaction works, terminal is healthy + lastHealthCheckAt: new Date(), + }); + } + + /** + * Find terminals needing health check + */ + async findTerminalsNeedingHealthCheck(maxAgeMinutes: number = 30): Promise { + const threshold = new Date(); + threshold.setMinutes(threshold.getMinutes() - maxAgeMinutes); + + return this.terminalRepository + .createQueryBuilder('terminal') + .where('terminal.isActive = true') + .andWhere( + '(terminal.lastHealthCheckAt IS NULL OR terminal.lastHealthCheckAt < :threshold)', + { threshold } + ) + .getMany(); + } + + /** + * Convert entity to response DTO (without credentials) + */ + private toResponseDto(terminal: BranchPaymentTerminal): TerminalResponseDto { + return { + id: terminal.id, + branchId: terminal.branchId, + terminalProvider: terminal.terminalProvider, + terminalId: terminal.terminalId, + terminalName: terminal.terminalName, + isPrimary: terminal.isPrimary, + isActive: terminal.isActive, + dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : undefined, + transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : undefined, + healthStatus: terminal.healthStatus, + lastTransactionAt: terminal.lastTransactionAt, + lastHealthCheckAt: terminal.lastHealthCheckAt, + }; + } +} diff --git a/src/modules/payment-terminals/services/transactions.service.ts b/src/modules/payment-terminals/services/transactions.service.ts new file mode 100644 index 0000000..ed574d0 --- /dev/null +++ b/src/modules/payment-terminals/services/transactions.service.ts @@ -0,0 +1,498 @@ +/** + * Transactions Service + * + * Service for processing and managing payment transactions + */ + +import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { + PaymentTransaction, + PaymentStatus, + PaymentMethod, +} from '../../mobile/entities/payment-transaction.entity'; +import { BranchPaymentTerminal } from '../../branches/entities/branch-payment-terminal.entity'; +import { + ProcessPaymentDto, + PaymentResultDto, + ProcessRefundDto, + RefundResultDto, + SendReceiptDto, + TransactionFilterDto, + TransactionStatsDto, +} from '../dto'; +import { CircuitBreaker } from '../../../shared/utils/circuit-breaker'; + +export class TransactionsService { + private transactionRepository: Repository; + private terminalRepository: Repository; + private circuitBreakers: Map = new Map(); + + constructor(private dataSource: DataSource) { + this.transactionRepository = dataSource.getRepository(PaymentTransaction); + this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); + } + + /** + * Process a payment + */ + async processPayment( + tenantId: string, + userId: string, + dto: ProcessPaymentDto + ): Promise { + // Get terminal + const terminal = await this.terminalRepository.findOne({ + where: { id: dto.terminalId, isActive: true }, + }); + + if (!terminal) { + return this.errorResult(dto.amount, dto.tipAmount || 0, 'Terminal not found', 'TERMINAL_NOT_FOUND'); + } + + // Check transaction limit + if (terminal.transactionLimit && dto.amount > Number(terminal.transactionLimit)) { + return this.errorResult( + dto.amount, + dto.tipAmount || 0, + `Amount exceeds transaction limit of ${terminal.transactionLimit}`, + 'LIMIT_EXCEEDED' + ); + } + + // Get or create circuit breaker for this terminal + const circuitBreaker = this.getCircuitBreaker(terminal.id); + + // Create transaction record + const transaction = this.transactionRepository.create({ + tenantId, + branchId: terminal.branchId, + userId, + sourceType: dto.sourceType, + sourceId: dto.sourceId, + terminalProvider: terminal.terminalProvider, + terminalId: terminal.terminalId, + amount: dto.amount, + currency: dto.currency || 'MXN', + tipAmount: dto.tipAmount || 0, + totalAmount: dto.amount + (dto.tipAmount || 0), + paymentMethod: 'card', // Default, will be updated from provider response + status: 'pending', + initiatedAt: new Date(), + }); + + await this.transactionRepository.save(transaction); + + try { + // Process through circuit breaker + const providerResult = await circuitBreaker.execute(async () => { + return this.processWithProvider(terminal, transaction, dto); + }); + + // Update transaction with result + transaction.status = providerResult.status; + transaction.externalTransactionId = providerResult.externalTransactionId; + transaction.paymentMethod = providerResult.paymentMethod; + transaction.cardBrand = providerResult.cardBrand; + transaction.cardLastFour = providerResult.cardLastFour; + transaction.receiptUrl = providerResult.receiptUrl; + transaction.providerResponse = providerResult.rawResponse; + + if (providerResult.status === 'completed') { + transaction.completedAt = new Date(); + // Update terminal last transaction + await this.terminalRepository.update(terminal.id, { + lastTransactionAt: new Date(), + healthStatus: 'healthy', + }); + } else if (providerResult.status === 'failed') { + transaction.failureReason = providerResult.error; + } + + await this.transactionRepository.save(transaction); + + return { + success: providerResult.status === 'completed', + transactionId: transaction.id, + externalTransactionId: providerResult.externalTransactionId, + amount: dto.amount, + totalAmount: transaction.totalAmount, + tipAmount: transaction.tipAmount, + currency: transaction.currency, + status: transaction.status, + paymentMethod: transaction.paymentMethod, + cardBrand: transaction.cardBrand, + cardLastFour: transaction.cardLastFour, + receiptUrl: transaction.receiptUrl, + error: providerResult.error, + }; + } catch (error: any) { + // Circuit breaker opened or other error + transaction.status = 'failed'; + transaction.failureReason = error.message; + await this.transactionRepository.save(transaction); + + // Update terminal health + await this.terminalRepository.update(terminal.id, { + healthStatus: 'offline', + lastHealthCheckAt: new Date(), + }); + + return this.errorResult( + dto.amount, + dto.tipAmount || 0, + error.message, + 'PROVIDER_ERROR', + transaction.id + ); + } + } + + /** + * Process refund + */ + async processRefund( + tenantId: string, + userId: string, + dto: ProcessRefundDto + ): Promise { + const transaction = await this.transactionRepository.findOne({ + where: { id: dto.transactionId, tenantId }, + }); + + if (!transaction) { + return { success: false, amount: 0, status: 'failed', error: 'Transaction not found' }; + } + + if (transaction.status !== 'completed') { + return { + success: false, + amount: 0, + status: 'failed', + error: 'Only completed transactions can be refunded', + }; + } + + const refundAmount = dto.amount || Number(transaction.totalAmount); + + if (refundAmount > Number(transaction.totalAmount)) { + return { + success: false, + amount: 0, + status: 'failed', + error: 'Refund amount cannot exceed transaction amount', + }; + } + + try { + // Get terminal for provider info + const terminal = await this.terminalRepository.findOne({ + where: { terminalProvider: transaction.terminalProvider as any }, + }); + + // Process refund with provider + // In production, this would call the actual provider API + const refundResult = await this.processRefundWithProvider(transaction, refundAmount, dto.reason); + + if (refundResult.success) { + transaction.status = 'refunded'; + await this.transactionRepository.save(transaction); + } + + return { + success: refundResult.success, + refundId: refundResult.refundId, + amount: refundAmount, + status: refundResult.success ? 'completed' : 'failed', + error: refundResult.error, + }; + } catch (error: any) { + return { + success: false, + amount: refundAmount, + status: 'failed', + error: error.message, + }; + } + } + + /** + * Get transaction by ID + */ + async findById(id: string, tenantId: string): Promise { + return this.transactionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Get transactions with filters + */ + async findAll( + tenantId: string, + filter: TransactionFilterDto + ): Promise<{ data: PaymentTransaction[]; total: number }> { + const query = this.transactionRepository + .createQueryBuilder('tx') + .where('tx.tenantId = :tenantId', { tenantId }); + + if (filter.branchId) { + query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId }); + } + + if (filter.userId) { + query.andWhere('tx.userId = :userId', { userId: filter.userId }); + } + + if (filter.status) { + query.andWhere('tx.status = :status', { status: filter.status }); + } + + if (filter.sourceType) { + query.andWhere('tx.sourceType = :sourceType', { sourceType: filter.sourceType }); + } + + if (filter.terminalProvider) { + query.andWhere('tx.terminalProvider = :provider', { provider: filter.terminalProvider }); + } + + if (filter.startDate) { + query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate }); + } + + if (filter.endDate) { + query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate }); + } + + const total = await query.getCount(); + + query.orderBy('tx.createdAt', 'DESC'); + + if (filter.limit) { + query.take(filter.limit); + } + + if (filter.offset) { + query.skip(filter.offset); + } + + const data = await query.getMany(); + + return { data, total }; + } + + /** + * Send receipt + */ + async sendReceipt( + transactionId: string, + tenantId: string, + dto: SendReceiptDto + ): Promise<{ success: boolean; error?: string }> { + const transaction = await this.findById(transactionId, tenantId); + if (!transaction) { + return { success: false, error: 'Transaction not found' }; + } + + if (!dto.email && !dto.phone) { + return { success: false, error: 'Email or phone is required' }; + } + + try { + // Send receipt via email or SMS + // In production, this would integrate with email/SMS service + + transaction.receiptSent = true; + transaction.receiptSentTo = dto.email || dto.phone || ''; + await this.transactionRepository.save(transaction); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + + /** + * Get transaction statistics + */ + async getStats(tenantId: string, filter?: TransactionFilterDto): Promise { + const query = this.transactionRepository + .createQueryBuilder('tx') + .where('tx.tenantId = :tenantId', { tenantId }); + + if (filter?.branchId) { + query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId }); + } + + if (filter?.startDate) { + query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate }); + } + + if (filter?.endDate) { + query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate }); + } + + const transactions = await query.getMany(); + + const byStatus: Record = { + pending: 0, + processing: 0, + completed: 0, + failed: 0, + refunded: 0, + cancelled: 0, + }; + + const byProvider: Record = {}; + const byPaymentMethod: Record = { + card: 0, + contactless: 0, + qr: 0, + link: 0, + }; + + let totalAmount = 0; + let completedCount = 0; + + for (const tx of transactions) { + byStatus[tx.status]++; + + if (!byProvider[tx.terminalProvider]) { + byProvider[tx.terminalProvider] = { count: 0, amount: 0 }; + } + byProvider[tx.terminalProvider].count++; + + if (tx.status === 'completed') { + totalAmount += Number(tx.totalAmount); + completedCount++; + byProvider[tx.terminalProvider].amount += Number(tx.totalAmount); + byPaymentMethod[tx.paymentMethod]++; + } + } + + const total = transactions.length; + const failedCount = byStatus.failed; + + return { + total, + totalAmount, + byStatus, + byProvider, + byPaymentMethod, + averageAmount: completedCount > 0 ? totalAmount / completedCount : 0, + successRate: total > 0 ? ((total - failedCount) / total) * 100 : 0, + }; + } + + /** + * Get or create circuit breaker for terminal + */ + private getCircuitBreaker(terminalId: string): CircuitBreaker { + if (!this.circuitBreakers.has(terminalId)) { + this.circuitBreakers.set( + terminalId, + new CircuitBreaker({ + name: `terminal-${terminalId}`, + failureThreshold: 3, + successThreshold: 2, + resetTimeout: 30000, // 30 seconds + }) + ); + } + return this.circuitBreakers.get(terminalId)!; + } + + /** + * Process payment with provider (simulated) + */ + private async processWithProvider( + terminal: BranchPaymentTerminal, + transaction: PaymentTransaction, + dto: ProcessPaymentDto + ): Promise<{ + status: PaymentStatus; + externalTransactionId?: string; + paymentMethod?: PaymentMethod; + cardBrand?: string; + cardLastFour?: string; + receiptUrl?: string; + rawResponse?: Record; + error?: string; + }> { + // In production, this would call the actual provider API + // For now, simulate a successful transaction + + // Simulate processing time + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Simulate success rate (95%) + const success = Math.random() > 0.05; + + if (success) { + return { + status: 'completed', + externalTransactionId: `${terminal.terminalProvider}-${Date.now()}`, + paymentMethod: 'card', + cardBrand: 'visa', + cardLastFour: '4242', + receiptUrl: `https://receipts.example.com/${transaction.id}`, + rawResponse: { + provider: terminal.terminalProvider, + approved: true, + timestamp: new Date().toISOString(), + }, + }; + } else { + return { + status: 'failed', + error: 'Payment declined by issuer', + rawResponse: { + provider: terminal.terminalProvider, + approved: false, + declineReason: 'insufficient_funds', + }, + }; + } + } + + /** + * Process refund with provider (simulated) + */ + private async processRefundWithProvider( + transaction: PaymentTransaction, + amount: number, + reason?: string + ): Promise<{ success: boolean; refundId?: string; error?: string }> { + // In production, this would call the actual provider API + + // Simulate processing + await new Promise((resolve) => setTimeout(resolve, 300)); + + return { + success: true, + refundId: `ref-${Date.now()}`, + }; + } + + /** + * Create error result + */ + private errorResult( + amount: number, + tipAmount: number, + error: string, + errorCode: string, + transactionId?: string + ): PaymentResultDto { + return { + success: false, + transactionId, + amount, + totalAmount: amount + tipAmount, + tipAmount, + currency: 'MXN', + status: 'failed', + error, + errorCode, + }; + } +} diff --git a/src/modules/products/controllers/index.ts b/src/modules/products/controllers/index.ts new file mode 100644 index 0000000..7e3e542 --- /dev/null +++ b/src/modules/products/controllers/index.ts @@ -0,0 +1 @@ +export { ProductsController, CategoriesController } from './products.controller'; diff --git a/src/modules/products/controllers/products.controller.ts b/src/modules/products/controllers/products.controller.ts new file mode 100644 index 0000000..770cd5c --- /dev/null +++ b/src/modules/products/controllers/products.controller.ts @@ -0,0 +1,377 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { ProductsService } from '../services/products.service'; +import { + CreateProductDto, + UpdateProductDto, + CreateProductCategoryDto, + UpdateProductCategoryDto, +} from '../dto'; + +export class ProductsController { + public router: Router; + + constructor(private readonly productsService: ProductsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Products + this.router.get('/', this.findAll.bind(this)); + this.router.get('/sellable', this.getSellableProducts.bind(this)); + this.router.get('/purchasable', this.getPurchasableProducts.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/sku/:sku', this.findBySku.bind(this)); + this.router.get('/barcode/:barcode', this.findByBarcode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { + search, + categoryId, + productType, + isActive, + isSellable, + isPurchasable, + limit, + offset, + } = req.query; + + const result = await this.productsService.findAll({ + tenantId, + search: search as string, + categoryId: categoryId as string, + productType: productType as 'product' | 'service' | 'consumable' | 'kit', + isActive: isActive ? isActive === 'true' : undefined, + isSellable: isSellable ? isSellable === 'true' : undefined, + isPurchasable: isPurchasable ? isPurchasable === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const product = await this.productsService.findOne(id, tenantId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async findBySku(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { sku } = req.params; + const product = await this.productsService.findBySku(sku, tenantId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async findByBarcode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { barcode } = req.params; + const product = await this.productsService.findByBarcode(barcode, tenantId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateProductDto = req.body; + const product = await this.productsService.create(tenantId, dto, userId); + res.status(201).json({ data: product }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateProductDto = req.body; + const product = await this.productsService.update(id, tenantId, dto, userId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.productsService.delete(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getSellableProducts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const products = await this.productsService.getSellableProducts(tenantId); + res.json({ data: products }); + } catch (error) { + next(error); + } + } + + private async getPurchasableProducts( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const products = await this.productsService.getPurchasableProducts(tenantId); + res.json({ data: products }); + } catch (error) { + next(error); + } + } +} + +export class CategoriesController { + public router: Router; + + constructor(private readonly productsService: ProductsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/', this.findAll.bind(this)); + this.router.get('/tree', this.getCategoryTree.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { search, parentId, isActive, limit, offset } = req.query; + + const result = await this.productsService.findAllCategories({ + tenantId, + search: search as string, + parentId: parentId as string, + isActive: isActive ? isActive === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const category = await this.productsService.findCategory(id, tenantId); + + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + res.json({ data: category }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateProductCategoryDto = req.body; + const category = await this.productsService.createCategory(tenantId, dto); + res.status(201).json({ data: category }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateProductCategoryDto = req.body; + const category = await this.productsService.updateCategory(id, tenantId, dto); + + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + res.json({ data: category }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.productsService.deleteCategory(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getCategoryTree(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const categories = await this.productsService.getCategoryTree(tenantId); + res.json({ data: categories }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/products/dto/create-product.dto.ts b/src/modules/products/dto/create-product.dto.ts new file mode 100644 index 0000000..b398408 --- /dev/null +++ b/src/modules/products/dto/create-product.dto.ts @@ -0,0 +1,431 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsUUID, + MaxLength, + IsEnum, + Min, +} from 'class-validator'; + +export class CreateProductCategoryDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateProductCategoryDto { + @IsOptional() + @IsString() + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateProductDto { + @IsString() + @MaxLength(50) + sku: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(['product', 'service', 'consumable', 'kit']) + productType?: 'product' | 'service' | 'consumable' | 'kit'; + + @IsOptional() + @IsNumber() + @Min(0) + salePrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + costPrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minSalePrice?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsNumber() + taxRate?: number; + + @IsOptional() + @IsBoolean() + taxIncluded?: boolean; + + @IsOptional() + @IsString() + @MaxLength(20) + satProductCode?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + satUnitCode?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uom?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uomPurchase?: string; + + @IsOptional() + @IsNumber() + conversionFactor?: number; + + @IsOptional() + @IsBoolean() + trackInventory?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + minStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderPoint?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderQuantity?: number; + + @IsOptional() + @IsBoolean() + trackLots?: boolean; + + @IsOptional() + @IsBoolean() + trackSerials?: boolean; + + @IsOptional() + @IsBoolean() + trackExpiry?: boolean; + + @IsOptional() + @IsNumber() + weight?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + weightUnit?: string; + + @IsOptional() + @IsNumber() + length?: number; + + @IsOptional() + @IsNumber() + width?: number; + + @IsOptional() + @IsNumber() + height?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + dimensionUnit?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + images?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isSellable?: boolean; + + @IsOptional() + @IsBoolean() + isPurchasable?: boolean; +} + +export class UpdateProductDto { + @IsOptional() + @IsString() + @MaxLength(50) + sku?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(['product', 'service', 'consumable', 'kit']) + productType?: 'product' | 'service' | 'consumable' | 'kit'; + + @IsOptional() + @IsNumber() + @Min(0) + salePrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + costPrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minSalePrice?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsNumber() + taxRate?: number; + + @IsOptional() + @IsBoolean() + taxIncluded?: boolean; + + @IsOptional() + @IsString() + @MaxLength(20) + satProductCode?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + satUnitCode?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uom?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uomPurchase?: string; + + @IsOptional() + @IsNumber() + conversionFactor?: number; + + @IsOptional() + @IsBoolean() + trackInventory?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + minStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderPoint?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderQuantity?: number; + + @IsOptional() + @IsBoolean() + trackLots?: boolean; + + @IsOptional() + @IsBoolean() + trackSerials?: boolean; + + @IsOptional() + @IsBoolean() + trackExpiry?: boolean; + + @IsOptional() + @IsNumber() + weight?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + weightUnit?: string; + + @IsOptional() + @IsNumber() + length?: number; + + @IsOptional() + @IsNumber() + width?: number; + + @IsOptional() + @IsNumber() + height?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + dimensionUnit?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + images?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isSellable?: boolean; + + @IsOptional() + @IsBoolean() + isPurchasable?: boolean; +} diff --git a/src/modules/products/dto/index.ts b/src/modules/products/dto/index.ts new file mode 100644 index 0000000..94bf432 --- /dev/null +++ b/src/modules/products/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateProductCategoryDto, + UpdateProductCategoryDto, + CreateProductDto, + UpdateProductDto, +} from './create-product.dto'; diff --git a/src/modules/products/entities/index.ts b/src/modules/products/entities/index.ts new file mode 100644 index 0000000..1471528 --- /dev/null +++ b/src/modules/products/entities/index.ts @@ -0,0 +1,4 @@ +export { ProductCategory } from './product-category.entity'; +export { Product } from './product.entity'; +export { ProductPrice } from './product-price.entity'; +export { ProductSupplier } from './product-supplier.entity'; diff --git a/src/modules/products/entities/product-category.entity.ts b/src/modules/products/entities/product-category.entity.ts new file mode 100644 index 0000000..4de6df7 --- /dev/null +++ b/src/modules/products/entities/product-category.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +@Entity({ name: 'product_categories', schema: 'products' }) +export class ProductCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: ProductCategory; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Imagen + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + // Orden + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/products/entities/product-price.entity.ts b/src/modules/products/entities/product-price.entity.ts new file mode 100644 index 0000000..c768e2b --- /dev/null +++ b/src/modules/products/entities/product-price.entity.ts @@ -0,0 +1,48 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_prices', schema: 'products' }) +export class ProductPrice { + @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: 'price_type', type: 'varchar', length: 30, default: 'standard' }) + priceType: 'standard' | 'wholesale' | 'retail' | 'promo'; + + @Column({ name: 'price_list_name', type: 'varchar', length: 100, nullable: true }) + priceListName?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4 }) + price: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_quantity', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minQuantity: number; + + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_to', type: 'timestamptz', nullable: true }) + validTo?: Date; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/products/entities/product-supplier.entity.ts b/src/modules/products/entities/product-supplier.entity.ts new file mode 100644 index 0000000..0cfbe24 --- /dev/null +++ b/src/modules/products/entities/product-supplier.entity.ts @@ -0,0 +1,51 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_suppliers', schema: 'products' }) +export class ProductSupplier { + @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: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_sku', type: 'varchar', length: 50, nullable: true }) + supplierSku?: string; + + @Column({ name: 'supplier_name', type: 'varchar', length: 200, nullable: true }) + supplierName?: string; + + @Column({ name: 'purchase_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + purchasePrice?: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_order_qty', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minOrderQty: number; + + @Column({ name: 'lead_time_days', type: 'int', default: 0 }) + leadTimeDays: number; + + @Index() + @Column({ name: 'is_preferred', type: 'boolean', default: false }) + isPreferred: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts new file mode 100644 index 0000000..51d08e0 --- /dev/null +++ b/src/modules/products/entities/product.entity.ts @@ -0,0 +1,176 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProductCategory } from './product-category.entity'; + +@Entity({ name: 'products', schema: 'products' }) +export class Product { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'category_id' }) + category: ProductCategory; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 50 }) + sku: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'product_type', type: 'varchar', length: 20, default: 'product' }) + productType: 'product' | 'service' | 'consumable' | 'kit'; + + // Precios + @Column({ name: 'sale_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + salePrice: number; + + @Column({ name: 'cost_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + costPrice: number; + + @Column({ name: 'min_sale_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + minSalePrice: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Impuestos + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16 }) + taxRate: number; + + @Column({ name: 'tax_included', type: 'boolean', default: false }) + taxIncluded: boolean; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode: string; + + // Unidad de medida + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'uom_purchase', type: 'varchar', length: 20, nullable: true }) + uomPurchase: string; + + @Column({ name: 'conversion_factor', type: 'decimal', precision: 10, scale: 4, default: 1 }) + conversionFactor: number; + + // Inventario + @Column({ name: 'track_inventory', type: 'boolean', default: true }) + trackInventory: boolean; + + @Column({ name: 'min_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + minStock: number; + + @Column({ name: 'max_stock', type: 'decimal', precision: 15, scale: 4, nullable: true }) + maxStock: number; + + @Column({ name: 'reorder_point', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderPoint: number; + + @Column({ name: 'reorder_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderQuantity: number; + + // Lotes y series + @Column({ name: 'track_lots', type: 'boolean', default: false }) + trackLots: boolean; + + @Column({ name: 'track_serials', type: 'boolean', default: false }) + trackSerials: boolean; + + @Column({ name: 'track_expiry', type: 'boolean', default: false }) + trackExpiry: boolean; + + // Dimensiones + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + weight: number; + + @Column({ name: 'weight_unit', type: 'varchar', length: 10, default: 'kg' }) + weightUnit: string; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + length: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + width: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + height: number; + + @Column({ name: 'dimension_unit', type: 'varchar', length: 10, default: 'cm' }) + dimensionUnit: string; + + // Imagenes + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ type: 'text', array: true, default: '{}' }) + images: string[]; + + // Tags + @Column({ type: 'text', array: true, default: '{}' }) + tags: string[]; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_sellable', type: 'boolean', default: true }) + isSellable: boolean; + + @Column({ name: 'is_purchasable', type: 'boolean', default: true }) + isPurchasable: boolean; + + // 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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/products/index.ts b/src/modules/products/index.ts new file mode 100644 index 0000000..0b7ab91 --- /dev/null +++ b/src/modules/products/index.ts @@ -0,0 +1,5 @@ +export { ProductsModule, ProductsModuleOptions } from './products.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts new file mode 100644 index 0000000..a1d90d4 --- /dev/null +++ b/src/modules/products/products.module.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ProductsService } from './services'; +import { ProductsController, CategoriesController } from './controllers'; +import { Product, ProductCategory } from './entities'; + +export interface ProductsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ProductsModule { + public router: Router; + public productsService: ProductsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: ProductsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const productRepository = this.dataSource.getRepository(Product); + const categoryRepository = this.dataSource.getRepository(ProductCategory); + + this.productsService = new ProductsService(productRepository, categoryRepository); + } + + private initializeRoutes(): void { + const productsController = new ProductsController(this.productsService); + const categoriesController = new CategoriesController(this.productsService); + + this.router.use(`${this.basePath}/products`, productsController.router); + this.router.use(`${this.basePath}/categories`, categoriesController.router); + } + + static getEntities(): Function[] { + return [Product, ProductCategory]; + } +} diff --git a/src/modules/products/services/index.ts b/src/modules/products/services/index.ts new file mode 100644 index 0000000..33a92cf --- /dev/null +++ b/src/modules/products/services/index.ts @@ -0,0 +1 @@ +export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service'; diff --git a/src/modules/products/services/products.service.ts b/src/modules/products/services/products.service.ts new file mode 100644 index 0000000..ee32e64 --- /dev/null +++ b/src/modules/products/services/products.service.ts @@ -0,0 +1,328 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Product, ProductCategory } from '../entities'; +import { + CreateProductDto, + UpdateProductDto, + CreateProductCategoryDto, + UpdateProductCategoryDto, +} from '../dto'; + +export interface ProductSearchParams { + tenantId: string; + search?: string; + categoryId?: string; + productType?: 'product' | 'service' | 'consumable' | 'kit'; + isActive?: boolean; + isSellable?: boolean; + isPurchasable?: boolean; + limit?: number; + offset?: number; +} + +export interface CategorySearchParams { + tenantId: string; + search?: string; + parentId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export class ProductsService { + constructor( + private readonly productRepository: Repository, + private readonly categoryRepository: Repository + ) {} + + // ==================== Products ==================== + + async findAll(params: ProductSearchParams): Promise<{ data: Product[]; total: number }> { + const { + tenantId, + search, + categoryId, + productType, + isActive, + isSellable, + isPurchasable, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (categoryId) { + baseWhere.categoryId = categoryId; + } + + if (productType) { + baseWhere.productType = productType; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (isSellable !== undefined) { + baseWhere.isSellable = isSellable; + } + + if (isPurchasable !== undefined) { + baseWhere.isPurchasable = isPurchasable; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, sku: ILike(`%${search}%`) }, + { ...baseWhere, barcode: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.productRepository.findAndCount({ + where, + relations: ['category'], + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { id, tenantId }, + relations: ['category'], + }); + } + + async findBySku(sku: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { sku, tenantId }, + relations: ['category'], + }); + } + + async findByBarcode(barcode: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { barcode, tenantId }, + relations: ['category'], + }); + } + + async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise { + // Check for existing SKU + const existingSku = await this.findBySku(dto.sku, tenantId); + if (existingSku) { + throw new Error('A product with this SKU already exists'); + } + + // Check for existing barcode + if (dto.barcode) { + const existingBarcode = await this.findByBarcode(dto.barcode, tenantId); + if (existingBarcode) { + throw new Error('A product with this barcode already exists'); + } + } + + const product = this.productRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.productRepository.save(product); + } + + async update( + id: string, + tenantId: string, + dto: UpdateProductDto, + updatedBy?: string + ): Promise { + const product = await this.findOne(id, tenantId); + if (!product) return null; + + // If changing SKU, check for duplicates + if (dto.sku && dto.sku !== product.sku) { + const existing = await this.findBySku(dto.sku, tenantId); + if (existing) { + throw new Error('A product with this SKU already exists'); + } + } + + // If changing barcode, check for duplicates + if (dto.barcode && dto.barcode !== product.barcode) { + const existing = await this.findByBarcode(dto.barcode, tenantId); + if (existing && existing.id !== id) { + throw new Error('A product with this barcode already exists'); + } + } + + Object.assign(product, { + ...dto, + updatedBy, + }); + + return this.productRepository.save(product); + } + + async delete(id: string, tenantId: string): Promise { + const product = await this.findOne(id, tenantId); + if (!product) return false; + + const result = await this.productRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getSellableProducts(tenantId: string): Promise { + return this.productRepository.find({ + where: { tenantId, isActive: true, isSellable: true }, + relations: ['category'], + order: { name: 'ASC' }, + }); + } + + async getPurchasableProducts(tenantId: string): Promise { + return this.productRepository.find({ + where: { tenantId, isActive: true, isPurchasable: true }, + relations: ['category'], + order: { name: 'ASC' }, + }); + } + + // ==================== Categories ==================== + + async findAllCategories( + params: CategorySearchParams + ): Promise<{ data: ProductCategory[]; total: number }> { + const { tenantId, search, parentId, isActive, limit = 100, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (parentId !== undefined) { + baseWhere.parentId = parentId || undefined; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.categoryRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + + return { data, total }; + } + + async findCategory(id: string, tenantId: string): Promise { + return this.categoryRepository.findOne({ where: { id, tenantId } }); + } + + async findCategoryByCode(code: string, tenantId: string): Promise { + return this.categoryRepository.findOne({ where: { code, tenantId } }); + } + + async createCategory( + tenantId: string, + dto: CreateProductCategoryDto + ): Promise { + // Check for existing code + const existingCode = await this.findCategoryByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A category with this code already exists'); + } + + // Calculate hierarchy if parent exists + let hierarchyPath = `/${dto.code}`; + let hierarchyLevel = 0; + + if (dto.parentId) { + const parent = await this.findCategory(dto.parentId, tenantId); + if (parent) { + hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; + hierarchyLevel = parent.hierarchyLevel + 1; + } + } + + const category = this.categoryRepository.create({ + ...dto, + tenantId, + hierarchyPath, + hierarchyLevel, + }); + + return this.categoryRepository.save(category); + } + + async updateCategory( + id: string, + tenantId: string, + dto: UpdateProductCategoryDto + ): Promise { + const category = await this.findCategory(id, tenantId); + if (!category) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== category.code) { + const existing = await this.findCategoryByCode(dto.code, tenantId); + if (existing) { + throw new Error('A category with this code already exists'); + } + } + + Object.assign(category, dto); + return this.categoryRepository.save(category); + } + + async deleteCategory(id: string, tenantId: string): Promise { + const category = await this.findCategory(id, tenantId); + if (!category) return false; + + // Check if category has children + const children = await this.categoryRepository.findOne({ + where: { parentId: id, tenantId }, + }); + if (children) { + throw new Error('Cannot delete category with children'); + } + + // Check if category has products + const products = await this.productRepository.findOne({ + where: { categoryId: id, tenantId }, + }); + if (products) { + throw new Error('Cannot delete category with products'); + } + + const result = await this.categoryRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getCategoryTree(tenantId: string): Promise { + const categories = await this.categoryRepository.find({ + where: { tenantId, isActive: true }, + order: { hierarchyPath: 'ASC', sortOrder: 'ASC' }, + }); + + return categories; + } +} diff --git a/src/modules/profiles/controllers/index.ts b/src/modules/profiles/controllers/index.ts new file mode 100644 index 0000000..e292287 --- /dev/null +++ b/src/modules/profiles/controllers/index.ts @@ -0,0 +1,2 @@ +export { ProfilesController } from './profiles.controller'; +export { PersonsController } from './persons.controller'; diff --git a/src/modules/profiles/controllers/persons.controller.ts b/src/modules/profiles/controllers/persons.controller.ts new file mode 100644 index 0000000..3c2e91a --- /dev/null +++ b/src/modules/profiles/controllers/persons.controller.ts @@ -0,0 +1,180 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { PersonsService } from '../services/persons.service'; +import { CreatePersonDto, UpdatePersonDto } from '../dto'; + +export class PersonsController { + public router: Router; + + constructor(private readonly personsService: PersonsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/', this.findAll.bind(this)); + this.router.get('/responsible', this.getResponsiblePersons.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/email/:email', this.findByEmail.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/verify', this.verify.bind(this)); + this.router.post('/:id/unverify', this.unverify.bind(this)); + this.router.post('/:id/set-responsible', this.setAsResponsible.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const { search, email, isVerified, isResponsibleForTenant, limit, offset } = req.query; + + const result = await this.personsService.findAll({ + search: search as string, + email: email as string, + isVerified: isVerified ? isVerified === 'true' : undefined, + isResponsibleForTenant: isResponsibleForTenant ? isResponsibleForTenant === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const person = await this.personsService.findOne(id); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async findByEmail(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email } = req.params; + const person = await this.personsService.findByEmail(email); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreatePersonDto = req.body; + const person = await this.personsService.create(dto); + res.status(201).json({ data: person }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: UpdatePersonDto = req.body; + const person = await this.personsService.update(id, dto); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.personsService.delete(id); + + if (!deleted) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async verify(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const verifiedBy = req.headers['x-user-id'] as string; + + const person = await this.personsService.verify(id, verifiedBy); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async unverify(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const person = await this.personsService.unverify(id); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async setAsResponsible(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { isResponsible } = req.body; + + const person = await this.personsService.setAsResponsible(id, isResponsible ?? true); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async getResponsiblePersons(req: Request, res: Response, next: NextFunction): Promise { + try { + const persons = await this.personsService.getResponsiblePersons(); + res.json({ data: persons }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/profiles/controllers/profiles.controller.ts b/src/modules/profiles/controllers/profiles.controller.ts new file mode 100644 index 0000000..afa2fd9 --- /dev/null +++ b/src/modules/profiles/controllers/profiles.controller.ts @@ -0,0 +1,281 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { ProfilesService } from '../services/profiles.service'; +import { CreateProfileDto, UpdateProfileDto, AssignProfileDto, CreateProfileToolDto } from '../dto'; + +export class ProfilesController { + public router: Router; + + constructor(private readonly profilesService: ProfilesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Profiles CRUD + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Profile Tools + this.router.get('/:id/tools', this.getTools.bind(this)); + this.router.post('/:id/tools', this.addTool.bind(this)); + this.router.delete('/:id/tools/:toolCode', this.removeTool.bind(this)); + + // User Profile Assignments + this.router.post('/assign', this.assignProfile.bind(this)); + this.router.delete('/assign/:userId/:profileId', this.unassignProfile.bind(this)); + this.router.get('/user/:userId', this.getUserProfiles.bind(this)); + this.router.get('/user/:userId/primary', this.getPrimaryProfile.bind(this)); + this.router.get('/user/:userId/tools', this.getUserTools.bind(this)); + + // Permissions Check + this.router.get('/user/:userId/module/:moduleCode/access', this.checkModuleAccess.bind(this)); + this.router.get('/user/:userId/platform/:platform/access', this.checkPlatformAccess.bind(this)); + } + + // ============================================ + // PROFILES CRUD + // ============================================ + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const profiles = await this.profilesService.findAll(tenantId); + res.json({ data: profiles, total: profiles.length }); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const profile = await this.profilesService.findOne(id); + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const profile = await this.profilesService.findByCode(code, tenantId); + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const dto: CreateProfileDto = req.body; + + const profile = await this.profilesService.create(tenantId, dto, userId); + res.status(201).json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const dto: UpdateProfileDto = req.body; + + const profile = await this.profilesService.update(id, dto, userId); + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.profilesService.delete(id); + + if (!deleted) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // PROFILE TOOLS + // ============================================ + + private async getTools(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tools = await this.profilesService.getToolsForProfile(id); + res.json({ data: tools }); + } catch (error) { + next(error); + } + } + + private async addTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreateProfileToolDto = req.body; + + const tool = await this.profilesService.addTool(id, dto); + res.status(201).json({ data: tool }); + } catch (error) { + next(error); + } + } + + private async removeTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id, toolCode } = req.params; + const removed = await this.profilesService.removeTool(id, toolCode); + + if (!removed) { + res.status(404).json({ error: 'Tool not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // USER PROFILE ASSIGNMENTS + // ============================================ + + private async assignProfile(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const dto: AssignProfileDto = req.body; + + const assignment = await this.profilesService.assignProfile(dto, userId); + res.status(201).json({ data: assignment }); + } catch (error) { + next(error); + } + } + + private async unassignProfile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, profileId } = req.params; + const unassigned = await this.profilesService.unassignProfile(userId, profileId); + + if (!unassigned) { + res.status(404).json({ error: 'Assignment not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getUserProfiles(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const profiles = await this.profilesService.getUserProfiles(userId); + res.json({ data: profiles }); + } catch (error) { + next(error); + } + } + + private async getPrimaryProfile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const profile = await this.profilesService.getPrimaryProfile(userId); + + if (!profile) { + res.status(404).json({ error: 'No primary profile found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async getUserTools(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const platform = req.query.platform as string | undefined; + const tools = await this.profilesService.getToolsForUser(userId, platform); + res.json({ data: tools }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PERMISSIONS CHECK + // ============================================ + + private async checkModuleAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, moduleCode } = req.params; + const hasAccess = await this.profilesService.hasModuleAccess(userId, moduleCode); + const accessLevel = await this.profilesService.getModuleAccessLevel(userId, moduleCode); + + res.json({ + data: { + hasAccess, + accessLevel, + }, + }); + } catch (error) { + next(error); + } + } + + private async checkPlatformAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, platform } = req.params; + const hasAccess = await this.profilesService.hasPlatformAccess(userId, platform); + + res.json({ + data: { + hasAccess, + platform, + }, + }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/profiles/dto/create-person.dto.ts b/src/modules/profiles/dto/create-person.dto.ts new file mode 100644 index 0000000..65086df --- /dev/null +++ b/src/modules/profiles/dto/create-person.dto.ts @@ -0,0 +1,135 @@ +import { IsString, IsOptional, IsBoolean, IsEmail, IsObject, MaxLength, IsDateString } from 'class-validator'; + +export class CreatePersonDto { + @IsString() + @MaxLength(200) + fullName: string; + + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + maternalName?: string; + + @IsEmail() + email: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + mobilePhone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationType?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationNumber?: string; + + @IsOptional() + @IsDateString() + identificationExpiry?: string; + + @IsOptional() + @IsObject() + address?: { + street?: string; + number?: string; + interior?: string; + neighborhood?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + + @IsOptional() + @IsBoolean() + isResponsibleForTenant?: boolean; +} + +export class UpdatePersonDto { + @IsOptional() + @IsString() + @MaxLength(200) + fullName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + maternalName?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + mobilePhone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationType?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationNumber?: string; + + @IsOptional() + @IsDateString() + identificationExpiry?: string; + + @IsOptional() + @IsObject() + address?: { + street?: string; + number?: string; + interior?: string; + neighborhood?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; +} + +export class VerifyPersonDto { + @IsOptional() + @IsString() + verifiedBy?: string; +} diff --git a/src/modules/profiles/dto/create-profile.dto.ts b/src/modules/profiles/dto/create-profile.dto.ts new file mode 100644 index 0000000..24fc991 --- /dev/null +++ b/src/modules/profiles/dto/create-profile.dto.ts @@ -0,0 +1,165 @@ +import { IsString, IsOptional, IsBoolean, IsNumber, IsArray, IsObject, MaxLength, MinLength, IsUUID } from 'class-validator'; + +export class CreateProfileDto { + @IsString() + @MinLength(2) + @MaxLength(10) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isSystem?: boolean; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + basePermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + availableModules?: string[]; + + @IsOptional() + @IsNumber() + monthlyPrice?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + includedPlatforms?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultTools?: string[]; + + @IsOptional() + @IsObject() + featureFlags?: Record; +} + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + basePermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + availableModules?: string[]; + + @IsOptional() + @IsNumber() + monthlyPrice?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + includedPlatforms?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultTools?: string[]; + + @IsOptional() + @IsObject() + featureFlags?: Record; +} + +export class AssignProfileDto { + @IsUUID() + userId: string; + + @IsUUID() + profileId: string; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; + + @IsOptional() + @IsString() + expiresAt?: string; +} + +export class CreateProfileToolDto { + @IsString() + @MaxLength(50) + toolCode: string; + + @IsString() + @MaxLength(100) + toolName: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsBoolean() + isMobileOnly?: boolean; + + @IsOptional() + @IsBoolean() + isWebOnly?: boolean; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsObject() + configuration?: Record; + + @IsOptional() + @IsNumber() + sortOrder?: number; +} diff --git a/src/modules/profiles/dto/index.ts b/src/modules/profiles/dto/index.ts new file mode 100644 index 0000000..0070356 --- /dev/null +++ b/src/modules/profiles/dto/index.ts @@ -0,0 +1,12 @@ +export { + CreateProfileDto, + UpdateProfileDto, + AssignProfileDto, + CreateProfileToolDto, +} from './create-profile.dto'; + +export { + CreatePersonDto, + UpdatePersonDto, + VerifyPersonDto, +} from './create-person.dto'; diff --git a/src/modules/profiles/entities/index.ts b/src/modules/profiles/entities/index.ts new file mode 100644 index 0000000..780b1e7 --- /dev/null +++ b/src/modules/profiles/entities/index.ts @@ -0,0 +1,5 @@ +export { Person } from './person.entity'; +export { UserProfile } from './user-profile.entity'; +export { ProfileTool } from './profile-tool.entity'; +export { ProfileModule } from './profile-module.entity'; +export { UserProfileAssignment } from './user-profile-assignment.entity'; diff --git a/src/modules/profiles/entities/person.entity.ts b/src/modules/profiles/entities/person.entity.ts new file mode 100644 index 0000000..69ee3b5 --- /dev/null +++ b/src/modules/profiles/entities/person.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, +} from 'typeorm'; + +@Entity({ name: 'persons', schema: 'auth' }) +export class Person { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Datos personales + @Column({ name: 'full_name', type: 'varchar', length: 200 }) + fullName: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true }) + lastName: string; + + @Column({ name: 'maternal_name', type: 'varchar', length: 100, nullable: true }) + maternalName: string; + + // Contacto + @Index() + @Column({ type: 'varchar', length: 255 }) + email: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ name: 'mobile_phone', type: 'varchar', length: 20, nullable: true }) + mobilePhone: string; + + // Identificacion oficial + @Column({ name: 'identification_type', type: 'varchar', length: 50, nullable: true }) + identificationType: string; // INE, pasaporte, cedula_profesional + + @Column({ name: 'identification_number', type: 'varchar', length: 50, nullable: true }) + identificationNumber: string; + + @Column({ name: 'identification_expiry', type: 'date', nullable: true }) + identificationExpiry: Date; + + // Direccion + @Column({ type: 'jsonb', default: {} }) + address: { + street?: string; + number?: string; + interior?: string; + neighborhood?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + + // Metadata + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy: string; + + @Column({ name: 'is_responsible_for_tenant', type: 'boolean', default: false }) + isResponsibleForTenant: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/profiles/entities/profile-module.entity.ts b/src/modules/profiles/entities/profile-module.entity.ts new file mode 100644 index 0000000..0895dfb --- /dev/null +++ b/src/modules/profiles/entities/profile-module.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Unique, + Index, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_modules', schema: 'auth' }) +@Unique(['profileId', 'moduleCode']) +export class ProfileModule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'module_code', type: 'varchar', length: 50 }) + moduleCode: string; + + @Column({ name: 'access_level', type: 'varchar', length: 20, default: 'read' }) + accessLevel: 'read' | 'write' | 'admin'; + + @Column({ name: 'can_export', type: 'boolean', default: false }) + canExport: boolean; + + @Column({ name: 'can_print', type: 'boolean', default: true }) + canPrint: boolean; + + // Relaciones + @ManyToOne(() => UserProfile, (profile) => profile.modules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/profile-tool.entity.ts b/src/modules/profiles/entities/profile-tool.entity.ts new file mode 100644 index 0000000..102ffd4 --- /dev/null +++ b/src/modules/profiles/entities/profile-tool.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_tools', schema: 'auth' }) +@Unique(['profileId', 'toolCode']) +export class ProfileTool { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Index() + @Column({ name: 'tool_code', type: 'varchar', length: 50 }) + toolCode: string; + + @Column({ name: 'tool_name', type: 'varchar', length: 100 }) + toolName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'is_mobile_only', type: 'boolean', default: false }) + isMobileOnly: boolean; + + @Column({ name: 'is_web_only', type: 'boolean', default: false }) + isWebOnly: boolean; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ type: 'jsonb', default: {} }) + configuration: Record; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => UserProfile, (profile) => profile.tools, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile-assignment.entity.ts b/src/modules/profiles/entities/user-profile-assignment.entity.ts new file mode 100644 index 0000000..39c6253 --- /dev/null +++ b/src/modules/profiles/entities/user-profile-assignment.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'user_profile_assignments', schema: 'auth' }) +@Unique(['userId', 'profileId']) +export class UserProfileAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' }) + assignedAt: Date; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + // Relaciones + @ManyToOne(() => UserProfile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile.entity.ts b/src/modules/profiles/entities/user-profile.entity.ts new file mode 100644 index 0000000..0b428e1 --- /dev/null +++ b/src/modules/profiles/entities/user-profile.entity.ts @@ -0,0 +1,89 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, + Unique, +} from 'typeorm'; +import { ProfileTool } from './profile-tool.entity'; +import { ProfileModule } from './profile-module.entity'; + +@Entity({ name: 'user_profiles', schema: 'auth' }) +@Unique(['tenantId', 'code']) +export class UserProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 10 }) + code: string; // ADM, CNT, VNT, CMP, ALM, HRH, PRD, EMP, GER, AUD + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + // Permisos base + @Column({ name: 'base_permissions', type: 'jsonb', default: [] }) + basePermissions: string[]; + + @Column({ name: 'available_modules', type: 'text', array: true, default: [] }) + availableModules: string[]; + + // Precios y plataformas + @Column({ name: 'monthly_price', type: 'decimal', precision: 10, scale: 2, default: 0 }) + monthlyPrice: number; + + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; // web, mobile, desktop + + // Configuracion de herramientas + @Column({ name: 'default_tools', type: 'text', array: true, default: [] }) + defaultTools: string[]; + + // Feature flags especificos del perfil + @Column({ name: 'feature_flags', type: 'jsonb', default: {} }) + featureFlags: Record; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @OneToMany(() => ProfileTool, (tool) => tool.profile, { cascade: true }) + tools: ProfileTool[]; + + @OneToMany(() => ProfileModule, (module) => module.profile, { cascade: true }) + modules: ProfileModule[]; +} diff --git a/src/modules/profiles/index.ts b/src/modules/profiles/index.ts new file mode 100644 index 0000000..c1438d2 --- /dev/null +++ b/src/modules/profiles/index.ts @@ -0,0 +1,5 @@ +export { ProfilesModule, ProfilesModuleOptions } from './profiles.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/profiles/profiles.module.ts b/src/modules/profiles/profiles.module.ts new file mode 100644 index 0000000..a25c2ec --- /dev/null +++ b/src/modules/profiles/profiles.module.ts @@ -0,0 +1,55 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ProfilesService, PersonsService } from './services'; +import { ProfilesController, PersonsController } from './controllers'; +import { Person, UserProfile, ProfileTool, ProfileModule, UserProfileAssignment } from './entities'; + +export interface ProfilesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ProfilesModule { + public router: Router; + public profilesService: ProfilesService; + public personsService: PersonsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: ProfilesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const profileRepository = this.dataSource.getRepository(UserProfile); + const toolRepository = this.dataSource.getRepository(ProfileTool); + const moduleRepository = this.dataSource.getRepository(ProfileModule); + const assignmentRepository = this.dataSource.getRepository(UserProfileAssignment); + const personRepository = this.dataSource.getRepository(Person); + + this.profilesService = new ProfilesService( + profileRepository, + toolRepository, + moduleRepository, + assignmentRepository + ); + + this.personsService = new PersonsService(personRepository); + } + + private initializeRoutes(): void { + const profilesController = new ProfilesController(this.profilesService); + const personsController = new PersonsController(this.personsService); + + this.router.use(`${this.basePath}/profiles`, profilesController.router); + this.router.use(`${this.basePath}/persons`, personsController.router); + } + + static getEntities(): Function[] { + return [Person, UserProfile, ProfileTool, ProfileModule, UserProfileAssignment]; + } +} diff --git a/src/modules/profiles/services/index.ts b/src/modules/profiles/services/index.ts new file mode 100644 index 0000000..50793ae --- /dev/null +++ b/src/modules/profiles/services/index.ts @@ -0,0 +1,2 @@ +export { ProfilesService } from './profiles.service'; +export { PersonsService, PersonSearchParams } from './persons.service'; diff --git a/src/modules/profiles/services/persons.service.ts b/src/modules/profiles/services/persons.service.ts new file mode 100644 index 0000000..142df28 --- /dev/null +++ b/src/modules/profiles/services/persons.service.ts @@ -0,0 +1,162 @@ +import { Repository, FindOptionsWhere, Like, ILike } from 'typeorm'; +import { Person } from '../entities'; +import { CreatePersonDto, UpdatePersonDto } from '../dto'; + +export interface PersonSearchParams { + search?: string; + email?: string; + isVerified?: boolean; + isResponsibleForTenant?: boolean; + limit?: number; + offset?: number; +} + +export class PersonsService { + constructor(private readonly personRepository: Repository) {} + + async findAll(params: PersonSearchParams = {}): Promise<{ data: Person[]; total: number }> { + const { search, email, isVerified, isResponsibleForTenant, limit = 50, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = {}; + + if (email) { + baseWhere.email = email; + } + + if (isVerified !== undefined) { + baseWhere.isVerified = isVerified; + } + + if (isResponsibleForTenant !== undefined) { + baseWhere.isResponsibleForTenant = isResponsibleForTenant; + } + + if (search) { + where.push( + { ...baseWhere, fullName: ILike(`%${search}%`) }, + { ...baseWhere, email: ILike(`%${search}%`) }, + { ...baseWhere, identificationNumber: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.personRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fullName: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string): Promise { + return this.personRepository.findOne({ where: { id } }); + } + + async findByEmail(email: string): Promise { + return this.personRepository.findOne({ where: { email } }); + } + + async findByIdentification(type: string, number: string): Promise { + return this.personRepository.findOne({ + where: { identificationType: type, identificationNumber: number }, + }); + } + + async create(dto: CreatePersonDto): Promise { + // Check for existing email + const existing = await this.findByEmail(dto.email); + if (existing) { + throw new Error('A person with this email already exists'); + } + + // Check for existing identification + if (dto.identificationType && dto.identificationNumber) { + const existingId = await this.findByIdentification(dto.identificationType, dto.identificationNumber); + if (existingId) { + throw new Error('A person with this identification already exists'); + } + } + + const person = this.personRepository.create({ + ...dto, + identificationExpiry: dto.identificationExpiry ? new Date(dto.identificationExpiry) : undefined, + }); + + return this.personRepository.save(person); + } + + async update(id: string, dto: UpdatePersonDto): Promise { + const person = await this.findOne(id); + if (!person) return null; + + // If changing email, check for duplicates + if (dto.email && dto.email !== person.email) { + const existing = await this.findByEmail(dto.email); + if (existing) { + throw new Error('A person with this email already exists'); + } + } + + // If changing identification, check for duplicates + if (dto.identificationType && dto.identificationNumber) { + const existingId = await this.findByIdentification(dto.identificationType, dto.identificationNumber); + if (existingId && existingId.id !== id) { + throw new Error('A person with this identification already exists'); + } + } + + Object.assign(person, { + ...dto, + identificationExpiry: dto.identificationExpiry ? new Date(dto.identificationExpiry) : person.identificationExpiry, + }); + + return this.personRepository.save(person); + } + + async delete(id: string): Promise { + const result = await this.personRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async verify(id: string, verifiedBy: string): Promise { + const person = await this.findOne(id); + if (!person) return null; + + person.isVerified = true; + person.verifiedAt = new Date(); + person.verifiedBy = verifiedBy; + + return this.personRepository.save(person); + } + + async unverify(id: string): Promise { + const person = await this.findOne(id); + if (!person) return null; + + person.isVerified = false; + person.verifiedAt = null as any; + person.verifiedBy = null as any; + + return this.personRepository.save(person); + } + + async setAsResponsible(id: string, isResponsible: boolean = true): Promise { + const person = await this.findOne(id); + if (!person) return null; + + person.isResponsibleForTenant = isResponsible; + + return this.personRepository.save(person); + } + + async getResponsiblePersons(): Promise { + return this.personRepository.find({ + where: { isResponsibleForTenant: true }, + order: { fullName: 'ASC' }, + }); + } +} diff --git a/src/modules/profiles/services/profiles.service.ts b/src/modules/profiles/services/profiles.service.ts new file mode 100644 index 0000000..cef72e5 --- /dev/null +++ b/src/modules/profiles/services/profiles.service.ts @@ -0,0 +1,272 @@ +import { Repository, FindOptionsWhere, In } from 'typeorm'; +import { UserProfile, ProfileTool, ProfileModule, UserProfileAssignment } from '../entities'; +import { CreateProfileDto, UpdateProfileDto, AssignProfileDto, CreateProfileToolDto } from '../dto'; + +export class ProfilesService { + constructor( + private readonly profileRepository: Repository, + private readonly toolRepository: Repository, + private readonly moduleRepository: Repository, + private readonly assignmentRepository: Repository + ) {} + + // ============================================ + // PROFILE CRUD + // ============================================ + + async findAll(tenantId?: string): Promise { + const where: FindOptionsWhere = {}; + + if (tenantId) { + // Include system profiles (tenantId = null) and tenant-specific profiles + return this.profileRepository.find({ + where: [{ tenantId }, { tenantId: undefined, isSystem: true }], + relations: ['tools', 'modules'], + order: { code: 'ASC' }, + }); + } + + return this.profileRepository.find({ + relations: ['tools', 'modules'], + order: { code: 'ASC' }, + }); + } + + async findOne(id: string): Promise { + return this.profileRepository.findOne({ + where: { id }, + relations: ['tools', 'modules'], + }); + } + + async findByCode(code: string, tenantId?: string): Promise { + if (tenantId) { + // First try tenant-specific, then system profile + const tenantProfile = await this.profileRepository.findOne({ + where: { code, tenantId }, + relations: ['tools', 'modules'], + }); + + if (tenantProfile) return tenantProfile; + + return this.profileRepository.findOne({ + where: { code, isSystem: true }, + relations: ['tools', 'modules'], + }); + } + + return this.profileRepository.findOne({ + where: { code }, + relations: ['tools', 'modules'], + }); + } + + async create(tenantId: string, dto: CreateProfileDto, createdBy?: string): Promise { + const profile = this.profileRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.profileRepository.save(profile); + } + + async update(id: string, dto: UpdateProfileDto, updatedBy?: string): Promise { + const profile = await this.findOne(id); + if (!profile) return null; + + // Don't allow updating system profiles + if (profile.isSystem) { + throw new Error('Cannot update system profiles'); + } + + Object.assign(profile, dto, { updatedBy }); + return this.profileRepository.save(profile); + } + + async delete(id: string): Promise { + const profile = await this.findOne(id); + if (!profile) return false; + + // Don't allow deleting system profiles + if (profile.isSystem) { + throw new Error('Cannot delete system profiles'); + } + + await this.profileRepository.softDelete(id); + return true; + } + + // ============================================ + // PROFILE TOOLS + // ============================================ + + async addTool(profileId: string, dto: CreateProfileToolDto): Promise { + const tool = this.toolRepository.create({ + ...dto, + profileId, + }); + + return this.toolRepository.save(tool); + } + + async removeTool(profileId: string, toolCode: string): Promise { + const result = await this.toolRepository.delete({ profileId, toolCode }); + return (result.affected ?? 0) > 0; + } + + async getToolsForProfile(profileId: string): Promise { + return this.toolRepository.find({ + where: { profileId, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + async getToolsForUser(userId: string, platform?: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { userId }, + relations: ['profile', 'profile.tools'], + }); + + const allTools: ProfileTool[] = []; + + for (const assignment of assignments) { + if (assignment.profile?.tools) { + for (const tool of assignment.profile.tools) { + if (!tool.isActive) continue; + + // Filter by platform + if (platform === 'mobile' && tool.isWebOnly) continue; + if (platform === 'web' && tool.isMobileOnly) continue; + + allTools.push(tool); + } + } + } + + // Remove duplicates and sort + const uniqueTools = allTools.filter( + (tool, index, self) => index === self.findIndex((t) => t.toolCode === tool.toolCode) + ); + + return uniqueTools.sort((a, b) => a.sortOrder - b.sortOrder); + } + + // ============================================ + // USER PROFILE ASSIGNMENTS + // ============================================ + + async assignProfile(dto: AssignProfileDto, assignedBy?: string): Promise { + // If setting as primary, unset other primary assignments for this user + if (dto.isPrimary) { + await this.assignmentRepository.update({ userId: dto.userId, isPrimary: true }, { isPrimary: false }); + } + + const existing = await this.assignmentRepository.findOne({ + where: { userId: dto.userId, profileId: dto.profileId }, + }); + + if (existing) { + Object.assign(existing, { + isPrimary: dto.isPrimary ?? existing.isPrimary, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : existing.expiresAt, + }); + return this.assignmentRepository.save(existing); + } + + const assignment = this.assignmentRepository.create({ + userId: dto.userId, + profileId: dto.profileId, + isPrimary: dto.isPrimary ?? false, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + assignedBy, + }); + + return this.assignmentRepository.save(assignment); + } + + async unassignProfile(userId: string, profileId: string): Promise { + const result = await this.assignmentRepository.delete({ userId, profileId }); + return (result.affected ?? 0) > 0; + } + + async getUserProfiles(userId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { userId }, + relations: ['profile', 'profile.tools', 'profile.modules'], + }); + + return assignments.map((a) => a.profile).filter((p) => p != null); + } + + async getPrimaryProfile(userId: string): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { userId, isPrimary: true }, + relations: ['profile', 'profile.tools', 'profile.modules'], + }); + + return assignment?.profile ?? null; + } + + async getUsersWithProfile(profileId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { profileId }, + select: ['userId'], + }); + + return assignments.map((a) => a.userId); + } + + // ============================================ + // PERMISSIONS CHECK + // ============================================ + + async hasModuleAccess(userId: string, moduleCode: string): Promise { + const profiles = await this.getUserProfiles(userId); + + for (const profile of profiles) { + // Admin has access to all + if (profile.availableModules.includes('all')) return true; + + // Check specific module + if (profile.availableModules.includes(moduleCode)) return true; + + // Check profile modules with access level + const moduleAccess = profile.modules?.find((m) => m.moduleCode === moduleCode); + if (moduleAccess) return true; + } + + return false; + } + + async getModuleAccessLevel(userId: string, moduleCode: string): Promise { + const profiles = await this.getUserProfiles(userId); + + let highestAccess: string | null = null; + const accessLevels = { read: 1, write: 2, admin: 3 }; + + for (const profile of profiles) { + const moduleAccess = profile.modules?.find((m) => m.moduleCode === moduleCode); + if (moduleAccess) { + const currentLevel = accessLevels[moduleAccess.accessLevel as keyof typeof accessLevels] ?? 0; + const highestLevel = highestAccess ? accessLevels[highestAccess as keyof typeof accessLevels] ?? 0 : 0; + + if (currentLevel > highestLevel) { + highestAccess = moduleAccess.accessLevel; + } + } + } + + return highestAccess; + } + + async hasPlatformAccess(userId: string, platform: string): Promise { + const profiles = await this.getUserProfiles(userId); + + for (const profile of profiles) { + if (profile.includedPlatforms.includes(platform)) return true; + } + + return false; + } +} diff --git a/src/modules/purchases/controllers/index.ts b/src/modules/purchases/controllers/index.ts new file mode 100644 index 0000000..36e07df --- /dev/null +++ b/src/modules/purchases/controllers/index.ts @@ -0,0 +1,89 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { PurchasesService } from '../services'; + +export class PurchasesController { + public router: Router; + constructor(private readonly purchasesService: PurchasesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/confirm', this.confirm.bind(this)); + this.router.post('/:id/receive', this.receive.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { supplierId, status, buyerId, limit, offset } = req.query; + const result = await this.purchasesService.findAll({ tenantId, supplierId: supplierId as string, status: status as string, buyerId: buyerId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.findOne(req.params.id, tenantId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.create(tenantId, req.body, userId); + res.status(201).json({ data: order }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.update(req.params.id, tenantId, req.body, userId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.purchasesService.delete(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async confirm(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.confirmOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot confirm' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async receive(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.receiveOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot receive' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } +} diff --git a/src/modules/purchases/dto/index.ts b/src/modules/purchases/dto/index.ts new file mode 100644 index 0000000..1e79596 --- /dev/null +++ b/src/modules/purchases/dto/index.ts @@ -0,0 +1,39 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator'; + +export class CreatePurchaseOrderDto { + @IsUUID() supplierId: string; + @IsOptional() @IsString() @MaxLength(200) supplierName?: string; + @IsOptional() @IsString() supplierEmail?: string; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() expectedDate?: string; + @IsOptional() @IsUUID() buyerId?: string; + @IsOptional() @IsUUID() warehouseId?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() @MaxLength(10) incoterm?: string; + @IsOptional() @IsString() supplierReference?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() items?: CreatePurchaseItemDto[]; +} + +export class CreatePurchaseItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsOptional() @IsString() @MaxLength(50) supplierSku?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdatePurchaseOrderDto { + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() expectedDate?: string; + @IsOptional() @IsUUID() warehouseId?: string; + @IsOptional() @IsString() supplierReference?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'sent', 'confirmed', 'partial', 'received', 'cancelled']) status?: string; +} diff --git a/src/modules/purchases/entities/index.ts b/src/modules/purchases/entities/index.ts new file mode 100644 index 0000000..d4c36a1 --- /dev/null +++ b/src/modules/purchases/entities/index.ts @@ -0,0 +1,4 @@ +export { PurchaseOrder } from './purchase-order.entity'; +export { PurchaseOrderItem } from './purchase-order-item.entity'; +export { PurchaseReceipt } from './purchase-receipt.entity'; +export { PurchaseReceiptItem } from './purchase-receipt-item.entity'; diff --git a/src/modules/purchases/entities/purchase-order-item.entity.ts b/src/modules/purchases/entities/purchase-order-item.entity.ts new file mode 100644 index 0000000..107491c --- /dev/null +++ b/src/modules/purchases/entities/purchase-order-item.entity.ts @@ -0,0 +1,84 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PurchaseOrder } from './purchase-order.entity'; + +@Entity({ name: 'purchase_order_items', schema: 'purchases' }) +export class PurchaseOrderItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @ManyToOne(() => PurchaseOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: PurchaseOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ name: 'supplier_sku', type: 'varchar', length: 50, nullable: true }) + supplierSku?: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReceived: number; + + @Column({ name: 'quantity_returned', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReturned: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: 'pending' | 'partial' | 'received' | 'cancelled'; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-order.entity.ts b/src/modules/purchases/entities/purchase-order.entity.ts new file mode 100644 index 0000000..2c87c74 --- /dev/null +++ b/src/modules/purchases/entities/purchase-order.entity.ts @@ -0,0 +1,98 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'purchase_orders', schema: 'purchases' }) +export class PurchaseOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_number', type: 'varchar', length: 30 }) + orderNumber: string; + + @Index() + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_name', type: 'varchar', length: 200, nullable: true }) + supplierName: string; + + @Column({ name: 'supplier_email', type: 'varchar', length: 255, nullable: true }) + supplierEmail: string; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' }) + orderDate: Date; + + @Column({ name: 'expected_date', type: 'date', nullable: true }) + expectedDate: Date; + + @Column({ name: 'received_date', type: 'date', nullable: true }) + receivedDate: Date; + + @Column({ name: 'buyer_id', type: 'uuid', nullable: true }) + buyerId: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + incoterm: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'sent' | 'confirmed' | 'partial' | 'received' | 'cancelled'; + + @Column({ name: 'supplier_reference', type: 'varchar', length: 100, nullable: true }) + supplierReference: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-receipt-item.entity.ts b/src/modules/purchases/entities/purchase-receipt-item.entity.ts new file mode 100644 index 0000000..8cd3eeb --- /dev/null +++ b/src/modules/purchases/entities/purchase-receipt-item.entity.ts @@ -0,0 +1,57 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PurchaseReceipt } from './purchase-receipt.entity'; + +@Entity({ name: 'purchase_receipt_items', schema: 'purchases' }) +export class PurchaseReceiptItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'receipt_id', type: 'uuid' }) + receiptId: string; + + @ManyToOne(() => PurchaseReceipt, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'receipt_id' }) + receipt: PurchaseReceipt; + + @Column({ name: 'order_item_id', type: 'uuid', nullable: true }) + orderItemId?: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'quantity_expected', type: 'decimal', precision: 15, scale: 4, nullable: true }) + quantityExpected?: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4 }) + quantityReceived: number; + + @Column({ name: 'quantity_rejected', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityRejected: number; + + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'quality_status', type: 'varchar', length: 20, default: 'pending' }) + qualityStatus: 'pending' | 'approved' | 'rejected' | 'quarantine'; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-receipt.entity.ts b/src/modules/purchases/entities/purchase-receipt.entity.ts new file mode 100644 index 0000000..03da5cc --- /dev/null +++ b/src/modules/purchases/entities/purchase-receipt.entity.ts @@ -0,0 +1,52 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'purchase_receipts', schema: 'purchases' }) +export class PurchaseReceipt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'receipt_number', type: 'varchar', length: 30 }) + receiptNumber: string; + + @Column({ name: 'receipt_date', type: 'date', default: () => 'CURRENT_DATE' }) + receiptDate: Date; + + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedBy?: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'supplier_delivery_note', type: 'varchar', length: 100, nullable: true }) + supplierDeliveryNote?: string; + + @Column({ name: 'supplier_invoice_number', type: 'varchar', length: 100, nullable: true }) + supplierInvoiceNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @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; +} diff --git a/src/modules/purchases/index.ts b/src/modules/purchases/index.ts index 2d12553..465e763 100644 --- a/src/modules/purchases/index.ts +++ b/src/modules/purchases/index.ts @@ -1,4 +1,5 @@ -export * from './purchases.service.js'; -export * from './rfqs.service.js'; -export * from './purchases.controller.js'; -export { default as purchasesRoutes } from './purchases.routes.js'; +export { PurchasesModule, PurchasesModuleOptions } from './purchases.module'; +export * from './entities'; +export { PurchasesService } from './services'; +export { PurchasesController } from './controllers'; +export * from './dto'; diff --git a/src/modules/purchases/purchases.module.ts b/src/modules/purchases/purchases.module.ts new file mode 100644 index 0000000..f684d9b --- /dev/null +++ b/src/modules/purchases/purchases.module.ts @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { PurchasesService } from './services'; +import { PurchasesController } from './controllers'; +import { PurchaseOrder } from './entities'; + +export interface PurchasesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PurchasesModule { + public router: Router; + public purchasesService: PurchasesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: PurchasesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const orderRepository = this.dataSource.getRepository(PurchaseOrder); + this.purchasesService = new PurchasesService(orderRepository); + } + + private initializeRoutes(): void { + const purchasesController = new PurchasesController(this.purchasesService); + this.router.use(`${this.basePath}/purchase-orders`, purchasesController.router); + } + + static getEntities(): Function[] { + return [PurchaseOrder]; + } +} diff --git a/src/modules/purchases/services/index.ts b/src/modules/purchases/services/index.ts new file mode 100644 index 0000000..72daa36 --- /dev/null +++ b/src/modules/purchases/services/index.ts @@ -0,0 +1,66 @@ +import { Repository, FindOptionsWhere } from 'typeorm'; +import { PurchaseOrder } from '../entities'; +import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto'; + +export interface PurchaseSearchParams { + tenantId: string; + supplierId?: string; + status?: string; + buyerId?: string; + limit?: number; + offset?: number; +} + +export class PurchasesService { + constructor(private readonly orderRepository: Repository) {} + + async findAll(params: PurchaseSearchParams): Promise<{ data: PurchaseOrder[]; total: number }> { + const { tenantId, supplierId, status, buyerId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (supplierId) where.supplierId = supplierId; + if (status) where.status = status as any; + if (buyerId) where.buyerId = buyerId; + const [data, total] = await this.orderRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.orderRepository.findOne({ where: { id, tenantId } }); + } + + async create(tenantId: string, dto: CreatePurchaseOrderDto, createdBy?: string): Promise { + const count = await this.orderRepository.count({ where: { tenantId } }); + const orderNumber = `OC-${String(count + 1).padStart(6, '0')}`; + const order = this.orderRepository.create({ ...dto, tenantId, orderNumber, createdBy, orderDate: new Date(), expectedDate: dto.expectedDate ? new Date(dto.expectedDate) : undefined }); + return this.orderRepository.save(order); + } + + async update(id: string, tenantId: string, dto: UpdatePurchaseOrderDto, updatedBy?: string): Promise { + const order = await this.findOne(id, tenantId); + if (!order) return null; + Object.assign(order, { ...dto, updatedBy }); + return this.orderRepository.save(order); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.orderRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async confirmOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOne(id, tenantId); + if (!order || order.status !== 'draft') return null; + order.status = 'confirmed'; + order.updatedBy = userId; + return this.orderRepository.save(order); + } + + async receiveOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOne(id, tenantId); + if (!order || !['sent', 'confirmed', 'partial'].includes(order.status)) return null; + order.status = 'received'; + order.receivedDate = new Date(); + order.updatedBy = userId; + return this.orderRepository.save(order); + } +} diff --git a/src/modules/reports/controllers/index.ts b/src/modules/reports/controllers/index.ts new file mode 100644 index 0000000..9624d2d --- /dev/null +++ b/src/modules/reports/controllers/index.ts @@ -0,0 +1,230 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { ReportsService } from '../services'; + +export class ReportsController { + public router: Router; + + constructor(private readonly reportsService: ReportsService) { + this.router = Router(); + + // Sales Reports + this.router.get('/sales', this.getSalesReport.bind(this)); + this.router.get('/sales/top-products', this.getTopSellingProducts.bind(this)); + this.router.get('/sales/top-customers', this.getTopCustomers.bind(this)); + + // Inventory Reports + this.router.get('/inventory', this.getInventoryReport.bind(this)); + this.router.get('/inventory/movements', this.getStockMovementReport.bind(this)); + + // Financial Reports + this.router.get('/financial', this.getFinancialReport.bind(this)); + this.router.get('/financial/receivables', this.getAccountsReceivable.bind(this)); + this.router.get('/financial/payables', this.getAccountsPayable.bind(this)); + } + + // ==================== Sales Reports ==================== + + private async getSalesReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, partnerId, productId, groupBy } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const result = await this.reportsService.getSalesReport({ + tenantId, + startDate: start, + endDate: end, + partnerId: partnerId as string, + productId: productId as string, + groupBy: groupBy as 'day' | 'week' | 'month' | 'partner' | 'product', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getTopSellingProducts(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, limit } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const data = await this.reportsService.getTopSellingProducts( + tenantId, + start, + end, + limit ? parseInt(limit as string) : 10 + ); + + res.json({ data }); + } catch (e) { + next(e); + } + } + + private async getTopCustomers(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, limit } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const data = await this.reportsService.getTopCustomers( + tenantId, + start, + end, + limit ? parseInt(limit as string) : 10 + ); + + res.json({ data }); + } catch (e) { + next(e); + } + } + + // ==================== Inventory Reports ==================== + + private async getInventoryReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { warehouseId, productId, categoryId, lowStockOnly } = req.query; + + const result = await this.reportsService.getInventoryReport({ + tenantId, + warehouseId: warehouseId as string, + productId: productId as string, + categoryId: categoryId as string, + lowStockOnly: lowStockOnly === 'true', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getStockMovementReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, warehouseId } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const data = await this.reportsService.getStockMovementReport( + tenantId, + start, + end, + warehouseId as string + ); + + res.json({ data }); + } catch (e) { + next(e); + } + } + + // ==================== Financial Reports ==================== + + private async getFinancialReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, reportType } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setMonth(new Date().getMonth() - 12)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const result = await this.reportsService.getFinancialReport({ + tenantId, + startDate: start, + endDate: end, + reportType: (reportType as any) || 'profit_loss', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getAccountsReceivable(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const now = new Date(); + const result = await this.reportsService.getFinancialReport({ + tenantId, + startDate: now, + endDate: now, + reportType: 'accounts_receivable', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getAccountsPayable(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const now = new Date(); + const result = await this.reportsService.getFinancialReport({ + tenantId, + startDate: now, + endDate: now, + reportType: 'accounts_payable', + }); + + res.json(result); + } catch (e) { + next(e); + } + } +} diff --git a/src/modules/reports/index.ts b/src/modules/reports/index.ts index b5d3f41..f6b9bfb 100644 --- a/src/modules/reports/index.ts +++ b/src/modules/reports/index.ts @@ -1,3 +1,3 @@ -export * from './reports.service.js'; -export * from './reports.controller.js'; -export { default as reportsRoutes } from './reports.routes.js'; +export { ReportsModule, ReportsModuleOptions } from './reports.module'; +export { ReportsService } from './services'; +export { ReportsController } from './controllers'; diff --git a/src/modules/reports/reports.module.ts b/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..8d55c3a --- /dev/null +++ b/src/modules/reports/reports.module.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ReportsService } from './services'; +import { ReportsController } from './controllers'; + +export interface ReportsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ReportsModule { + public router: Router; + public reportsService: ReportsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: ReportsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + this.reportsService = new ReportsService(this.dataSource); + } + + private initializeRoutes(): void { + const reportsController = new ReportsController(this.reportsService); + this.router.use(`${this.basePath}/reports`, reportsController.router); + } + + // Reports module doesn't have its own entities - it uses data from other modules + static getEntities(): Function[] { + return []; + } +} diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts new file mode 100644 index 0000000..6de33ff --- /dev/null +++ b/src/modules/reports/services/index.ts @@ -0,0 +1,526 @@ +import { DataSource } from 'typeorm'; + +export interface ReportDateRange { + startDate: Date; + endDate: Date; +} + +export interface SalesReportParams { + tenantId: string; + startDate: Date; + endDate: Date; + partnerId?: string; + productId?: string; + groupBy?: 'day' | 'week' | 'month' | 'partner' | 'product'; +} + +export interface InventoryReportParams { + tenantId: string; + warehouseId?: string; + productId?: string; + categoryId?: string; + lowStockOnly?: boolean; +} + +export interface FinancialReportParams { + tenantId: string; + startDate: Date; + endDate: Date; + reportType: 'income' | 'expenses' | 'profit_loss' | 'cash_flow' | 'accounts_receivable' | 'accounts_payable'; +} + +export interface SalesSummary { + totalOrders: number; + totalRevenue: number; + averageOrderValue: number; + totalItems: number; +} + +export interface InventorySummary { + totalProducts: number; + totalValue: number; + lowStockItems: number; + outOfStockItems: number; +} + +export interface FinancialSummary { + totalIncome: number; + totalExpenses: number; + netProfit: number; + margin: number; +} + +export class ReportsService { + constructor(private readonly dataSource: DataSource) {} + + // ==================== Sales Reports ==================== + + async getSalesReport(params: SalesReportParams): Promise<{ + summary: SalesSummary; + data: any[]; + period: ReportDateRange; + }> { + const { tenantId, startDate, endDate, partnerId, productId, groupBy = 'day' } = params; + + // Build query based on groupBy + let query = ` + SELECT + COUNT(DISTINCT o.id) as order_count, + SUM(o.total) as total_revenue, + SUM(oi.quantity) as total_items + FROM sales.sales_orders o + LEFT JOIN sales.sales_order_items oi ON o.id = oi.order_id + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + `; + + const queryParams: any[] = [tenantId, startDate, endDate]; + let paramIndex = 4; + + if (partnerId) { + query += ` AND o.partner_id = $${paramIndex}`; + queryParams.push(partnerId); + paramIndex++; + } + + if (productId) { + query += ` AND oi.product_id = $${paramIndex}`; + queryParams.push(productId); + } + + try { + const result = await this.dataSource.query(query, queryParams); + const row = result[0] || {}; + + const summary: SalesSummary = { + totalOrders: parseInt(row.order_count) || 0, + totalRevenue: parseFloat(row.total_revenue) || 0, + averageOrderValue: row.order_count > 0 ? parseFloat(row.total_revenue) / parseInt(row.order_count) : 0, + totalItems: parseInt(row.total_items) || 0, + }; + + // Get detailed data grouped by period + const detailQuery = this.buildSalesDetailQuery(groupBy); + const detailParams = [tenantId, startDate, endDate]; + const detailData = await this.dataSource.query(detailQuery, detailParams); + + return { + summary, + data: detailData, + period: { startDate, endDate }, + }; + } catch (error) { + // Return empty data if tables don't exist yet + return { + summary: { totalOrders: 0, totalRevenue: 0, averageOrderValue: 0, totalItems: 0 }, + data: [], + period: { startDate, endDate }, + }; + } + } + + private buildSalesDetailQuery(groupBy: string): string { + const groupByClause = { + day: "DATE_TRUNC('day', o.order_date)", + week: "DATE_TRUNC('week', o.order_date)", + month: "DATE_TRUNC('month', o.order_date)", + partner: 'o.partner_id', + product: 'oi.product_id', + }[groupBy] || "DATE_TRUNC('day', o.order_date)"; + + return ` + SELECT + ${groupByClause} as period, + COUNT(DISTINCT o.id) as order_count, + SUM(o.total) as total_revenue + FROM sales.sales_orders o + LEFT JOIN sales.sales_order_items oi ON o.id = oi.order_id + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + GROUP BY ${groupByClause} + ORDER BY period DESC + `; + } + + async getTopSellingProducts( + tenantId: string, + startDate: Date, + endDate: Date, + limit: number = 10 + ): Promise { + try { + const query = ` + SELECT + oi.product_id, + oi.product_name, + SUM(oi.quantity) as total_quantity, + SUM(oi.line_total) as total_revenue + FROM sales.sales_order_items oi + JOIN sales.sales_orders o ON oi.order_id = o.id + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + GROUP BY oi.product_id, oi.product_name + ORDER BY total_revenue DESC + LIMIT $4 + `; + + return await this.dataSource.query(query, [tenantId, startDate, endDate, limit]); + } catch { + return []; + } + } + + async getTopCustomers( + tenantId: string, + startDate: Date, + endDate: Date, + limit: number = 10 + ): Promise { + try { + const query = ` + SELECT + o.partner_id, + o.partner_name, + COUNT(o.id) as order_count, + SUM(o.total) as total_revenue + FROM sales.sales_orders o + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + GROUP BY o.partner_id, o.partner_name + ORDER BY total_revenue DESC + LIMIT $4 + `; + + return await this.dataSource.query(query, [tenantId, startDate, endDate, limit]); + } catch { + return []; + } + } + + // ==================== Inventory Reports ==================== + + async getInventoryReport(params: InventoryReportParams): Promise<{ + summary: InventorySummary; + data: any[]; + }> { + const { tenantId, warehouseId, productId, categoryId, lowStockOnly } = params; + + try { + let query = ` + SELECT + COUNT(DISTINCT sl.product_id) as total_products, + SUM(sl.quantity_on_hand * COALESCE(sl.unit_cost, 0)) as total_value, + COUNT(CASE WHEN sl.quantity_on_hand <= 0 THEN 1 END) as out_of_stock, + COUNT(CASE WHEN sl.quantity_on_hand > 0 AND sl.quantity_on_hand <= 10 THEN 1 END) as low_stock + FROM inventory.stock_levels sl + WHERE sl.tenant_id = $1 + `; + + const queryParams: any[] = [tenantId]; + let paramIndex = 2; + + if (warehouseId) { + query += ` AND sl.warehouse_id = $${paramIndex}`; + queryParams.push(warehouseId); + paramIndex++; + } + + if (productId) { + query += ` AND sl.product_id = $${paramIndex}`; + queryParams.push(productId); + } + + const result = await this.dataSource.query(query, queryParams); + const row = result[0] || {}; + + const summary: InventorySummary = { + totalProducts: parseInt(row.total_products) || 0, + totalValue: parseFloat(row.total_value) || 0, + lowStockItems: parseInt(row.low_stock) || 0, + outOfStockItems: parseInt(row.out_of_stock) || 0, + }; + + // Get detailed stock levels + let detailQuery = ` + SELECT + sl.product_id, + p.name as product_name, + p.sku, + sl.warehouse_id, + w.name as warehouse_name, + sl.quantity_on_hand, + sl.quantity_reserved, + sl.quantity_available, + sl.unit_cost, + (sl.quantity_on_hand * COALESCE(sl.unit_cost, 0)) as total_value + FROM inventory.stock_levels sl + LEFT JOIN products.products p ON sl.product_id = p.id + LEFT JOIN inventory.warehouses w ON sl.warehouse_id = w.id + WHERE sl.tenant_id = $1 + `; + + const detailParams: any[] = [tenantId]; + let detailIndex = 2; + + if (warehouseId) { + detailQuery += ` AND sl.warehouse_id = $${detailIndex}`; + detailParams.push(warehouseId); + detailIndex++; + } + + if (lowStockOnly) { + detailQuery += ` AND sl.quantity_on_hand <= 10`; + } + + detailQuery += ` ORDER BY sl.quantity_on_hand ASC LIMIT 100`; + + const detailData = await this.dataSource.query(detailQuery, detailParams); + + return { summary, data: detailData }; + } catch { + return { + summary: { totalProducts: 0, totalValue: 0, lowStockItems: 0, outOfStockItems: 0 }, + data: [], + }; + } + } + + async getStockMovementReport( + tenantId: string, + startDate: Date, + endDate: Date, + warehouseId?: string + ): Promise { + try { + let query = ` + SELECT + sm.movement_type, + COUNT(*) as movement_count, + SUM(sm.quantity) as total_quantity, + SUM(sm.total_cost) as total_value + FROM inventory.stock_movements sm + WHERE sm.tenant_id = $1 + AND sm.status = 'confirmed' + AND sm.created_at BETWEEN $2 AND $3 + `; + + const params: any[] = [tenantId, startDate, endDate]; + + if (warehouseId) { + query += ` AND (sm.source_warehouse_id = $4 OR sm.dest_warehouse_id = $4)`; + params.push(warehouseId); + } + + query += ` GROUP BY sm.movement_type ORDER BY total_quantity DESC`; + + return await this.dataSource.query(query, params); + } catch { + return []; + } + } + + // ==================== Financial Reports ==================== + + async getFinancialReport(params: FinancialReportParams): Promise<{ + summary: FinancialSummary; + data: any[]; + period: ReportDateRange; + }> { + const { tenantId, startDate, endDate, reportType } = params; + + try { + switch (reportType) { + case 'income': + return await this.getIncomeReport(tenantId, startDate, endDate); + case 'expenses': + return await this.getExpensesReport(tenantId, startDate, endDate); + case 'profit_loss': + return await this.getProfitLossReport(tenantId, startDate, endDate); + case 'accounts_receivable': + return await this.getAccountsReceivableReport(tenantId); + case 'accounts_payable': + return await this.getAccountsPayableReport(tenantId); + default: + return await this.getProfitLossReport(tenantId, startDate, endDate); + } + } catch { + return { + summary: { totalIncome: 0, totalExpenses: 0, netProfit: 0, margin: 0 }, + data: [], + period: { startDate, endDate }, + }; + } + } + + private async getIncomeReport( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + DATE_TRUNC('month', i.invoice_date) as period, + SUM(i.total) as total_income + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'sale' + AND i.status NOT IN ('cancelled', 'voided', 'draft') + AND i.invoice_date BETWEEN $2 AND $3 + GROUP BY DATE_TRUNC('month', i.invoice_date) + ORDER BY period + `; + + const data = await this.dataSource.query(query, [tenantId, startDate, endDate]); + const totalIncome = data.reduce((sum: number, row: any) => sum + parseFloat(row.total_income || 0), 0); + + return { + summary: { totalIncome, totalExpenses: 0, netProfit: totalIncome, margin: 100 }, + data, + period: { startDate, endDate }, + }; + } + + private async getExpensesReport( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + DATE_TRUNC('month', i.invoice_date) as period, + SUM(i.total) as total_expenses + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'purchase' + AND i.status NOT IN ('cancelled', 'voided', 'draft') + AND i.invoice_date BETWEEN $2 AND $3 + GROUP BY DATE_TRUNC('month', i.invoice_date) + ORDER BY period + `; + + const data = await this.dataSource.query(query, [tenantId, startDate, endDate]); + const totalExpenses = data.reduce((sum: number, row: any) => sum + parseFloat(row.total_expenses || 0), 0); + + return { + summary: { totalIncome: 0, totalExpenses, netProfit: -totalExpenses, margin: 0 }, + data, + period: { startDate, endDate }, + }; + } + + private async getProfitLossReport( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const incomeQuery = ` + SELECT COALESCE(SUM(total), 0) as total + FROM billing.invoices + WHERE tenant_id = $1 + AND invoice_type = 'sale' + AND status NOT IN ('cancelled', 'voided', 'draft') + AND invoice_date BETWEEN $2 AND $3 + `; + + const expensesQuery = ` + SELECT COALESCE(SUM(total), 0) as total + FROM billing.invoices + WHERE tenant_id = $1 + AND invoice_type = 'purchase' + AND status NOT IN ('cancelled', 'voided', 'draft') + AND invoice_date BETWEEN $2 AND $3 + `; + + const [incomeResult, expensesResult] = await Promise.all([ + this.dataSource.query(incomeQuery, [tenantId, startDate, endDate]), + this.dataSource.query(expensesQuery, [tenantId, startDate, endDate]), + ]); + + const totalIncome = parseFloat(incomeResult[0]?.total) || 0; + const totalExpenses = parseFloat(expensesResult[0]?.total) || 0; + const netProfit = totalIncome - totalExpenses; + const margin = totalIncome > 0 ? (netProfit / totalIncome) * 100 : 0; + + return { + summary: { totalIncome, totalExpenses, netProfit, margin }, + data: [{ totalIncome, totalExpenses, netProfit, margin }], + period: { startDate, endDate }, + }; + } + + private async getAccountsReceivableReport( + tenantId: string + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + i.id, + i.invoice_number, + i.partner_name, + i.total, + i.amount_paid, + (i.total - i.amount_paid) as amount_due, + i.due_date, + CASE + WHEN i.due_date < CURRENT_DATE THEN 'overdue' + WHEN i.due_date < CURRENT_DATE + INTERVAL '7 days' THEN 'due_soon' + ELSE 'current' + END as status + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'sale' + AND i.status IN ('validated', 'sent', 'partial') + AND (i.total - i.amount_paid) > 0 + ORDER BY i.due_date ASC + `; + + const data = await this.dataSource.query(query, [tenantId]); + const totalIncome = data.reduce((sum: number, row: any) => sum + parseFloat(row.amount_due || 0), 0); + + const now = new Date(); + return { + summary: { totalIncome, totalExpenses: 0, netProfit: totalIncome, margin: 0 }, + data, + period: { startDate: now, endDate: now }, + }; + } + + private async getAccountsPayableReport( + tenantId: string + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + i.id, + i.invoice_number, + i.partner_name, + i.total, + i.amount_paid, + (i.total - i.amount_paid) as amount_due, + i.due_date, + CASE + WHEN i.due_date < CURRENT_DATE THEN 'overdue' + WHEN i.due_date < CURRENT_DATE + INTERVAL '7 days' THEN 'due_soon' + ELSE 'current' + END as status + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'purchase' + AND i.status IN ('validated', 'sent', 'partial') + AND (i.total - i.amount_paid) > 0 + ORDER BY i.due_date ASC + `; + + const data = await this.dataSource.query(query, [tenantId]); + const totalExpenses = data.reduce((sum: number, row: any) => sum + parseFloat(row.amount_due || 0), 0); + + const now = new Date(); + return { + summary: { totalIncome: 0, totalExpenses, netProfit: -totalExpenses, margin: 0 }, + data, + period: { startDate: now, endDate: now }, + }; + } +} diff --git a/src/modules/sales/controllers/index.ts b/src/modules/sales/controllers/index.ts new file mode 100644 index 0000000..049434b --- /dev/null +++ b/src/modules/sales/controllers/index.ts @@ -0,0 +1,177 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { SalesService } from '../services'; +import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto'; + +export class QuotationsController { + public router: Router; + constructor(private readonly salesService: SalesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/convert', this.convert.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { partnerId, status, salesRepId, limit, offset } = req.query; + const result = await this.salesService.findAllQuotations({ tenantId, partnerId: partnerId as string, status: status as string, salesRepId: salesRepId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const quotation = await this.salesService.findQuotation(req.params.id, tenantId); + if (!quotation) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: quotation }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const quotation = await this.salesService.createQuotation(tenantId, req.body, userId); + res.status(201).json({ data: quotation }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const quotation = await this.salesService.updateQuotation(req.params.id, tenantId, req.body, userId); + if (!quotation) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: quotation }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.salesService.deleteQuotation(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async convert(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.convertQuotationToOrder(req.params.id, tenantId, userId); + res.json({ data: order }); + } catch (e) { next(e); } + } +} + +export class SalesOrdersController { + public router: Router; + constructor(private readonly salesService: SalesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/confirm', this.confirm.bind(this)); + this.router.post('/:id/ship', this.ship.bind(this)); + this.router.post('/:id/deliver', this.deliver.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { partnerId, status, salesRepId, limit, offset } = req.query; + const result = await this.salesService.findAllOrders({ tenantId, partnerId: partnerId as string, status: status as string, salesRepId: salesRepId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.findOrder(req.params.id, tenantId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.createSalesOrder(tenantId, req.body, userId); + res.status(201).json({ data: order }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.updateSalesOrder(req.params.id, tenantId, req.body, userId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.salesService.deleteSalesOrder(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async confirm(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.confirmOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot confirm' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async ship(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { trackingNumber, carrier } = req.body; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.shipOrder(req.params.id, tenantId, trackingNumber, carrier, userId); + if (!order) { res.status(400).json({ error: 'Cannot ship' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async deliver(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.deliverOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot deliver' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } +} diff --git a/src/modules/sales/dto/index.ts b/src/modules/sales/dto/index.ts new file mode 100644 index 0000000..6741874 --- /dev/null +++ b/src/modules/sales/dto/index.ts @@ -0,0 +1,82 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator'; + +export class CreateQuotationDto { + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsString() partnerEmail?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() quotationDate?: string; + @IsOptional() @IsDateString() validUntil?: string; + @IsOptional() @IsUUID() salesRepId?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsString() termsAndConditions?: string; + @IsOptional() @IsArray() items?: CreateQuotationItemDto[]; +} + +export class CreateQuotationItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsOptional() @IsString() description?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdateQuotationDto { + @IsOptional() @IsUUID() partnerId?: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() validUntil?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'sent', 'accepted', 'rejected', 'expired']) status?: string; +} + +export class CreateSalesOrderDto { + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsUUID() quotationId?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() requestedDate?: string; + @IsOptional() @IsDateString() promisedDate?: string; + @IsOptional() @IsUUID() salesRepId?: string; + @IsOptional() @IsUUID() warehouseId?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() shippingMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() items?: CreateOrderItemDto[]; +} + +export class CreateOrderItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() unitCost?: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdateSalesOrderDto { + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() promisedDate?: string; + @IsOptional() @IsString() shippingMethod?: string; + @IsOptional() @IsString() trackingNumber?: string; + @IsOptional() @IsString() carrier?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled']) status?: string; +} diff --git a/src/modules/sales/entities/index.ts b/src/modules/sales/entities/index.ts new file mode 100644 index 0000000..cca5d8f --- /dev/null +++ b/src/modules/sales/entities/index.ts @@ -0,0 +1,4 @@ +export { Quotation } from './quotation.entity'; +export { QuotationItem } from './quotation-item.entity'; +export { SalesOrder } from './sales-order.entity'; +export { SalesOrderItem } from './sales-order-item.entity'; diff --git a/src/modules/sales/entities/quotation-item.entity.ts b/src/modules/sales/entities/quotation-item.entity.ts new file mode 100644 index 0000000..95928bd --- /dev/null +++ b/src/modules/sales/entities/quotation-item.entity.ts @@ -0,0 +1,65 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Quotation } from './quotation.entity'; + +@Entity({ name: 'quotation_items', schema: 'sales' }) +export class QuotationItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'quotation_id', type: 'uuid' }) + quotationId: string; + + @ManyToOne(() => Quotation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'quotation_id' }) + quotation: Quotation; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/sales/entities/quotation.entity.ts b/src/modules/sales/entities/quotation.entity.ts new file mode 100644 index 0000000..88e9bdd --- /dev/null +++ b/src/modules/sales/entities/quotation.entity.ts @@ -0,0 +1,101 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index, OneToMany } from 'typeorm'; + +@Entity({ name: 'quotations', schema: 'sales' }) +export class Quotation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'quotation_number', type: 'varchar', length: 30 }) + quotationNumber: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true }) + partnerEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'quotation_date', type: 'date', default: () => 'CURRENT_DATE' }) + quotationDate: Date; + + @Column({ name: 'valid_until', type: 'date', nullable: true }) + validUntil: Date; + + @Column({ name: 'expected_close_date', type: 'date', nullable: true }) + expectedCloseDate: Date; + + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted'; + + @Column({ name: 'converted_to_order', type: 'boolean', default: false }) + convertedToOrder: boolean; + + @Column({ name: 'order_id', type: 'uuid', nullable: true }) + orderId: string; + + @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) + convertedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) + termsAndConditions: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/sales/entities/sales-order-item.entity.ts b/src/modules/sales/entities/sales-order-item.entity.ts new file mode 100644 index 0000000..3a38976 --- /dev/null +++ b/src/modules/sales/entities/sales-order-item.entity.ts @@ -0,0 +1,90 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { SalesOrder } from './sales-order.entity'; + +@Entity({ name: 'sales_order_items', schema: 'sales' }) +export class SalesOrderItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @ManyToOne(() => SalesOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: SalesOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReserved: number; + + @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityShipped: number; + + @Column({ name: 'quantity_delivered', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityDelivered: number; + + @Column({ name: 'quantity_returned', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReturned: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitCost: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: 'pending' | 'reserved' | 'shipped' | 'delivered' | 'cancelled'; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/sales/entities/sales-order.entity.ts b/src/modules/sales/entities/sales-order.entity.ts new file mode 100644 index 0000000..6d08bb1 --- /dev/null +++ b/src/modules/sales/entities/sales-order.entity.ts @@ -0,0 +1,113 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'sales_orders', schema: 'sales' }) +export class SalesOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_number', type: 'varchar', length: 30 }) + orderNumber: string; + + @Column({ name: 'quotation_id', type: 'uuid', nullable: true }) + quotationId: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true }) + partnerEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' }) + orderDate: Date; + + @Column({ name: 'requested_date', type: 'date', nullable: true }) + requestedDate: Date; + + @Column({ name: 'promised_date', type: 'date', nullable: true }) + promisedDate: Date; + + @Column({ name: 'shipped_date', type: 'date', nullable: true }) + shippedDate: Date; + + @Column({ name: 'delivered_date', type: 'date', nullable: true }) + deliveredDate: Date; + + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + + @Column({ name: 'shipping_method', type: 'varchar', length: 50, nullable: true }) + shippingMethod: string; + + @Column({ name: 'tracking_number', type: 'varchar', length: 100, nullable: true }) + trackingNumber: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + carrier: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/sales/index.ts b/src/modules/sales/index.ts index 31f7ef6..705ff68 100644 --- a/src/modules/sales/index.ts +++ b/src/modules/sales/index.ts @@ -1,7 +1,5 @@ -export * from './pricelists.service.js'; -export * from './sales-teams.service.js'; -export * from './customer-groups.service.js'; -export * from './quotations.service.js'; -export * from './orders.service.js'; -export * from './sales.controller.js'; -export { default as salesRoutes } from './sales.routes.js'; +export { SalesModule, SalesModuleOptions } from './sales.module'; +export * from './entities'; +export { SalesService } from './services'; +export { QuotationsController, SalesOrdersController } from './controllers'; +export * from './dto'; diff --git a/src/modules/sales/sales.module.ts b/src/modules/sales/sales.module.ts new file mode 100644 index 0000000..ae5fa33 --- /dev/null +++ b/src/modules/sales/sales.module.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { SalesService } from './services'; +import { QuotationsController, SalesOrdersController } from './controllers'; +import { Quotation, SalesOrder } from './entities'; + +export interface SalesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class SalesModule { + public router: Router; + public salesService: SalesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: SalesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const quotationRepository = this.dataSource.getRepository(Quotation); + const orderRepository = this.dataSource.getRepository(SalesOrder); + this.salesService = new SalesService(quotationRepository, orderRepository); + } + + private initializeRoutes(): void { + const quotationsController = new QuotationsController(this.salesService); + const ordersController = new SalesOrdersController(this.salesService); + this.router.use(`${this.basePath}/quotations`, quotationsController.router); + this.router.use(`${this.basePath}/sales-orders`, ordersController.router); + } + + static getEntities(): Function[] { + return [Quotation, SalesOrder]; + } +} diff --git a/src/modules/sales/services/index.ts b/src/modules/sales/services/index.ts new file mode 100644 index 0000000..fcde7fd --- /dev/null +++ b/src/modules/sales/services/index.ts @@ -0,0 +1,144 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Quotation, SalesOrder } from '../entities'; +import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto'; + +export interface SalesSearchParams { + tenantId: string; + search?: string; + partnerId?: string; + status?: string; + salesRepId?: string; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +export class SalesService { + constructor( + private readonly quotationRepository: Repository, + private readonly orderRepository: Repository + ) {} + + async findAllQuotations(params: SalesSearchParams): Promise<{ data: Quotation[]; total: number }> { + const { tenantId, search, partnerId, status, salesRepId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + if (salesRepId) where.salesRepId = salesRepId; + const [data, total] = await this.quotationRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findQuotation(id: string, tenantId: string): Promise { + return this.quotationRepository.findOne({ where: { id, tenantId } }); + } + + async createQuotation(tenantId: string, dto: CreateQuotationDto, createdBy?: string): Promise { + const count = await this.quotationRepository.count({ where: { tenantId } }); + const quotationNumber = `COT-${String(count + 1).padStart(6, '0')}`; + const quotation = this.quotationRepository.create({ ...dto, tenantId, quotationNumber, createdBy, quotationDate: dto.quotationDate ? new Date(dto.quotationDate) : new Date(), validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined }); + return this.quotationRepository.save(quotation); + } + + async updateQuotation(id: string, tenantId: string, dto: UpdateQuotationDto, updatedBy?: string): Promise { + const quotation = await this.findQuotation(id, tenantId); + if (!quotation) return null; + Object.assign(quotation, { ...dto, updatedBy }); + return this.quotationRepository.save(quotation); + } + + async deleteQuotation(id: string, tenantId: string): Promise { + const result = await this.quotationRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async convertQuotationToOrder(id: string, tenantId: string, userId?: string): Promise { + const quotation = await this.findQuotation(id, tenantId); + if (!quotation) throw new Error('Quotation not found'); + if (quotation.convertedToOrder) throw new Error('Quotation already converted'); + + const order = await this.createSalesOrder(tenantId, { + partnerId: quotation.partnerId, + partnerName: quotation.partnerName, + quotationId: quotation.id, + billingAddress: quotation.billingAddress, + shippingAddress: quotation.shippingAddress, + currency: quotation.currency, + paymentTermDays: quotation.paymentTermDays, + paymentMethod: quotation.paymentMethod, + notes: quotation.notes, + }, userId); + + quotation.convertedToOrder = true; + quotation.orderId = order.id; + quotation.convertedAt = new Date(); + quotation.status = 'converted'; + await this.quotationRepository.save(quotation); + + return order; + } + + async findAllOrders(params: SalesSearchParams): Promise<{ data: SalesOrder[]; total: number }> { + const { tenantId, search, partnerId, status, salesRepId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + if (salesRepId) where.salesRepId = salesRepId; + const [data, total] = await this.orderRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findOrder(id: string, tenantId: string): Promise { + return this.orderRepository.findOne({ where: { id, tenantId } }); + } + + async createSalesOrder(tenantId: string, dto: CreateSalesOrderDto, createdBy?: string): Promise { + const count = await this.orderRepository.count({ where: { tenantId } }); + const orderNumber = `OV-${String(count + 1).padStart(6, '0')}`; + const order = this.orderRepository.create({ ...dto, tenantId, orderNumber, createdBy, orderDate: new Date(), requestedDate: dto.requestedDate ? new Date(dto.requestedDate) : undefined, promisedDate: dto.promisedDate ? new Date(dto.promisedDate) : undefined }); + return this.orderRepository.save(order); + } + + async updateSalesOrder(id: string, tenantId: string, dto: UpdateSalesOrderDto, updatedBy?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order) return null; + Object.assign(order, { ...dto, updatedBy }); + return this.orderRepository.save(order); + } + + async deleteSalesOrder(id: string, tenantId: string): Promise { + const result = await this.orderRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async confirmOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'draft') return null; + order.status = 'confirmed'; + order.updatedBy = userId; + return this.orderRepository.save(order); + } + + async shipOrder(id: string, tenantId: string, trackingNumber?: string, carrier?: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || !['confirmed', 'processing'].includes(order.status)) return null; + order.status = 'shipped'; + order.shippedDate = new Date(); + if (trackingNumber) order.trackingNumber = trackingNumber; + if (carrier) order.carrier = carrier; + order.updatedBy = userId; + return this.orderRepository.save(order); + } + + async deliverOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'shipped') return null; + order.status = 'delivered'; + order.deliveredDate = new Date(); + order.updatedBy = userId; + return this.orderRepository.save(order); + } +} + +export { SalesService as default }; diff --git a/src/modules/storage/controllers/index.ts b/src/modules/storage/controllers/index.ts new file mode 100644 index 0000000..159a806 --- /dev/null +++ b/src/modules/storage/controllers/index.ts @@ -0,0 +1 @@ +export { StorageController } from './storage.controller'; diff --git a/src/modules/storage/controllers/storage.controller.ts b/src/modules/storage/controllers/storage.controller.ts new file mode 100644 index 0000000..5429b1a --- /dev/null +++ b/src/modules/storage/controllers/storage.controller.ts @@ -0,0 +1,358 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { StorageService, FileSearchFilters } from '../services/storage.service'; + +export class StorageController { + public router: Router; + + constructor(private readonly storageService: StorageService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Buckets + this.router.get('/buckets', this.findAllBuckets.bind(this)); + this.router.get('/buckets/:id', this.findBucket.bind(this)); + this.router.post('/buckets', this.createBucket.bind(this)); + this.router.patch('/buckets/:id', this.updateBucket.bind(this)); + this.router.delete('/buckets/:id', this.deleteBucket.bind(this)); + + // Folders + this.router.get('/buckets/:bucketId/folders', this.findFoldersInBucket.bind(this)); + this.router.get('/folders/:id', this.findFolder.bind(this)); + this.router.post('/folders', this.createFolder.bind(this)); + this.router.patch('/folders/:id', this.updateFolder.bind(this)); + this.router.delete('/folders/:id', this.deleteFolder.bind(this)); + + // Files + this.router.get('/buckets/:bucketId/files', this.findFilesInFolder.bind(this)); + this.router.get('/files/:id', this.findFile.bind(this)); + this.router.get('/files/key/:storageKey', this.findFileByStorageKey.bind(this)); + this.router.post('/files', this.createFile.bind(this)); + this.router.patch('/files/:id', this.updateFile.bind(this)); + this.router.delete('/files/:id', this.deleteFile.bind(this)); + this.router.post('/files/:id/download', this.incrementDownloadCount.bind(this)); + + // Search & Stats + this.router.get('/search', this.searchFiles.bind(this)); + this.router.get('/usage', this.getStorageUsage.bind(this)); + this.router.get('/recent', this.findRecentFiles.bind(this)); + } + + // ============================================ + // BUCKETS + // ============================================ + + private async findAllBuckets(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const buckets = await this.storageService.findAllBuckets(tenantId); + res.json({ data: buckets, total: buckets.length }); + } catch (error) { + next(error); + } + } + + private async findBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const bucket = await this.storageService.findBucket(id); + + if (!bucket) { + res.status(404).json({ error: 'Bucket not found' }); + return; + } + + res.json({ data: bucket }); + } catch (error) { + next(error); + } + } + + private async createBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const bucket = await this.storageService.createBucket(tenantId, req.body, userId); + res.status(201).json({ data: bucket }); + } catch (error) { + next(error); + } + } + + private async updateBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const bucket = await this.storageService.updateBucket(id, req.body); + + if (!bucket) { + res.status(404).json({ error: 'Bucket not found' }); + return; + } + + res.json({ data: bucket }); + } catch (error) { + next(error); + } + } + + private async deleteBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.storageService.deleteBucket(id); + + if (!deleted) { + res.status(404).json({ error: 'Bucket not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // FOLDERS + // ============================================ + + private async findFoldersInBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { bucketId } = req.params; + const parentId = req.query.parentId as string | undefined; + + const folders = await this.storageService.findFoldersInBucket(bucketId, parentId); + res.json({ data: folders, total: folders.length }); + } catch (error) { + next(error); + } + } + + private async findFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const folder = await this.storageService.findFolder(id); + + if (!folder) { + res.status(404).json({ error: 'Folder not found' }); + return; + } + + res.json({ data: folder }); + } catch (error) { + next(error); + } + } + + private async createFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { bucketId, ...data } = req.body; + + const folder = await this.storageService.createFolder(tenantId, bucketId, data, userId); + res.status(201).json({ data: folder }); + } catch (error) { + next(error); + } + } + + private async updateFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const folder = await this.storageService.updateFolder(id, req.body); + + if (!folder) { + res.status(404).json({ error: 'Folder not found' }); + return; + } + + res.json({ data: folder }); + } catch (error) { + next(error); + } + } + + private async deleteFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const recursive = req.query.recursive === 'true'; + + const deleted = await this.storageService.deleteFolder(id, recursive); + + if (!deleted) { + res.status(404).json({ error: 'Folder not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // FILES + // ============================================ + + private async findFilesInFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { bucketId } = req.params; + const folderId = req.query.folderId as string | undefined; + const filters: FileSearchFilters = { + category: req.query.category as string, + mimeType: req.query.mimeType as string, + uploadedBy: req.query.uploadedBy as string, + }; + + if (req.query.isPublic !== undefined) { + filters.isPublic = req.query.isPublic === 'true'; + } + + const files = await this.storageService.findFilesInFolder(bucketId, folderId, filters); + res.json({ data: files, total: files.length }); + } catch (error) { + next(error); + } + } + + private async findFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const file = await this.storageService.findFile(id); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json({ data: file }); + } catch (error) { + next(error); + } + } + + private async findFileByStorageKey(req: Request, res: Response, next: NextFunction): Promise { + try { + const { storageKey } = req.params; + const file = await this.storageService.findFileByStorageKey(storageKey); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json({ data: file }); + } catch (error) { + next(error); + } + } + + private async createFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { bucketId, ...data } = req.body; + + const file = await this.storageService.createFile(tenantId, bucketId, data, userId); + res.status(201).json({ data: file }); + } catch (error) { + next(error); + } + } + + private async updateFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const file = await this.storageService.updateFile(id, req.body); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json({ data: file }); + } catch (error) { + next(error); + } + } + + private async deleteFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.storageService.deleteFile(id); + + if (!deleted) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async incrementDownloadCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + await this.storageService.incrementDownloadCount(id); + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // SEARCH & STATS + // ============================================ + + private async searchFiles(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const query = req.query.q as string; + + if (!query) { + res.status(400).json({ error: 'Query parameter "q" is required' }); + return; + } + + const filters: FileSearchFilters = { + category: req.query.category as string, + mimeType: req.query.mimeType as string, + }; + + if (req.query.isPublic !== undefined) { + filters.isPublic = req.query.isPublic === 'true'; + } + + const files = await this.storageService.searchFiles(tenantId, query, filters); + res.json({ data: files, total: files.length }); + } catch (error) { + next(error); + } + } + + private async getStorageUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const usage = await this.storageService.getStorageUsage(tenantId); + res.json({ data: usage }); + } catch (error) { + next(error); + } + } + + private async findRecentFiles(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const limit = parseInt(req.query.limit as string) || 20; + + const files = await this.storageService.findRecentFiles(tenantId, limit); + res.json({ data: files, total: files.length }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/storage/dto/index.ts b/src/modules/storage/dto/index.ts new file mode 100644 index 0000000..28df792 --- /dev/null +++ b/src/modules/storage/dto/index.ts @@ -0,0 +1,10 @@ +export { + CreateBucketDto, + UpdateBucketDto, + CreateFolderDto, + UpdateFolderDto, + CreateFileDto, + UpdateFileDto, + UpdateProcessingStatusDto, + SearchFilesDto, +} from './storage.dto'; diff --git a/src/modules/storage/dto/storage.dto.ts b/src/modules/storage/dto/storage.dto.ts new file mode 100644 index 0000000..39ecc31 --- /dev/null +++ b/src/modules/storage/dto/storage.dto.ts @@ -0,0 +1,286 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + MaxLength, + MinLength, + Min, +} from 'class-validator'; + +// ============================================ +// BUCKET DTOs +// ============================================ + +export class CreateBucketDto { + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + bucketType?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + provider?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + maxSizeBytes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxFileSize?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedMimeTypes?: string[]; + + @IsOptional() + @IsObject() + retentionPolicy?: Record; + + @IsOptional() + @IsObject() + providerConfig?: Record; +} + +export class UpdateBucketDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + maxSizeBytes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxFileSize?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedMimeTypes?: string[]; + + @IsOptional() + @IsObject() + retentionPolicy?: Record; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +// ============================================ +// FOLDER DTOs +// ============================================ + +export class CreateFolderDto { + @IsUUID() + bucketId: string; + + @IsString() + @MinLength(1) + @MaxLength(200) + name: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateFolderDto { + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// FILE DTOs +// ============================================ + +export class CreateFileDto { + @IsUUID() + bucketId: string; + + @IsOptional() + @IsUUID() + folderId?: string; + + @IsString() + @MaxLength(500) + originalName: string; + + @IsString() + @MaxLength(500) + storageKey: string; + + @IsString() + @MaxLength(100) + mimeType: string; + + @IsNumber() + @Min(0) + sizeBytes: number; + + @IsOptional() + @IsString() + @MaxLength(64) + checksum?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; +} + +export class UpdateFileDto { + @IsOptional() + @IsString() + @MaxLength(500) + originalName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsUUID() + folderId?: string; +} + +export class UpdateProcessingStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// SEARCH DTOs +// ============================================ + +export class SearchFilesDto { + @IsString() + @MinLength(1) + query: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mimeType?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} diff --git a/src/modules/storage/entities/bucket.entity.ts b/src/modules/storage/entities/bucket.entity.ts new file mode 100644 index 0000000..2d2a5bb --- /dev/null +++ b/src/modules/storage/entities/bucket.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BucketType = 'public' | 'private' | 'protected'; +export type StorageProvider = 'local' | 's3' | 'gcs' | 'azure'; + +@Entity({ name: 'buckets', schema: 'storage' }) +export class StorageBucket { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'bucket_type', type: 'varchar', length: 30, default: 'private' }) + bucketType: BucketType; + + @Column({ name: 'max_file_size_mb', type: 'int', default: 50 }) + maxFileSizeMb: number; + + @Column({ name: 'allowed_mime_types', type: 'text', array: true, default: [] }) + allowedMimeTypes: string[]; + + @Column({ name: 'allowed_extensions', type: 'text', array: true, default: [] }) + allowedExtensions: string[]; + + @Column({ name: 'auto_delete_days', type: 'int', nullable: true }) + autoDeleteDays: number; + + @Column({ name: 'versioning_enabled', type: 'boolean', default: false }) + versioningEnabled: boolean; + + @Column({ name: 'max_versions', type: 'int', default: 5 }) + maxVersions: number; + + @Column({ name: 'storage_provider', type: 'varchar', length: 30, default: 'local' }) + storageProvider: StorageProvider; + + @Column({ name: 'storage_config', type: 'jsonb', default: {} }) + storageConfig: Record; + + @Column({ name: 'quota_per_tenant_gb', type: 'int', nullable: true }) + quotaPerTenantGb: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/storage/entities/file-access-token.entity.ts b/src/modules/storage/entities/file-access-token.entity.ts new file mode 100644 index 0000000..30195de --- /dev/null +++ b/src/modules/storage/entities/file-access-token.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_access_tokens', schema: 'storage' }) +export class FileAccessToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'token', type: 'varchar', length: 255, unique: true }) + token: string; + + @Column({ name: 'permissions', type: 'text', array: true, default: ['read'] }) + permissions: string[]; + + @Column({ name: 'allowed_ips', type: 'inet', array: true, nullable: true }) + allowedIps: string[]; + + @Column({ name: 'max_downloads', type: 'int', nullable: true }) + maxDownloads: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'created_for', type: 'varchar', length: 255, nullable: true }) + createdFor: string; + + @Column({ name: 'purpose', type: 'text', nullable: true }) + purpose: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file-share.entity.ts b/src/modules/storage/entities/file-share.entity.ts new file mode 100644 index 0000000..0ca769d --- /dev/null +++ b/src/modules/storage/entities/file-share.entity.ts @@ -0,0 +1,88 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_shares', schema: 'storage' }) +export class FileShare { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'shared_with_user_id', type: 'uuid', nullable: true }) + sharedWithUserId: string; + + @Column({ name: 'shared_with_email', type: 'varchar', length: 255, nullable: true }) + sharedWithEmail: string; + + @Column({ name: 'shared_with_role', type: 'varchar', length: 50, nullable: true }) + sharedWithRole: string; + + @Column({ name: 'can_view', type: 'boolean', default: true }) + canView: boolean; + + @Column({ name: 'can_download', type: 'boolean', default: true }) + canDownload: boolean; + + @Column({ name: 'can_edit', type: 'boolean', default: false }) + canEdit: boolean; + + @Column({ name: 'can_delete', type: 'boolean', default: false }) + canDelete: boolean; + + @Column({ name: 'can_share', type: 'boolean', default: false }) + canShare: boolean; + + @Index() + @Column({ name: 'public_link', type: 'varchar', length: 255, unique: true, nullable: true }) + publicLink: string; + + @Column({ name: 'public_link_password', type: 'varchar', length: 255, nullable: true }) + publicLinkPassword: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'view_count', type: 'int', default: 0 }) + viewCount: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Column({ name: 'notify_on_access', type: 'boolean', default: false }) + notifyOnAccess: 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; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file.entity.ts b/src/modules/storage/entities/file.entity.ts new file mode 100644 index 0000000..193b473 --- /dev/null +++ b/src/modules/storage/entities/file.entity.ts @@ -0,0 +1,154 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; + +export type FileCategory = 'image' | 'document' | 'video' | 'audio' | 'archive' | 'other'; +export type FileStatus = 'active' | 'processing' | 'archived' | 'deleted'; +export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'files', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path', 'version']) +export class StorageFile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName: string; + + @Column({ name: 'path', type: 'text' }) + path: string; + + @Index() + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType: string; + + @Column({ name: 'extension', type: 'varchar', length: 20, nullable: true }) + extension: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30, nullable: true }) + category: FileCategory; + + @Column({ name: 'size_bytes', type: 'bigint' }) + sizeBytes: number; + + @Column({ name: 'checksum_md5', type: 'varchar', length: 32, nullable: true }) + checksumMd5: string; + + @Index() + @Column({ name: 'checksum_sha256', type: 'varchar', length: 64, nullable: true }) + checksumSha256: string; + + @Column({ name: 'storage_key', type: 'text' }) + storageKey: string; + + @Column({ name: 'storage_url', type: 'text', nullable: true }) + storageUrl: string; + + @Column({ name: 'cdn_url', type: 'text', nullable: true }) + cdnUrl: string; + + @Column({ name: 'width', type: 'int', nullable: true }) + width: number; + + @Column({ name: 'height', type: 'int', nullable: true }) + height: number; + + @Column({ name: 'thumbnail_url', type: 'text', nullable: true }) + thumbnailUrl: string; + + @Column({ name: 'thumbnails', type: 'jsonb', default: {} }) + thumbnails: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'alt_text', type: 'text', nullable: true }) + altText: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'parent_version_id', type: 'uuid', nullable: true }) + parentVersionId: string; + + @Column({ name: 'is_latest', type: 'boolean', default: true }) + isLatest: boolean; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'access_count', type: 'int', default: 0 }) + accessCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'active' }) + status: FileStatus; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'processing_status', type: 'varchar', length: 20, nullable: true }) + processingStatus: ProcessingStatus; + + @Column({ name: 'processing_error', type: 'text', nullable: true }) + processingError: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; +} diff --git a/src/modules/storage/entities/folder.entity.ts b/src/modules/storage/entities/folder.entity.ts new file mode 100644 index 0000000..ce3fdae --- /dev/null +++ b/src/modules/storage/entities/folder.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'folders', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path']) +export class StorageFolder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @Index() + @Column({ name: 'path', type: 'text' }) + path: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'depth', type: 'int', default: 0 }) + depth: number; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string; + + @Column({ name: 'icon', type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'is_private', type: 'boolean', default: false }) + isPrivate: boolean; + + @Column({ name: 'owner_id', type: 'uuid', nullable: true }) + ownerId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'parent_id' }) + parent: StorageFolder; + + @OneToMany(() => StorageFolder, (folder) => folder.parent) + children: StorageFolder[]; +} diff --git a/src/modules/storage/entities/index.ts b/src/modules/storage/entities/index.ts new file mode 100644 index 0000000..e6f07f0 --- /dev/null +++ b/src/modules/storage/entities/index.ts @@ -0,0 +1,7 @@ +export { StorageBucket, BucketType, StorageProvider } from './bucket.entity'; +export { StorageFolder } from './folder.entity'; +export { StorageFile, FileCategory, FileStatus, ProcessingStatus } from './file.entity'; +export { FileAccessToken } from './file-access-token.entity'; +export { StorageUpload, UploadStatus } from './upload.entity'; +export { FileShare } from './file-share.entity'; +export { TenantUsage } from './tenant-usage.entity'; diff --git a/src/modules/storage/entities/tenant-usage.entity.ts b/src/modules/storage/entities/tenant-usage.entity.ts new file mode 100644 index 0000000..f02517a --- /dev/null +++ b/src/modules/storage/entities/tenant-usage.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'tenant_usage', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'monthYear']) +export class TenantUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @Column({ name: 'quota_bytes', type: 'bigint', nullable: true }) + quotaBytes: number; + + @Column({ name: 'quota_file_count', type: 'int', nullable: true }) + quotaFileCount: number; + + @Column({ name: 'usage_by_category', type: 'jsonb', default: {} }) + usageByCategory: Record; + + @Column({ name: 'monthly_upload_bytes', type: 'bigint', default: 0 }) + monthlyUploadBytes: number; + + @Column({ name: 'monthly_download_bytes', type: 'bigint', default: 0 }) + monthlyDownloadBytes: number; + + @Column({ name: 'month_year', type: 'varchar', length: 7 }) + monthYear: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; +} diff --git a/src/modules/storage/entities/upload.entity.ts b/src/modules/storage/entities/upload.entity.ts new file mode 100644 index 0000000..e095875 --- /dev/null +++ b/src/modules/storage/entities/upload.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; +import { StorageFile } from './file.entity'; + +export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +@Entity({ name: 'uploads', schema: 'storage' }) +export class StorageUpload { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'file_name', type: 'varchar', length: 255 }) + fileName: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType: string; + + @Column({ name: 'total_size_bytes', type: 'bigint', nullable: true }) + totalSizeBytes: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: UploadStatus; + + @Column({ name: 'uploaded_bytes', type: 'bigint', default: 0 }) + uploadedBytes: number; + + @Column({ name: 'upload_progress', type: 'decimal', precision: 5, scale: 2, default: 0 }) + uploadProgress: number; + + @Column({ name: 'total_chunks', type: 'int', nullable: true }) + totalChunks: number; + + @Column({ name: 'completed_chunks', type: 'int', default: 0 }) + completedChunks: number; + + @Column({ name: 'chunk_size_bytes', type: 'int', nullable: true }) + chunkSizeBytes: number; + + @Column({ name: 'chunks_status', type: 'jsonb', default: {} }) + chunksStatus: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'file_id', type: 'uuid', nullable: true }) + fileId: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_chunk_at', type: 'timestamptz', nullable: true }) + lastChunkAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; + + @ManyToOne(() => StorageFile, { nullable: true }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/index.ts b/src/modules/storage/index.ts new file mode 100644 index 0000000..f55c5c9 --- /dev/null +++ b/src/modules/storage/index.ts @@ -0,0 +1,5 @@ +export { StorageModule, StorageModuleOptions } from './storage.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/storage/services/index.ts b/src/modules/storage/services/index.ts new file mode 100644 index 0000000..36fd7d0 --- /dev/null +++ b/src/modules/storage/services/index.ts @@ -0,0 +1 @@ +export { StorageService, FileSearchFilters } from './storage.service'; diff --git a/src/modules/storage/services/storage.service.ts b/src/modules/storage/services/storage.service.ts new file mode 100644 index 0000000..766c7c2 --- /dev/null +++ b/src/modules/storage/services/storage.service.ts @@ -0,0 +1,332 @@ +import { Repository, FindOptionsWhere, In, IsNull } from 'typeorm'; +import { StorageBucket, StorageFolder, StorageFile } from '../entities'; + +export interface FileSearchFilters { + category?: string; + mimeType?: string; + folderId?: string; + isPublic?: boolean; + uploadedBy?: string; +} + +export class StorageService { + constructor( + private readonly bucketRepository: Repository, + private readonly folderRepository: Repository, + private readonly fileRepository: Repository + ) {} + + // ============================================ + // BUCKETS + // ============================================ + + async findAllBuckets(tenantId: string): Promise { + return this.bucketRepository.find({ + where: { tenantId }, + order: { name: 'ASC' }, + }); + } + + async findBucket(id: string): Promise { + return this.bucketRepository.findOne({ where: { id } }); + } + + async findBucketByName(tenantId: string, name: string): Promise { + return this.bucketRepository.findOne({ where: { tenantId, name } }); + } + + async createBucket( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const bucket = this.bucketRepository.create({ + ...data, + tenantId, + createdBy, + }); + return this.bucketRepository.save(bucket); + } + + async updateBucket(id: string, data: Partial): Promise { + const bucket = await this.findBucket(id); + if (!bucket) return null; + + Object.assign(bucket, data); + return this.bucketRepository.save(bucket); + } + + async deleteBucket(id: string): Promise { + // Check if bucket has files + const fileCount = await this.fileRepository.count({ where: { bucketId: id } }); + if (fileCount > 0) { + throw new Error('Cannot delete bucket with files. Delete files first.'); + } + + const result = await this.bucketRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async updateBucketUsage(id: string, sizeChange: number, fileCountChange: number): Promise { + await this.bucketRepository + .createQueryBuilder() + .update() + .set({ + currentSizeBytes: () => `current_size_bytes + ${sizeChange}`, + fileCount: () => `file_count + ${fileCountChange}`, + }) + .where('id = :id', { id }) + .execute(); + } + + // ============================================ + // FOLDERS + // ============================================ + + async findFoldersInBucket(bucketId: string, parentId?: string): Promise { + const where: FindOptionsWhere = { bucketId }; + if (parentId) { + where.parentId = parentId; + } else { + where.parentId = IsNull(); + } + + return this.folderRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + async findFolder(id: string): Promise { + return this.folderRepository.findOne({ where: { id } }); + } + + async findFolderByPath(bucketId: string, path: string): Promise { + return this.folderRepository.findOne({ where: { bucketId, path } }); + } + + async createFolder( + tenantId: string, + bucketId: string, + data: Partial, + createdBy?: string + ): Promise { + const folder = this.folderRepository.create({ + ...data, + tenantId, + bucketId, + createdBy, + }); + return this.folderRepository.save(folder); + } + + async updateFolder(id: string, data: Partial): Promise { + const folder = await this.findFolder(id); + if (!folder) return null; + + Object.assign(folder, data); + return this.folderRepository.save(folder); + } + + async deleteFolder(id: string, recursive: boolean = false): Promise { + const folder = await this.findFolder(id); + if (!folder) return false; + + // Check for children + const childFolders = await this.folderRepository.count({ where: { parentId: id } }); + const childFiles = await this.fileRepository.count({ where: { folderId: id } }); + + if ((childFolders > 0 || childFiles > 0) && !recursive) { + throw new Error('Folder is not empty. Use recursive delete or remove contents first.'); + } + + if (recursive) { + // Delete all files in folder + await this.fileRepository.softDelete({ folderId: id }); + // Delete all subfolders (recursive deletion would need proper implementation) + await this.folderRepository.softDelete({ parentId: id }); + } + + const result = await this.folderRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // FILES + // ============================================ + + async findFilesInFolder( + bucketId: string, + folderId?: string, + filters: FileSearchFilters = {} + ): Promise { + const where: FindOptionsWhere = { bucketId }; + + if (folderId) { + where.folderId = folderId; + } else { + where.folderId = IsNull(); + } + + if (filters.category) where.category = filters.category as any; + if (filters.mimeType) where.mimeType = filters.mimeType; + if (filters.isPublic !== undefined) where.isPublic = filters.isPublic; + if (filters.uploadedBy) where.uploadedBy = filters.uploadedBy; + + return this.fileRepository.find({ + where, + order: { originalName: 'ASC' }, + }); + } + + async findFile(id: string): Promise { + return this.fileRepository.findOne({ + where: { id }, + relations: ['bucket', 'folder'], + }); + } + + async findFileByStorageKey(storageKey: string): Promise { + return this.fileRepository.findOne({ where: { storageKey } }); + } + + async createFile( + tenantId: string, + bucketId: string, + data: Partial, + uploadedBy?: string + ): Promise { + const file = this.fileRepository.create({ + ...data, + tenantId, + bucketId, + uploadedBy, + status: 'active', + }); + + const savedFile = await this.fileRepository.save(file); + + // Update bucket usage + await this.updateBucketUsage(bucketId, data.sizeBytes || 0, 1); + + return savedFile; + } + + async updateFile(id: string, data: Partial): Promise { + const file = await this.findFile(id); + if (!file) return null; + + Object.assign(file, data); + return this.fileRepository.save(file); + } + + async deleteFile(id: string): Promise { + const file = await this.findFile(id); + if (!file) return false; + + // Update bucket usage + await this.updateBucketUsage(file.bucketId, -(file.sizeBytes || 0), -1); + + const result = await this.fileRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async incrementDownloadCount(id: string): Promise { + await this.fileRepository + .createQueryBuilder() + .update() + .set({ + downloadCount: () => 'download_count + 1', + lastAccessedAt: new Date(), + }) + .where('id = :id', { id }) + .execute(); + } + + async updateProcessingStatus( + id: string, + status: string, + metadata?: Record + ): Promise { + const updates: Partial = { processingStatus: status as any }; + if (metadata) { + updates.metadata = metadata; + } + if (status === 'completed') { + updates.processedAt = new Date(); + } + await this.fileRepository.update(id, updates); + } + + // ============================================ + // SEARCH & QUERIES + // ============================================ + + async searchFiles( + tenantId: string, + query: string, + filters: FileSearchFilters = {} + ): Promise { + const qb = this.fileRepository + .createQueryBuilder('file') + .where('file.tenant_id = :tenantId', { tenantId }) + .andWhere( + '(file.original_name ILIKE :query OR file.description ILIKE :query)', + { query: `%${query}%` } + ); + + if (filters.category) { + qb.andWhere('file.category = :category', { category: filters.category }); + } + if (filters.mimeType) { + qb.andWhere('file.mime_type = :mimeType', { mimeType: filters.mimeType }); + } + if (filters.isPublic !== undefined) { + qb.andWhere('file.is_public = :isPublic', { isPublic: filters.isPublic }); + } + + return qb.orderBy('file.created_at', 'DESC').take(100).getMany(); + } + + async getStorageUsage(tenantId: string): Promise<{ + totalBytes: number; + fileCount: number; + byCategory: Record; + }> { + const buckets = await this.bucketRepository.find({ where: { tenantId } }); + + let totalBytes = 0; + let fileCount = 0; + for (const bucket of buckets) { + totalBytes += bucket.currentSizeBytes || 0; + fileCount += bucket.fileCount || 0; + } + + const categoryStats = await this.fileRepository + .createQueryBuilder('file') + .select('file.category', 'category') + .addSelect('SUM(file.size_bytes)', 'bytes') + .addSelect('COUNT(*)', 'count') + .where('file.tenant_id = :tenantId', { tenantId }) + .groupBy('file.category') + .getRawMany(); + + const byCategory: Record = {}; + for (const stat of categoryStats) { + byCategory[stat.category || 'uncategorized'] = { + bytes: parseInt(stat.bytes) || 0, + count: parseInt(stat.count) || 0, + }; + } + + return { totalBytes, fileCount, byCategory }; + } + + async findRecentFiles(tenantId: string, limit: number = 20): Promise { + return this.fileRepository.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } +} diff --git a/src/modules/storage/storage.module.ts b/src/modules/storage/storage.module.ts new file mode 100644 index 0000000..37a107e --- /dev/null +++ b/src/modules/storage/storage.module.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { StorageService } from './services'; +import { StorageController } from './controllers'; +import { + StorageBucket, + StorageFolder, + StorageFile, +} from './entities'; + +export interface StorageModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class StorageModule { + public router: Router; + public storageService: StorageService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: StorageModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const bucketRepository = this.dataSource.getRepository(StorageBucket); + const folderRepository = this.dataSource.getRepository(StorageFolder); + const fileRepository = this.dataSource.getRepository(StorageFile); + + this.storageService = new StorageService( + bucketRepository, + folderRepository, + fileRepository + ); + } + + private initializeRoutes(): void { + const storageController = new StorageController(this.storageService); + this.router.use(`${this.basePath}/storage`, storageController.router); + } + + static getEntities(): Function[] { + return [ + StorageBucket, + StorageFolder, + StorageFile, + ]; + } +} diff --git a/src/modules/warehouses/controllers/index.ts b/src/modules/warehouses/controllers/index.ts new file mode 100644 index 0000000..bb75c7b --- /dev/null +++ b/src/modules/warehouses/controllers/index.ts @@ -0,0 +1 @@ +export { WarehousesController } from './warehouses.controller'; diff --git a/src/modules/warehouses/controllers/warehouses.controller.ts b/src/modules/warehouses/controllers/warehouses.controller.ts new file mode 100644 index 0000000..de7579a --- /dev/null +++ b/src/modules/warehouses/controllers/warehouses.controller.ts @@ -0,0 +1,313 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { WarehousesService } from '../services/warehouses.service'; +import { + CreateWarehouseDto, + UpdateWarehouseDto, + CreateWarehouseLocationDto, + UpdateWarehouseLocationDto, +} from '../dto'; + +export class WarehousesController { + public router: Router; + + constructor(private readonly warehousesService: WarehousesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Warehouses + this.router.get('/', this.findAll.bind(this)); + this.router.get('/active', this.getActiveWarehouses.bind(this)); + this.router.get('/default', this.getDefaultWarehouse.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Locations + this.router.get('/:id/locations', this.getLocations.bind(this)); + this.router.get('/:id/locations/tree', this.getLocationTree.bind(this)); + this.router.get('/:id/locations/pickable', this.getPickableLocations.bind(this)); + this.router.get('/:id/locations/receivable', this.getReceivableLocations.bind(this)); + this.router.post('/:id/locations', this.createLocation.bind(this)); + this.router.patch('/:id/locations/:locationId', this.updateLocation.bind(this)); + this.router.delete('/:id/locations/:locationId', this.deleteLocation.bind(this)); + } + + // ==================== Warehouses ==================== + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { search, warehouseType, branchId, isActive, limit, offset } = req.query; + + const result = await this.warehousesService.findAll({ + tenantId, + search: search as string, + warehouseType: warehouseType as 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual', + branchId: branchId as string, + isActive: isActive ? isActive === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const warehouse = await this.warehousesService.findOne(id, tenantId); + + if (!warehouse) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { code } = req.params; + const warehouse = await this.warehousesService.findByCode(code, tenantId); + + if (!warehouse) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async getDefaultWarehouse(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const warehouse = await this.warehousesService.getDefaultWarehouse(tenantId); + + if (!warehouse) { + res.status(404).json({ error: 'No default warehouse found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async getActiveWarehouses(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const warehouses = await this.warehousesService.getActiveWarehouses(tenantId); + res.json({ data: warehouses }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateWarehouseDto = req.body; + const warehouse = await this.warehousesService.create(tenantId, dto, userId); + res.status(201).json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateWarehouseDto = req.body; + const warehouse = await this.warehousesService.update(id, tenantId, dto, userId); + + if (!warehouse) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.warehousesService.delete(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ==================== Locations ==================== + + private async getLocations(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { search, locationType, parentId, isActive, limit, offset } = req.query; + + const result = await this.warehousesService.findAllLocations({ + warehouseId: id, + search: search as string, + locationType: locationType as 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin', + parentId: parentId as string, + isActive: isActive ? isActive === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async getLocationTree(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const locations = await this.warehousesService.getLocationTree(id); + res.json({ data: locations }); + } catch (error) { + next(error); + } + } + + private async getPickableLocations(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const locations = await this.warehousesService.getPickableLocations(id); + res.json({ data: locations }); + } catch (error) { + next(error); + } + } + + private async getReceivableLocations( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const locations = await this.warehousesService.getReceivableLocations(id); + res.json({ data: locations }); + } catch (error) { + next(error); + } + } + + private async createLocation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreateWarehouseLocationDto = { ...req.body, warehouseId: id }; + const location = await this.warehousesService.createLocation(dto); + res.status(201).json({ data: location }); + } catch (error) { + next(error); + } + } + + private async updateLocation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { locationId } = req.params; + const dto: UpdateWarehouseLocationDto = req.body; + const location = await this.warehousesService.updateLocation(locationId, dto); + + if (!location) { + res.status(404).json({ error: 'Location not found' }); + return; + } + + res.json({ data: location }); + } catch (error) { + next(error); + } + } + + private async deleteLocation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { locationId } = req.params; + const deleted = await this.warehousesService.deleteLocation(locationId); + + if (!deleted) { + res.status(404).json({ error: 'Location not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/warehouses/dto/create-warehouse.dto.ts b/src/modules/warehouses/dto/create-warehouse.dto.ts new file mode 100644 index 0000000..297a719 --- /dev/null +++ b/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -0,0 +1,378 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsUUID, + IsArray, + IsObject, + MaxLength, + IsEnum, +} from 'class-validator'; + +export class CreateWarehouseDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + branchId?: string; + + @IsOptional() + @IsEnum(['standard', 'transit', 'returns', 'quarantine', 'virtual']) + warehouseType?: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + managerName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsObject() + settings?: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +export class UpdateWarehouseDto { + @IsOptional() + @IsString() + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + branchId?: string; + + @IsOptional() + @IsEnum(['standard', 'transit', 'returns', 'quarantine', 'virtual']) + warehouseType?: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + managerName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsObject() + settings?: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +export class CreateWarehouseLocationDto { + @IsUUID() + warehouseId: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsString() + @MaxLength(30) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsOptional() + @IsEnum(['zone', 'aisle', 'rack', 'shelf', 'bin']) + locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + @IsOptional() + @IsString() + @MaxLength(10) + aisle?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + rack?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + shelf?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + bin?: string; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedProductTypes?: string[]; + + @IsOptional() + @IsObject() + temperatureRange?: { min?: number; max?: number }; + + @IsOptional() + @IsObject() + humidityRange?: { min?: number; max?: number }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isPickable?: boolean; + + @IsOptional() + @IsBoolean() + isReceivable?: boolean; +} + +export class UpdateWarehouseLocationDto { + @IsOptional() + @IsString() + @MaxLength(30) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsOptional() + @IsEnum(['zone', 'aisle', 'rack', 'shelf', 'bin']) + locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + @IsOptional() + @IsString() + @MaxLength(10) + aisle?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + rack?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + shelf?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + bin?: string; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedProductTypes?: string[]; + + @IsOptional() + @IsObject() + temperatureRange?: { min?: number; max?: number }; + + @IsOptional() + @IsObject() + humidityRange?: { min?: number; max?: number }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isPickable?: boolean; + + @IsOptional() + @IsBoolean() + isReceivable?: boolean; +} diff --git a/src/modules/warehouses/dto/index.ts b/src/modules/warehouses/dto/index.ts new file mode 100644 index 0000000..f76b571 --- /dev/null +++ b/src/modules/warehouses/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateWarehouseDto, + UpdateWarehouseDto, + CreateWarehouseLocationDto, + UpdateWarehouseLocationDto, +} from './create-warehouse.dto'; diff --git a/src/modules/warehouses/entities/index.ts b/src/modules/warehouses/entities/index.ts new file mode 100644 index 0000000..fb6b6e3 --- /dev/null +++ b/src/modules/warehouses/entities/index.ts @@ -0,0 +1,3 @@ +export { Warehouse } from './warehouse.entity'; +export { WarehouseLocation } from './warehouse-location.entity'; +export { WarehouseZone } from './warehouse-zone.entity'; diff --git a/src/modules/warehouses/entities/warehouse-location.entity.ts b/src/modules/warehouses/entities/warehouse-location.entity.ts new file mode 100644 index 0000000..030ff0a --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-location.entity.ts @@ -0,0 +1,111 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_locations', schema: 'inventory' }) +export class WarehouseLocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => WarehouseLocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: WarehouseLocation; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + // Tipo de ubicacion + @Index() + @Column({ name: 'location_type', type: 'varchar', length: 20, default: 'shelf' }) + locationType: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Coordenadas dentro del almacen + @Column({ type: 'varchar', length: 10, nullable: true }) + aisle: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + rack: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + shelf: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + bin: string; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Restricciones + @Column({ name: 'allowed_product_types', type: 'text', array: true, default: '{}' }) + allowedProductTypes: string[]; + + @Column({ name: 'temperature_range', type: 'jsonb', nullable: true }) + temperatureRange: { min?: number; max?: number }; + + @Column({ name: 'humidity_range', type: 'jsonb', nullable: true }) + humidityRange: { min?: number; max?: number }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_pickable', type: 'boolean', default: true }) + isPickable: boolean; + + @Column({ name: 'is_receivable', type: 'boolean', default: true }) + isReceivable: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/warehouses/entities/warehouse-zone.entity.ts b/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 0000000..d710cc5 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_zones', schema: 'inventory' }) +export class WarehouseZone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color?: string; + + @Index() + @Column({ name: 'zone_type', type: 'varchar', length: 20, default: 'storage' }) + zoneType: 'storage' | 'picking' | 'packing' | 'shipping' | 'receiving' | 'quarantine'; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/warehouses/entities/warehouse.entity.ts b/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 0000000..dc8c6f1 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,115 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'warehouses', schema: 'inventory' }) +export class Warehouse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'warehouse_type', type: 'varchar', length: 20, default: 'standard' }) + warehouseType: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + // Direccion + @Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Contacto + @Column({ name: 'manager_name', type: 'varchar', length: 100, nullable: true }) + managerName: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Configuracion + @Column({ type: 'jsonb', default: {} }) + settings: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + // 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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/warehouses/index.ts b/src/modules/warehouses/index.ts new file mode 100644 index 0000000..2328bc3 --- /dev/null +++ b/src/modules/warehouses/index.ts @@ -0,0 +1,5 @@ +export { WarehousesModule, WarehousesModuleOptions } from './warehouses.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/warehouses/services/index.ts b/src/modules/warehouses/services/index.ts new file mode 100644 index 0000000..94a9de4 --- /dev/null +++ b/src/modules/warehouses/services/index.ts @@ -0,0 +1,5 @@ +export { + WarehousesService, + WarehouseSearchParams, + LocationSearchParams, +} from './warehouses.service'; diff --git a/src/modules/warehouses/services/warehouses.service.ts b/src/modules/warehouses/services/warehouses.service.ts new file mode 100644 index 0000000..8e8f8f4 --- /dev/null +++ b/src/modules/warehouses/services/warehouses.service.ts @@ -0,0 +1,294 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Warehouse, WarehouseLocation } from '../entities'; +import { + CreateWarehouseDto, + UpdateWarehouseDto, + CreateWarehouseLocationDto, + UpdateWarehouseLocationDto, +} from '../dto'; + +export interface WarehouseSearchParams { + tenantId: string; + search?: string; + warehouseType?: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + branchId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export interface LocationSearchParams { + warehouseId: string; + search?: string; + locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + parentId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export class WarehousesService { + constructor( + private readonly warehouseRepository: Repository, + private readonly locationRepository: Repository + ) {} + + // ==================== Warehouses ==================== + + async findAll(params: WarehouseSearchParams): Promise<{ data: Warehouse[]; total: number }> { + const { tenantId, search, warehouseType, branchId, isActive, limit = 50, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (warehouseType) { + baseWhere.warehouseType = warehouseType; + } + + if (branchId) { + baseWhere.branchId = branchId; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.warehouseRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.warehouseRepository.findOne({ where: { id, tenantId } }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.warehouseRepository.findOne({ where: { code, tenantId } }); + } + + async getDefaultWarehouse(tenantId: string): Promise { + return this.warehouseRepository.findOne({ where: { tenantId, isDefault: true, isActive: true } }); + } + + async create(tenantId: string, dto: CreateWarehouseDto, createdBy?: string): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A warehouse with this code already exists'); + } + + // If setting as default, unset other defaults + if (dto.isDefault) { + await this.warehouseRepository.update({ tenantId, isDefault: true }, { isDefault: false }); + } + + const warehouse = this.warehouseRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.warehouseRepository.save(warehouse); + } + + async update( + id: string, + tenantId: string, + dto: UpdateWarehouseDto, + updatedBy?: string + ): Promise { + const warehouse = await this.findOne(id, tenantId); + if (!warehouse) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== warehouse.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('A warehouse with this code already exists'); + } + } + + // If setting as default, unset other defaults + if (dto.isDefault && !warehouse.isDefault) { + await this.warehouseRepository.update({ tenantId, isDefault: true }, { isDefault: false }); + } + + Object.assign(warehouse, { + ...dto, + updatedBy, + }); + + return this.warehouseRepository.save(warehouse); + } + + async delete(id: string, tenantId: string): Promise { + const warehouse = await this.findOne(id, tenantId); + if (!warehouse) return false; + + // Check if warehouse has locations + const locations = await this.locationRepository.findOne({ where: { warehouseId: id } }); + if (locations) { + throw new Error('Cannot delete warehouse with locations'); + } + + const result = await this.warehouseRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getActiveWarehouses(tenantId: string): Promise { + return this.warehouseRepository.find({ + where: { tenantId, isActive: true }, + order: { isDefault: 'DESC', name: 'ASC' }, + }); + } + + // ==================== Locations ==================== + + async findAllLocations( + params: LocationSearchParams + ): Promise<{ data: WarehouseLocation[]; total: number }> { + const { warehouseId, search, locationType, parentId, isActive, limit = 100, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { warehouseId }; + + if (locationType) { + baseWhere.locationType = locationType; + } + + if (parentId !== undefined) { + baseWhere.parentId = parentId || undefined; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) }, + { ...baseWhere, barcode: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.locationRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { hierarchyPath: 'ASC', code: 'ASC' }, + }); + + return { data, total }; + } + + async findLocation(id: string): Promise { + return this.locationRepository.findOne({ where: { id }, relations: ['warehouse'] }); + } + + async findLocationByCode(code: string, warehouseId: string): Promise { + return this.locationRepository.findOne({ where: { code, warehouseId } }); + } + + async findLocationByBarcode(barcode: string): Promise { + return this.locationRepository.findOne({ where: { barcode }, relations: ['warehouse'] }); + } + + async createLocation(dto: CreateWarehouseLocationDto): Promise { + // Check for existing code in warehouse + const existingCode = await this.findLocationByCode(dto.code, dto.warehouseId); + if (existingCode) { + throw new Error('A location with this code already exists in this warehouse'); + } + + // Calculate hierarchy if parent exists + let hierarchyPath = `/${dto.code}`; + let hierarchyLevel = 0; + + if (dto.parentId) { + const parent = await this.findLocation(dto.parentId); + if (parent) { + hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; + hierarchyLevel = parent.hierarchyLevel + 1; + } + } + + const location = this.locationRepository.create({ + ...dto, + hierarchyPath, + hierarchyLevel, + }); + + return this.locationRepository.save(location); + } + + async updateLocation( + id: string, + dto: UpdateWarehouseLocationDto + ): Promise { + const location = await this.findLocation(id); + if (!location) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== location.code) { + const existing = await this.findLocationByCode(dto.code, location.warehouseId); + if (existing) { + throw new Error('A location with this code already exists in this warehouse'); + } + } + + Object.assign(location, dto); + return this.locationRepository.save(location); + } + + async deleteLocation(id: string): Promise { + const location = await this.findLocation(id); + if (!location) return false; + + // Check if location has children + const children = await this.locationRepository.findOne({ where: { parentId: id } }); + if (children) { + throw new Error('Cannot delete location with children'); + } + + const result = await this.locationRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getLocationTree(warehouseId: string): Promise { + return this.locationRepository.find({ + where: { warehouseId, isActive: true }, + order: { hierarchyPath: 'ASC' }, + }); + } + + async getPickableLocations(warehouseId: string): Promise { + return this.locationRepository.find({ + where: { warehouseId, isActive: true, isPickable: true }, + order: { code: 'ASC' }, + }); + } + + async getReceivableLocations(warehouseId: string): Promise { + return this.locationRepository.find({ + where: { warehouseId, isActive: true, isReceivable: true }, + order: { code: 'ASC' }, + }); + } +} diff --git a/src/modules/warehouses/warehouses.module.ts b/src/modules/warehouses/warehouses.module.ts new file mode 100644 index 0000000..ad316ec --- /dev/null +++ b/src/modules/warehouses/warehouses.module.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { WarehousesService } from './services'; +import { WarehousesController } from './controllers'; +import { Warehouse, WarehouseLocation } from './entities'; + +export interface WarehousesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class WarehousesModule { + public router: Router; + public warehousesService: WarehousesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: WarehousesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const warehouseRepository = this.dataSource.getRepository(Warehouse); + const locationRepository = this.dataSource.getRepository(WarehouseLocation); + + this.warehousesService = new WarehousesService(warehouseRepository, locationRepository); + } + + private initializeRoutes(): void { + const warehousesController = new WarehousesController(this.warehousesService); + this.router.use(`${this.basePath}/warehouses`, warehousesController.router); + } + + static getEntities(): Function[] { + return [Warehouse, WarehouseLocation]; + } +} diff --git a/src/modules/webhooks/controllers/index.ts b/src/modules/webhooks/controllers/index.ts new file mode 100644 index 0000000..7423a12 --- /dev/null +++ b/src/modules/webhooks/controllers/index.ts @@ -0,0 +1 @@ +export { WebhooksController } from './webhooks.controller'; diff --git a/src/modules/webhooks/controllers/webhooks.controller.ts b/src/modules/webhooks/controllers/webhooks.controller.ts new file mode 100644 index 0000000..8887b2d --- /dev/null +++ b/src/modules/webhooks/controllers/webhooks.controller.ts @@ -0,0 +1,276 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { WebhooksService } from '../services/webhooks.service'; + +export class WebhooksController { + public router: Router; + + constructor(private readonly webhooksService: WebhooksService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Event Types + this.router.get('/event-types', this.findAllEventTypes.bind(this)); + this.router.get('/event-types/:code', this.findEventTypeByCode.bind(this)); + this.router.get('/event-types/category/:category', this.findEventTypesByCategory.bind(this)); + + // Endpoints + this.router.get('/endpoints', this.findAllEndpoints.bind(this)); + this.router.get('/endpoints/active', this.findActiveEndpoints.bind(this)); + this.router.get('/endpoints/:id', this.findEndpoint.bind(this)); + this.router.post('/endpoints', this.createEndpoint.bind(this)); + this.router.patch('/endpoints/:id', this.updateEndpoint.bind(this)); + this.router.delete('/endpoints/:id', this.deleteEndpoint.bind(this)); + this.router.patch('/endpoints/:id/toggle', this.toggleEndpoint.bind(this)); + this.router.get('/endpoints/:id/stats', this.getEndpointStats.bind(this)); + this.router.get('/endpoints/:id/deliveries', this.findDeliveriesForEndpoint.bind(this)); + + // Events + this.router.post('/events', this.createEvent.bind(this)); + this.router.get('/events/:id', this.findEvent.bind(this)); + this.router.get('/events/pending', this.findPendingEvents.bind(this)); + + // Deliveries + this.router.get('/deliveries/:id', this.findDelivery.bind(this)); + this.router.get('/events/:eventId/deliveries', this.findDeliveriesForEvent.bind(this)); + } + + // ============================================ + // EVENT TYPES + // ============================================ + + private async findAllEventTypes(req: Request, res: Response, next: NextFunction): Promise { + try { + const eventTypes = await this.webhooksService.findAllEventTypes(); + res.json({ data: eventTypes, total: eventTypes.length }); + } catch (error) { + next(error); + } + } + + private async findEventTypeByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const eventType = await this.webhooksService.findEventTypeByCode(code); + + if (!eventType) { + res.status(404).json({ error: 'Event type not found' }); + return; + } + + res.json({ data: eventType }); + } catch (error) { + next(error); + } + } + + private async findEventTypesByCategory(req: Request, res: Response, next: NextFunction): Promise { + try { + const { category } = req.params; + const eventTypes = await this.webhooksService.findEventTypesByCategory(category); + res.json({ data: eventTypes, total: eventTypes.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // ENDPOINTS + // ============================================ + + private async findAllEndpoints(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const endpoints = await this.webhooksService.findAllEndpoints(tenantId); + res.json({ data: endpoints, total: endpoints.length }); + } catch (error) { + next(error); + } + } + + private async findActiveEndpoints(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const endpoints = await this.webhooksService.findActiveEndpoints(tenantId); + res.json({ data: endpoints, total: endpoints.length }); + } catch (error) { + next(error); + } + } + + private async findEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const endpoint = await this.webhooksService.findEndpoint(id); + + if (!endpoint) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async createEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const endpoint = await this.webhooksService.createEndpoint(tenantId, req.body, userId); + res.status(201).json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async updateEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + + const endpoint = await this.webhooksService.updateEndpoint(id, req.body, userId); + + if (!endpoint) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async deleteEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.webhooksService.deleteEndpoint(id); + + if (!deleted) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async toggleEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { isActive } = req.body; + + const endpoint = await this.webhooksService.toggleEndpoint(id, isActive); + + if (!endpoint) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async getEndpointStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const startDate = new Date(req.query.startDate as string || Date.now() - 7 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await this.webhooksService.getEndpointStats(id, startDate, endDate); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + private async findDeliveriesForEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const limit = parseInt(req.query.limit as string) || 50; + + const deliveries = await this.webhooksService.findDeliveriesForEndpoint(id, limit); + res.json({ data: deliveries, total: deliveries.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // EVENTS + // ============================================ + + private async createEvent(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const event = await this.webhooksService.createEvent(tenantId, req.body); + res.status(201).json({ data: event }); + } catch (error) { + next(error); + } + } + + private async findEvent(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const event = await this.webhooksService.findEvent(id); + + if (!event) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + res.json({ data: event }); + } catch (error) { + next(error); + } + } + + private async findPendingEvents(req: Request, res: Response, next: NextFunction): Promise { + try { + const limit = parseInt(req.query.limit as string) || 100; + const events = await this.webhooksService.findPendingEvents(limit); + res.json({ data: events, total: events.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // DELIVERIES + // ============================================ + + private async findDelivery(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const delivery = await this.webhooksService.findDelivery(id); + + if (!delivery) { + res.status(404).json({ error: 'Delivery not found' }); + return; + } + + res.json({ data: delivery }); + } catch (error) { + next(error); + } + } + + private async findDeliveriesForEvent(req: Request, res: Response, next: NextFunction): Promise { + try { + const { eventId } = req.params; + const deliveries = await this.webhooksService.findDeliveriesForEvent(eventId); + res.json({ data: deliveries, total: deliveries.length }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/webhooks/dto/index.ts b/src/modules/webhooks/dto/index.ts new file mode 100644 index 0000000..e89b97d --- /dev/null +++ b/src/modules/webhooks/dto/index.ts @@ -0,0 +1,8 @@ +export { + CreateWebhookEndpointDto, + UpdateWebhookEndpointDto, + ToggleWebhookEndpointDto, + CreateWebhookEventDto, + UpdateDeliveryResultDto, + ScheduleRetryDto, +} from './webhook.dto'; diff --git a/src/modules/webhooks/dto/webhook.dto.ts b/src/modules/webhooks/dto/webhook.dto.ts new file mode 100644 index 0000000..92f6763 --- /dev/null +++ b/src/modules/webhooks/dto/webhook.dto.ts @@ -0,0 +1,178 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsUrl, + MaxLength, + MinLength, + Min, + Max, +} from 'class-validator'; + +// ============================================ +// ENDPOINT DTOs +// ============================================ + +export class CreateWebhookEndpointDto { + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsUrl() + @MaxLength(500) + url: string; + + @IsOptional() + @IsString() + @MaxLength(20) + authType?: string; + + @IsOptional() + @IsObject() + authConfig?: Record; + + @IsArray() + @IsString({ each: true }) + subscribedEvents: string[]; + + @IsOptional() + @IsObject() + headers?: Record; + + @IsOptional() + @IsNumber() + @Min(1000) + @Max(30000) + timeout?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(10) + retryCount?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateWebhookEndpointDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUrl() + @MaxLength(500) + url?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + authType?: string; + + @IsOptional() + @IsObject() + authConfig?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + subscribedEvents?: string[]; + + @IsOptional() + @IsObject() + headers?: Record; + + @IsOptional() + @IsNumber() + @Min(1000) + @Max(30000) + timeout?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(10) + retryCount?: number; +} + +export class ToggleWebhookEndpointDto { + @IsBoolean() + isActive: boolean; +} + +// ============================================ +// EVENT DTOs +// ============================================ + +export class CreateWebhookEventDto { + @IsString() + @MaxLength(100) + eventType: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsObject() + payload: Record; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// DELIVERY DTOs +// ============================================ + +export class UpdateDeliveryResultDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsNumber() + responseStatus?: number; + + @IsOptional() + @IsString() + responseBody?: string; + + @IsOptional() + @IsNumber() + durationMs?: number; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +export class ScheduleRetryDto { + @IsString() + nextRetryAt: string; + + @IsNumber() + @Min(1) + attemptNumber: number; +} diff --git a/src/modules/webhooks/entities/delivery.entity.ts b/src/modules/webhooks/entities/delivery.entity.ts new file mode 100644 index 0000000..3cb91d8 --- /dev/null +++ b/src/modules/webhooks/entities/delivery.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type DeliveryStatus = 'pending' | 'sending' | 'delivered' | 'failed' | 'retrying' | 'cancelled'; + +@Entity({ name: 'deliveries', schema: 'webhooks' }) +export class WebhookDelivery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'event_id', type: 'uuid' }) + eventId: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'payload_hash', type: 'varchar', length: 64, nullable: true }) + payloadHash: string; + + @Column({ name: 'request_url', type: 'text' }) + requestUrl: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10 }) + requestMethod: string; + + @Column({ name: 'request_headers', type: 'jsonb', default: {} }) + requestHeaders: Record; + + @Column({ name: 'response_status', type: 'int', nullable: true }) + responseStatus: number; + + @Column({ name: 'response_headers', type: 'jsonb', default: {} }) + responseHeaders: Record; + + @Column({ name: 'response_body', type: 'text', nullable: true }) + responseBody: string; + + @Column({ name: 'response_time_ms', type: 'int', nullable: true }) + responseTimeMs: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: DeliveryStatus; + + @Column({ name: 'attempt_number', type: 'int', default: 1 }) + attemptNumber: number; + + @Column({ name: 'max_attempts', type: 'int', default: 5 }) + maxAttempts: number; + + @Index() + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string; + + @Column({ name: 'scheduled_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + scheduledAt: Date; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint-log.entity.ts b/src/modules/webhooks/entities/endpoint-log.entity.ts new file mode 100644 index 0000000..513e4a7 --- /dev/null +++ b/src/modules/webhooks/entities/endpoint-log.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type WebhookLogType = 'config_changed' | 'activated' | 'deactivated' | 'verified' | 'error' | 'rate_limited' | 'created'; + +@Entity({ name: 'endpoint_logs', schema: 'webhooks' }) +export class WebhookEndpointLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'log_type', type: 'varchar', length: 30 }) + logType: WebhookLogType; + + @Column({ name: 'message', type: 'text', nullable: true }) + message: string; + + @Column({ name: 'details', type: 'jsonb', default: {} }) + details: Record; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint.entity.ts b/src/modules/webhooks/entities/endpoint.entity.ts new file mode 100644 index 0000000..7f12106 --- /dev/null +++ b/src/modules/webhooks/entities/endpoint.entity.ts @@ -0,0 +1,110 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AuthType = 'none' | 'basic' | 'bearer' | 'hmac' | 'oauth2'; + +@Entity({ name: 'endpoints', schema: 'webhooks' }) +@Unique(['tenantId', 'url']) +export class WebhookEndpoint { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'url', type: 'text' }) + url: string; + + @Column({ name: 'http_method', type: 'varchar', length: 10, default: 'POST' }) + httpMethod: string; + + @Column({ name: 'auth_type', type: 'varchar', length: 30, default: 'none' }) + authType: AuthType; + + @Column({ name: 'auth_config', type: 'jsonb', default: {} }) + authConfig: Record; + + @Column({ name: 'custom_headers', type: 'jsonb', default: {} }) + customHeaders: Record; + + @Column({ name: 'subscribed_events', type: 'text', array: true, default: [] }) + subscribedEvents: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'retry_enabled', type: 'boolean', default: true }) + retryEnabled: boolean; + + @Column({ name: 'max_retries', type: 'int', default: 5 }) + maxRetries: number; + + @Column({ name: 'retry_delay_seconds', type: 'int', default: 60 }) + retryDelaySeconds: number; + + @Column({ name: 'retry_backoff_multiplier', type: 'decimal', precision: 3, scale: 1, default: 2.0 }) + retryBackoffMultiplier: number; + + @Column({ name: 'timeout_seconds', type: 'int', default: 30 }) + timeoutSeconds: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'signing_secret', type: 'varchar', length: 255, nullable: true }) + signingSecret: string; + + @Column({ name: 'total_deliveries', type: 'int', default: 0 }) + totalDeliveries: number; + + @Column({ name: 'successful_deliveries', type: 'int', default: 0 }) + successfulDeliveries: number; + + @Column({ name: 'failed_deliveries', type: 'int', default: 0 }) + failedDeliveries: number; + + @Column({ name: 'last_delivery_at', type: 'timestamptz', nullable: true }) + lastDeliveryAt: Date; + + @Column({ name: 'last_success_at', type: 'timestamptz', nullable: true }) + lastSuccessAt: Date; + + @Column({ name: 'last_failure_at', type: 'timestamptz', nullable: true }) + lastFailureAt: Date; + + @Column({ name: 'rate_limit_per_minute', type: 'int', default: 60 }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', default: 1000 }) + rateLimitPerHour: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/webhooks/entities/event-type.entity.ts b/src/modules/webhooks/entities/event-type.entity.ts new file mode 100644 index 0000000..9a4ec81 --- /dev/null +++ b/src/modules/webhooks/entities/event-type.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'sales' | 'inventory' | 'customers' | 'auth' | 'billing' | 'system'; + +@Entity({ name: 'event_types', schema: 'webhooks' }) +export class WebhookEventType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: EventCategory; + + @Column({ name: 'payload_schema', type: 'jsonb', default: {} }) + payloadSchema: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_internal', type: 'boolean', default: false }) + isInternal: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/webhooks/entities/event.entity.ts b/src/modules/webhooks/entities/event.entity.ts new file mode 100644 index 0000000..d93932b --- /dev/null +++ b/src/modules/webhooks/entities/event.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type WebhookEventStatus = 'pending' | 'processing' | 'dispatched' | 'failed'; + +@Entity({ name: 'events', schema: 'webhooks' }) +export class WebhookEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'resource_type', type: 'varchar', length: 100, nullable: true }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'triggered_by', type: 'uuid', nullable: true }) + triggeredBy: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: WebhookEventStatus; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + @Column({ name: 'dispatched_endpoints', type: 'int', default: 0 }) + dispatchedEndpoints: number; + + @Column({ name: 'failed_endpoints', type: 'int', default: 0 }) + failedEndpoints: number; + + @Index() + @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) + idempotencyKey: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/webhooks/entities/index.ts b/src/modules/webhooks/entities/index.ts new file mode 100644 index 0000000..3c8a0c2 --- /dev/null +++ b/src/modules/webhooks/entities/index.ts @@ -0,0 +1,6 @@ +export { WebhookEventType, EventCategory } from './event-type.entity'; +export { WebhookEndpoint, AuthType } from './endpoint.entity'; +export { WebhookDelivery, DeliveryStatus } from './delivery.entity'; +export { WebhookEvent, WebhookEventStatus } from './event.entity'; +export { WebhookSubscription } from './subscription.entity'; +export { WebhookEndpointLog, WebhookLogType } from './endpoint-log.entity'; diff --git a/src/modules/webhooks/entities/subscription.entity.ts b/src/modules/webhooks/entities/subscription.entity.ts new file mode 100644 index 0000000..a30b746 --- /dev/null +++ b/src/modules/webhooks/entities/subscription.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; +import { WebhookEventType } from './event-type.entity'; + +@Entity({ name: 'subscriptions', schema: 'webhooks' }) +@Unique(['endpointId', 'eventTypeId']) +export class WebhookSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'event_type_id', type: 'uuid' }) + eventTypeId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'payload_template', type: 'jsonb', nullable: true }) + payloadTemplate: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; + + @ManyToOne(() => WebhookEventType, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'event_type_id' }) + eventType: WebhookEventType; +} diff --git a/src/modules/webhooks/index.ts b/src/modules/webhooks/index.ts new file mode 100644 index 0000000..8250561 --- /dev/null +++ b/src/modules/webhooks/index.ts @@ -0,0 +1,5 @@ +export { WebhooksModule, WebhooksModuleOptions } from './webhooks.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/webhooks/services/index.ts b/src/modules/webhooks/services/index.ts new file mode 100644 index 0000000..ad6802e --- /dev/null +++ b/src/modules/webhooks/services/index.ts @@ -0,0 +1 @@ +export { WebhooksService } from './webhooks.service'; diff --git a/src/modules/webhooks/services/webhooks.service.ts b/src/modules/webhooks/services/webhooks.service.ts new file mode 100644 index 0000000..da10e72 --- /dev/null +++ b/src/modules/webhooks/services/webhooks.service.ts @@ -0,0 +1,263 @@ +import { Repository, FindOptionsWhere, In, LessThan } from 'typeorm'; +import { WebhookEventType, WebhookEndpoint, WebhookDelivery, WebhookEvent } from '../entities'; + +export class WebhooksService { + constructor( + private readonly eventTypeRepository: Repository, + private readonly endpointRepository: Repository, + private readonly deliveryRepository: Repository, + private readonly eventRepository: Repository + ) {} + + // ============================================ + // EVENT TYPES + // ============================================ + + async findAllEventTypes(): Promise { + return this.eventTypeRepository.find({ + where: { isActive: true }, + order: { category: 'ASC', code: 'ASC' }, + }); + } + + async findEventTypeByCode(code: string): Promise { + return this.eventTypeRepository.findOne({ where: { code } }); + } + + async findEventTypesByCategory(category: string): Promise { + return this.eventTypeRepository.find({ + where: { category: category as any, isActive: true }, + order: { code: 'ASC' }, + }); + } + + // ============================================ + // ENDPOINTS + // ============================================ + + async findAllEndpoints(tenantId: string): Promise { + return this.endpointRepository.find({ + where: { tenantId }, + order: { name: 'ASC' }, + }); + } + + async findActiveEndpoints(tenantId: string): Promise { + return this.endpointRepository.find({ + where: { tenantId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findEndpoint(id: string): Promise { + return this.endpointRepository.findOne({ where: { id } }); + } + + async findEndpointByUrl(tenantId: string, url: string): Promise { + return this.endpointRepository.findOne({ where: { tenantId, url } }); + } + + async createEndpoint( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const endpoint = this.endpointRepository.create({ + ...data, + tenantId, + createdBy, + }); + return this.endpointRepository.save(endpoint); + } + + async updateEndpoint( + id: string, + data: Partial, + updatedBy?: string + ): Promise { + const endpoint = await this.findEndpoint(id); + if (!endpoint) return null; + + Object.assign(endpoint, data, { updatedBy }); + return this.endpointRepository.save(endpoint); + } + + async deleteEndpoint(id: string): Promise { + const result = await this.endpointRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async toggleEndpoint(id: string, isActive: boolean): Promise { + const endpoint = await this.findEndpoint(id); + if (!endpoint) return null; + + endpoint.isActive = isActive; + return this.endpointRepository.save(endpoint); + } + + async findEndpointsForEvent(tenantId: string, eventTypeCode: string): Promise { + return this.endpointRepository + .createQueryBuilder('endpoint') + .where('endpoint.tenant_id = :tenantId', { tenantId }) + .andWhere('endpoint.is_active = true') + .andWhere(':eventTypeCode = ANY(endpoint.subscribed_events)', { eventTypeCode }) + .getMany(); + } + + async updateEndpointHealth( + id: string, + isHealthy: boolean, + consecutiveFailures: number + ): Promise { + await this.endpointRepository.update(id, { + isHealthy, + consecutiveFailures, + lastHealthCheck: new Date(), + }); + } + + // ============================================ + // EVENTS + // ============================================ + + async createEvent(tenantId: string, data: Partial): Promise { + const event = this.eventRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.eventRepository.save(event); + } + + async findEvent(id: string): Promise { + return this.eventRepository.findOne({ + where: { id }, + relations: ['deliveries'], + }); + } + + async findPendingEvents(limit: number = 100): Promise { + return this.eventRepository.find({ + where: { status: 'pending' }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + async updateEventStatus(id: string, status: string): Promise { + const updates: Partial = { status: status as any }; + if (status === 'processed') { + updates.processedAt = new Date(); + } + await this.eventRepository.update(id, updates); + } + + // ============================================ + // DELIVERIES + // ============================================ + + async createDelivery(data: Partial): Promise { + const delivery = this.deliveryRepository.create({ + ...data, + status: 'pending', + }); + return this.deliveryRepository.save(delivery); + } + + async findDelivery(id: string): Promise { + return this.deliveryRepository.findOne({ where: { id } }); + } + + async findDeliveriesForEvent(eventId: string): Promise { + return this.deliveryRepository.find({ + where: { eventId }, + order: { attemptNumber: 'ASC' }, + }); + } + + async findDeliveriesForEndpoint( + endpointId: string, + limit: number = 50 + ): Promise { + return this.deliveryRepository.find({ + where: { endpointId }, + order: { createdAt: 'DESC' }, + take: limit, + relations: ['event'], + }); + } + + async updateDeliveryResult( + id: string, + status: string, + responseStatus?: number, + responseBody?: string, + duration?: number, + errorMessage?: string + ): Promise { + const updates: Partial = { + status: status as any, + responseStatus, + responseBody, + durationMs: duration, + errorMessage, + completedAt: new Date(), + }; + + await this.deliveryRepository.update(id, updates); + } + + async findPendingRetries(limit: number = 100): Promise { + const now = new Date(); + return this.deliveryRepository.find({ + where: { + status: 'pending', + nextRetryAt: LessThan(now), + }, + order: { nextRetryAt: 'ASC' }, + take: limit, + }); + } + + async scheduleRetry(id: string, nextRetryAt: Date, attemptNumber: number): Promise { + await this.deliveryRepository.update(id, { + nextRetryAt, + attemptNumber, + status: 'pending', + }); + } + + // ============================================ + // STATISTICS + // ============================================ + + async getEndpointStats( + endpointId: string, + startDate: Date, + endDate: Date + ): Promise<{ + total: number; + success: number; + failed: number; + avgDuration: number; + }> { + const result = await this.deliveryRepository + .createQueryBuilder('d') + .select('COUNT(*)', 'total') + .addSelect('SUM(CASE WHEN d.status = :success THEN 1 ELSE 0 END)', 'success') + .addSelect('SUM(CASE WHEN d.status = :failed THEN 1 ELSE 0 END)', 'failed') + .addSelect('AVG(d.duration_ms)', 'avgDuration') + .where('d.endpoint_id = :endpointId', { endpointId }) + .andWhere('d.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .setParameter('success', 'success') + .setParameter('failed', 'failed') + .getRawOne(); + + return { + total: parseInt(result.total) || 0, + success: parseInt(result.success) || 0, + failed: parseInt(result.failed) || 0, + avgDuration: parseFloat(result.avgDuration) || 0, + }; + } +} diff --git a/src/modules/webhooks/webhooks.module.ts b/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 0000000..0f8dfea --- /dev/null +++ b/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { WebhooksService } from './services'; +import { WebhooksController } from './controllers'; +import { + WebhookEventType, + WebhookEndpoint, + WebhookDelivery, + WebhookEvent, +} from './entities'; + +export interface WebhooksModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class WebhooksModule { + public router: Router; + public webhooksService: WebhooksService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: WebhooksModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const eventTypeRepository = this.dataSource.getRepository(WebhookEventType); + const endpointRepository = this.dataSource.getRepository(WebhookEndpoint); + const deliveryRepository = this.dataSource.getRepository(WebhookDelivery); + const eventRepository = this.dataSource.getRepository(WebhookEvent); + + this.webhooksService = new WebhooksService( + eventTypeRepository, + endpointRepository, + deliveryRepository, + eventRepository + ); + } + + private initializeRoutes(): void { + const webhooksController = new WebhooksController(this.webhooksService); + this.router.use(`${this.basePath}/webhooks`, webhooksController.router); + } + + static getEntities(): Function[] { + return [ + WebhookEventType, + WebhookEndpoint, + WebhookDelivery, + WebhookEvent, + ]; + } +} diff --git a/src/modules/whatsapp/controllers/index.ts b/src/modules/whatsapp/controllers/index.ts new file mode 100644 index 0000000..60d21b3 --- /dev/null +++ b/src/modules/whatsapp/controllers/index.ts @@ -0,0 +1 @@ +export { WhatsAppController } from './whatsapp.controller'; diff --git a/src/modules/whatsapp/controllers/whatsapp.controller.ts b/src/modules/whatsapp/controllers/whatsapp.controller.ts new file mode 100644 index 0000000..ab38207 --- /dev/null +++ b/src/modules/whatsapp/controllers/whatsapp.controller.ts @@ -0,0 +1,500 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { WhatsAppService, MessageFilters, ContactFilters } from '../services/whatsapp.service'; + +export class WhatsAppController { + public router: Router; + + constructor(private readonly whatsappService: WhatsAppService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Accounts + this.router.get('/accounts', this.findAllAccounts.bind(this)); + this.router.get('/accounts/active', this.findActiveAccounts.bind(this)); + this.router.get('/accounts/:id', this.findAccount.bind(this)); + this.router.post('/accounts', this.createAccount.bind(this)); + this.router.patch('/accounts/:id', this.updateAccount.bind(this)); + this.router.patch('/accounts/:id/status', this.updateAccountStatus.bind(this)); + this.router.get('/accounts/:id/stats', this.getAccountStats.bind(this)); + + // Contacts + this.router.get('/accounts/:accountId/contacts', this.findContacts.bind(this)); + this.router.get('/contacts/:id', this.findContact.bind(this)); + this.router.get('/accounts/:accountId/contacts/phone/:phoneNumber', this.findContactByPhone.bind(this)); + this.router.post('/contacts', this.createContact.bind(this)); + this.router.patch('/contacts/:id', this.updateContact.bind(this)); + this.router.post('/contacts/:id/opt-in', this.optInContact.bind(this)); + this.router.post('/contacts/:id/opt-out', this.optOutContact.bind(this)); + this.router.post('/contacts/:id/tags', this.addTagToContact.bind(this)); + this.router.delete('/contacts/:id/tags/:tag', this.removeTagFromContact.bind(this)); + + // Messages + this.router.get('/accounts/:accountId/messages', this.findMessages.bind(this)); + this.router.get('/messages/:id', this.findMessage.bind(this)); + this.router.get('/contacts/:contactId/messages', this.findConversationMessages.bind(this)); + this.router.post('/messages', this.createMessage.bind(this)); + this.router.patch('/messages/:id/status', this.updateMessageStatus.bind(this)); + + // Templates + this.router.get('/accounts/:accountId/templates', this.findTemplates.bind(this)); + this.router.get('/accounts/:accountId/templates/approved', this.findApprovedTemplates.bind(this)); + this.router.get('/templates/:id', this.findTemplate.bind(this)); + this.router.post('/templates', this.createTemplate.bind(this)); + this.router.patch('/templates/:id', this.updateTemplate.bind(this)); + this.router.patch('/templates/:id/status', this.updateTemplateStatus.bind(this)); + this.router.delete('/templates/:id', this.deactivateTemplate.bind(this)); + } + + // ============================================ + // ACCOUNTS + // ============================================ + + private async findAllAccounts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const accounts = await this.whatsappService.findAllAccounts(tenantId); + res.json({ data: accounts, total: accounts.length }); + } catch (error) { + next(error); + } + } + + private async findActiveAccounts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const accounts = await this.whatsappService.findActiveAccounts(tenantId); + res.json({ data: accounts, total: accounts.length }); + } catch (error) { + next(error); + } + } + + private async findAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const account = await this.whatsappService.findAccount(id); + + if (!account) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ data: account }); + } catch (error) { + next(error); + } + } + + private async createAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const account = await this.whatsappService.createAccount(tenantId, req.body, userId); + res.status(201).json({ data: account }); + } catch (error) { + next(error); + } + } + + private async updateAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const account = await this.whatsappService.updateAccount(id, req.body); + + if (!account) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ data: account }); + } catch (error) { + next(error); + } + } + + private async updateAccountStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status } = req.body; + + const updated = await this.whatsappService.updateAccountStatus(id, status); + + if (!updated) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async getAccountStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await this.whatsappService.getAccountStats(id, startDate, endDate); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONTACTS + // ============================================ + + private async findContacts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId } = req.params; + const filters: ContactFilters = { + conversationStatus: req.query.conversationStatus as string, + tag: req.query.tag as string, + }; + + if (req.query.optedIn !== undefined) { + filters.optedIn = req.query.optedIn === 'true'; + } + + const limit = parseInt(req.query.limit as string) || 50; + + const contacts = await this.whatsappService.findContacts(tenantId, accountId, filters, limit); + res.json({ data: contacts, total: contacts.length }); + } catch (error) { + next(error); + } + } + + private async findContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const contact = await this.whatsappService.findContact(id); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async findContactByPhone(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId, phoneNumber } = req.params; + const contact = await this.whatsappService.findContactByPhone(accountId, phoneNumber); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async createContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId, ...data } = req.body; + + const contact = await this.whatsappService.createContact(tenantId, accountId, data); + res.status(201).json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async updateContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const contact = await this.whatsappService.updateContact(id, req.body); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async optInContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const optedIn = await this.whatsappService.optInContact(id); + + if (!optedIn) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async optOutContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const optedOut = await this.whatsappService.optOutContact(id); + + if (!optedOut) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async addTagToContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { tag } = req.body; + + const contact = await this.whatsappService.addTagToContact(id, tag); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async removeTagFromContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id, tag } = req.params; + const contact = await this.whatsappService.removeTagFromContact(id, tag); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MESSAGES + // ============================================ + + private async findMessages(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId } = req.params; + const filters: MessageFilters = { + contactId: req.query.contactId as string, + direction: req.query.direction as string, + messageType: req.query.messageType as string, + status: req.query.status as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const limit = parseInt(req.query.limit as string) || 50; + + const messages = await this.whatsappService.findMessages(accountId, filters, limit); + res.json({ data: messages, total: messages.length }); + } catch (error) { + next(error); + } + } + + private async findMessage(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const message = await this.whatsappService.findMessage(id); + + if (!message) { + res.status(404).json({ error: 'Message not found' }); + return; + } + + res.json({ data: message }); + } catch (error) { + next(error); + } + } + + private async findConversationMessages(req: Request, res: Response, next: NextFunction): Promise { + try { + const { contactId } = req.params; + const limit = parseInt(req.query.limit as string) || 100; + + const messages = await this.whatsappService.findConversationMessages(contactId, limit); + res.json({ data: messages, total: messages.length }); + } catch (error) { + next(error); + } + } + + private async createMessage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId, contactId, ...data } = req.body; + + const message = await this.whatsappService.createMessage(tenantId, accountId, contactId, data); + res.status(201).json({ data: message }); + } catch (error) { + next(error); + } + } + + private async updateMessageStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, timestamp } = req.body; + + const message = await this.whatsappService.updateMessageStatus( + id, + status, + timestamp ? new Date(timestamp) : undefined + ); + + if (!message) { + res.status(404).json({ error: 'Message not found' }); + return; + } + + res.json({ data: message }); + } catch (error) { + next(error); + } + } + + // ============================================ + // TEMPLATES + // ============================================ + + private async findTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId } = req.params; + const category = req.query.category as string | undefined; + + const templates = await this.whatsappService.findTemplates(tenantId, accountId, category); + res.json({ data: templates, total: templates.length }); + } catch (error) { + next(error); + } + } + + private async findApprovedTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId } = req.params; + + const templates = await this.whatsappService.findApprovedTemplates(tenantId, accountId); + res.json({ data: templates, total: templates.length }); + } catch (error) { + next(error); + } + } + + private async findTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const template = await this.whatsappService.findTemplate(id); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async createTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId, ...data } = req.body; + + const template = await this.whatsappService.createTemplate(tenantId, accountId, data); + res.status(201).json({ data: template }); + } catch (error) { + next(error); + } + } + + private async updateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const template = await this.whatsappService.updateTemplate(id, req.body); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async updateTemplateStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { metaStatus, metaTemplateId, rejectionReason } = req.body; + + const template = await this.whatsappService.updateTemplateStatus( + id, + metaStatus, + metaTemplateId, + rejectionReason + ); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async deactivateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deactivated = await this.whatsappService.deactivateTemplate(id); + + if (!deactivated) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/whatsapp/dto/index.ts b/src/modules/whatsapp/dto/index.ts new file mode 100644 index 0000000..03a2864 --- /dev/null +++ b/src/modules/whatsapp/dto/index.ts @@ -0,0 +1,14 @@ +export { + CreateAccountDto, + UpdateAccountDto, + UpdateAccountStatusDto, + CreateContactDto, + UpdateContactDto, + AddTagDto, + CreateMessageDto, + UpdateMessageStatusDto, + MessageErrorDto, + CreateTemplateDto, + UpdateTemplateDto, + UpdateTemplateStatusDto, +} from './whatsapp.dto'; diff --git a/src/modules/whatsapp/dto/whatsapp.dto.ts b/src/modules/whatsapp/dto/whatsapp.dto.ts new file mode 100644 index 0000000..6db7988 --- /dev/null +++ b/src/modules/whatsapp/dto/whatsapp.dto.ts @@ -0,0 +1,377 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + MaxLength, + MinLength, + Min, + Matches, +} from 'class-validator'; + +// ============================================ +// ACCOUNT DTOs +// ============================================ + +export class CreateAccountDto { + @IsString() + @MaxLength(20) + @Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number format' }) + phoneNumber: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + displayName: string; + + @IsOptional() + @IsString() + @MaxLength(20) + businessId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phoneNumberId?: string; + + @IsOptional() + @IsString() + accessToken?: string; + + @IsOptional() + @IsString() + webhookSecret?: string; + + @IsOptional() + @IsObject() + businessProfile?: { + description?: string; + email?: string; + website?: string; + address?: string; + vertical?: string; + }; +} + +export class UpdateAccountDto { + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; + + @IsOptional() + @IsString() + accessToken?: string; + + @IsOptional() + @IsString() + webhookSecret?: string; + + @IsOptional() + @IsObject() + businessProfile?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + +export class UpdateAccountStatusDto { + @IsString() + @MaxLength(20) + status: string; +} + +// ============================================ +// CONTACT DTOs +// ============================================ + +export class CreateContactDto { + @IsUUID() + accountId: string; + + @IsString() + @MaxLength(20) + @Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number format' }) + phoneNumber: string; + + @IsOptional() + @IsString() + @MaxLength(50) + waId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + profileName?: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateContactDto { + @IsOptional() + @IsString() + @MaxLength(200) + profileName?: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + conversationStatus?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; +} + +export class AddTagDto { + @IsString() + @MaxLength(50) + tag: string; +} + +// ============================================ +// MESSAGE DTOs +// ============================================ + +export class CreateMessageDto { + @IsUUID() + accountId: string; + + @IsUUID() + contactId: string; + + @IsString() + @MaxLength(10) + direction: string; + + @IsString() + @MaxLength(20) + messageType: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + caption?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mediaId?: string; + + @IsOptional() + @IsString() + mediaUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mediaMimeType?: string; + + @IsOptional() + @IsUUID() + templateId?: string; + + @IsOptional() + @IsString() + @MaxLength(512) + templateName?: string; + + @IsOptional() + @IsArray() + templateVariables?: string[]; + + @IsOptional() + @IsString() + @MaxLength(30) + interactiveType?: string; + + @IsOptional() + @IsObject() + interactiveData?: Record; + + @IsOptional() + @IsString() + @MaxLength(100) + contextMessageId?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateMessageStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + timestamp?: string; +} + +export class MessageErrorDto { + @IsString() + @MaxLength(20) + errorCode: string; + + @IsString() + errorMessage: string; +} + +// ============================================ +// TEMPLATE DTOs +// ============================================ + +export class CreateTemplateDto { + @IsUUID() + accountId: string; + + @IsString() + @MinLength(1) + @MaxLength(512) + name: string; + + @IsString() + @MinLength(2) + @MaxLength(200) + displayName: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + @MaxLength(30) + category: string; + + @IsOptional() + @IsString() + @MaxLength(10) + language?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + headerType?: string; + + @IsOptional() + @IsString() + headerText?: string; + + @IsOptional() + @IsString() + headerMediaUrl?: string; + + @IsString() + bodyText: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + bodyVariables?: string[]; + + @IsOptional() + @IsString() + @MaxLength(60) + footerText?: string; + + @IsOptional() + @IsArray() + buttons?: Record[]; +} + +export class UpdateTemplateDto { + @IsOptional() + @IsString() + @MaxLength(200) + displayName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + headerType?: string; + + @IsOptional() + @IsString() + headerText?: string; + + @IsOptional() + @IsString() + headerMediaUrl?: string; + + @IsOptional() + @IsString() + bodyText?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + bodyVariables?: string[]; + + @IsOptional() + @IsString() + @MaxLength(60) + footerText?: string; + + @IsOptional() + @IsArray() + buttons?: Record[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateTemplateStatusDto { + @IsString() + @MaxLength(20) + metaStatus: string; + + @IsOptional() + @IsString() + @MaxLength(50) + metaTemplateId?: string; + + @IsOptional() + @IsString() + rejectionReason?: string; +} diff --git a/src/modules/whatsapp/entities/account.entity.ts b/src/modules/whatsapp/entities/account.entity.ts new file mode 100644 index 0000000..85893f2 --- /dev/null +++ b/src/modules/whatsapp/entities/account.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AccountStatus = 'pending' | 'active' | 'suspended' | 'disconnected'; + +@Entity({ name: 'accounts', schema: 'whatsapp' }) +@Unique(['tenantId', 'phoneNumber']) +export class WhatsAppAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'phone_number_id', type: 'varchar', length: 50 }) + phoneNumberId: string; + + @Column({ name: 'business_account_id', type: 'varchar', length: 50 }) + businessAccountId: string; + + @Column({ name: 'access_token', type: 'text', nullable: true }) + accessToken: string; + + @Column({ name: 'webhook_verify_token', type: 'varchar', length: 255, nullable: true }) + webhookVerifyToken: string; + + @Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true }) + webhookSecret: string; + + @Column({ name: 'business_name', type: 'varchar', length: 200, nullable: true }) + businessName: string; + + @Column({ name: 'business_description', type: 'text', nullable: true }) + businessDescription: string; + + @Column({ name: 'business_category', type: 'varchar', length: 100, nullable: true }) + businessCategory: string; + + @Column({ name: 'business_website', type: 'text', nullable: true }) + businessWebsite: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'default_language', type: 'varchar', length: 10, default: 'es_MX' }) + defaultLanguage: string; + + @Column({ name: 'auto_reply_enabled', type: 'boolean', default: false }) + autoReplyEnabled: boolean; + + @Column({ name: 'auto_reply_message', type: 'text', nullable: true }) + autoReplyMessage: string; + + @Column({ name: 'business_hours', type: 'jsonb', default: {} }) + businessHours: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: AccountStatus; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'daily_message_limit', type: 'int', default: 1000 }) + dailyMessageLimit: number; + + @Column({ name: 'messages_sent_today', type: 'int', default: 0 }) + messagesSentToday: number; + + @Column({ name: 'last_limit_reset', type: 'timestamptz', nullable: true }) + lastLimitReset: Date; + + @Column({ name: 'total_messages_sent', type: 'bigint', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'bigint', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/whatsapp/entities/automation.entity.ts b/src/modules/whatsapp/entities/automation.entity.ts new file mode 100644 index 0000000..a8aeb81 --- /dev/null +++ b/src/modules/whatsapp/entities/automation.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type AutomationTriggerType = 'keyword' | 'first_message' | 'after_hours' | 'no_response' | 'webhook'; +export type AutomationActionType = 'send_message' | 'send_template' | 'assign_agent' | 'add_tag' | 'create_ticket'; + +@Entity({ name: 'automations', schema: 'whatsapp' }) +export class WhatsAppAutomation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'trigger_type', type: 'varchar', length: 30 }) + triggerType: AutomationTriggerType; + + @Column({ name: 'trigger_config', type: 'jsonb', default: {} }) + triggerConfig: Record; + + @Column({ name: 'action_type', type: 'varchar', length: 30 }) + actionType: AutomationActionType; + + @Column({ name: 'action_config', type: 'jsonb', default: {} }) + actionConfig: Record; + + @Column({ name: 'conditions', type: 'jsonb', default: [] }) + conditions: Record[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'trigger_count', type: 'int', default: 0 }) + triggerCount: number; + + @Column({ name: 'last_triggered_at', type: 'timestamptz', nullable: true }) + lastTriggeredAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/broadcast-recipient.entity.ts b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts new file mode 100644 index 0000000..71fb0ad --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Broadcast } from './broadcast.entity'; +import { WhatsAppContact } from './contact.entity'; +import { WhatsAppMessage } from './message.entity'; + +export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; + +@Entity({ name: 'broadcast_recipients', schema: 'whatsapp' }) +@Unique(['broadcastId', 'contactId']) +export class BroadcastRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'broadcast_id', type: 'uuid' }) + broadcastId: string; + + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: any[]; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: RecipientStatus; + + @Column({ name: 'message_id', type: 'uuid', nullable: true }) + messageId: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => Broadcast, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'broadcast_id' }) + broadcast: Broadcast; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; + + @ManyToOne(() => WhatsAppMessage, { nullable: true }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/broadcast.entity.ts b/src/modules/whatsapp/entities/broadcast.entity.ts new file mode 100644 index 0000000..94f11cc --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppTemplate } from './template.entity'; + +export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'completed' | 'cancelled' | 'failed'; +export type AudienceType = 'all' | 'segment' | 'custom' | 'file'; + +@Entity({ name: 'broadcasts', schema: 'whatsapp' }) +export class Broadcast { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ name: 'recipient_count', type: 'int', default: 0 }) + recipientCount: number; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Column({ name: 'timezone', type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'draft' }) + status: BroadcastStatus; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'reply_count', type: 'int', default: 0 }) + replyCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'estimated_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + estimatedCost: number; + + @Column({ name: 'actual_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + actualCost: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppTemplate) + @JoinColumn({ name: 'template_id' }) + template: WhatsAppTemplate; +} diff --git a/src/modules/whatsapp/entities/contact.entity.ts b/src/modules/whatsapp/entities/contact.entity.ts new file mode 100644 index 0000000..b3b6726 --- /dev/null +++ b/src/modules/whatsapp/entities/contact.entity.ts @@ -0,0 +1,99 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type ConversationStatus = 'active' | 'waiting' | 'resolved' | 'blocked'; +export type MessageDirection = 'inbound' | 'outbound'; + +@Entity({ name: 'contacts', schema: 'whatsapp' }) +@Unique(['accountId', 'phoneNumber']) +export class WhatsAppContact { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'wa_id', type: 'varchar', length: 50, nullable: true }) + waId: string; + + @Column({ name: 'profile_name', type: 'varchar', length: 200, nullable: true }) + profileName: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'conversation_status', type: 'varchar', length: 20, default: 'active' }) + conversationStatus: ConversationStatus; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @Column({ name: 'last_message_direction', type: 'varchar', length: 10, nullable: true }) + lastMessageDirection: MessageDirection; + + @Column({ name: 'conversation_window_expires_at', type: 'timestamptz', nullable: true }) + conversationWindowExpiresAt: Date; + + @Column({ name: 'can_send_template_only', type: 'boolean', default: true }) + canSendTemplateOnly: boolean; + + @Index() + @Column({ name: 'opted_in', type: 'boolean', default: false }) + optedIn: boolean; + + @Column({ name: 'opted_in_at', type: 'timestamptz', nullable: true }) + optedInAt: Date; + + @Column({ name: 'opted_out', type: 'boolean', default: false }) + optedOut: boolean; + + @Column({ name: 'opted_out_at', type: 'timestamptz', nullable: true }) + optedOutAt: Date; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'total_messages_sent', type: 'int', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'int', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/conversation.entity.ts b/src/modules/whatsapp/entities/conversation.entity.ts new file mode 100644 index 0000000..7eef57b --- /dev/null +++ b/src/modules/whatsapp/entities/conversation.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type WAConversationStatus = 'open' | 'pending' | 'resolved' | 'closed'; +export type WAConversationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'conversations', schema: 'whatsapp' }) +export class WhatsAppConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'open' }) + status: WAConversationStatus; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: WAConversationPriority; + + @Index() + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', nullable: true }) + assignedAt: Date; + + @Column({ name: 'team_id', type: 'uuid', nullable: true }) + teamId: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'first_response_at', type: 'timestamptz', nullable: true }) + firstResponseAt: Date; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @Column({ name: 'message_count', type: 'int', default: 0 }) + messageCount: number; + + @Column({ name: 'unread_count', type: 'int', default: 0 }) + unreadCount: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/index.ts b/src/modules/whatsapp/entities/index.ts new file mode 100644 index 0000000..4eafbfe --- /dev/null +++ b/src/modules/whatsapp/entities/index.ts @@ -0,0 +1,10 @@ +export { WhatsAppAccount, AccountStatus } from './account.entity'; +export { WhatsAppContact, ConversationStatus } from './contact.entity'; +export { WhatsAppMessage, MessageType, MessageStatus, MessageDirection, CostCategory } from './message.entity'; +export { WhatsAppTemplate, TemplateCategory, TemplateStatus, HeaderType } from './template.entity'; +export { WhatsAppConversation, WAConversationStatus, WAConversationPriority } from './conversation.entity'; +export { MessageStatusUpdate } from './message-status-update.entity'; +export { QuickReply } from './quick-reply.entity'; +export { WhatsAppAutomation, AutomationTriggerType, AutomationActionType } from './automation.entity'; +export { Broadcast, BroadcastStatus, AudienceType } from './broadcast.entity'; +export { BroadcastRecipient, RecipientStatus } from './broadcast-recipient.entity'; diff --git a/src/modules/whatsapp/entities/message-status-update.entity.ts b/src/modules/whatsapp/entities/message-status-update.entity.ts new file mode 100644 index 0000000..a729d27 --- /dev/null +++ b/src/modules/whatsapp/entities/message-status-update.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppMessage } from './message.entity'; + +@Entity({ name: 'message_status_updates', schema: 'whatsapp' }) +export class MessageStatusUpdate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'message_id', type: 'uuid' }) + messageId: string; + + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: string; + + @Column({ name: 'previous_status', type: 'varchar', length: 20, nullable: true }) + previousStatus: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_title', type: 'varchar', length: 200, nullable: true }) + errorTitle: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'meta_timestamp', type: 'timestamptz', nullable: true }) + metaTimestamp: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WhatsAppMessage, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/message.entity.ts b/src/modules/whatsapp/entities/message.entity.ts new file mode 100644 index 0000000..d51fc47 --- /dev/null +++ b/src/modules/whatsapp/entities/message.entity.ts @@ -0,0 +1,137 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type MessageType = 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'location' | 'contacts' | 'interactive' | 'template' | 'reaction'; +export type MessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; +export type MessageDirection = 'inbound' | 'outbound'; +export type CostCategory = 'utility' | 'authentication' | 'marketing'; + +@Entity({ name: 'messages', schema: 'whatsapp' }) +export class WhatsAppMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Index() + @Column({ name: 'wa_message_id', type: 'varchar', length: 100, nullable: true }) + waMessageId: string; + + @Column({ name: 'wa_conversation_id', type: 'varchar', length: 100, nullable: true }) + waConversationId: string; + + @Index() + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction: MessageDirection; + + @Column({ name: 'message_type', type: 'varchar', length: 20 }) + messageType: MessageType; + + @Column({ name: 'content', type: 'text', nullable: true }) + content: string; + + @Column({ name: 'caption', type: 'text', nullable: true }) + caption: string; + + @Column({ name: 'media_id', type: 'varchar', length: 100, nullable: true }) + mediaId: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'media_mime_type', type: 'varchar', length: 100, nullable: true }) + mediaMimeType: string; + + @Column({ name: 'media_sha256', type: 'varchar', length: 64, nullable: true }) + mediaSha256: string; + + @Column({ name: 'media_size_bytes', type: 'int', nullable: true }) + mediaSizeBytes: number; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_name', type: 'varchar', length: 512, nullable: true }) + templateName: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: string[]; + + @Column({ name: 'interactive_type', type: 'varchar', length: 30, nullable: true }) + interactiveType: string; + + @Column({ name: 'interactive_data', type: 'jsonb', default: {} }) + interactiveData: Record; + + @Column({ name: 'context_message_id', type: 'varchar', length: 100, nullable: true }) + contextMessageId: string; + + @Column({ name: 'quoted_message_id', type: 'uuid', nullable: true }) + quotedMessageId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: MessageStatus; + + @Column({ name: 'status_updated_at', type: 'timestamptz', nullable: true }) + statusUpdatedAt: Date; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'is_billable', type: 'boolean', default: false }) + isBillable: boolean; + + @Column({ name: 'cost_category', type: 'varchar', length: 30, nullable: true }) + costCategory: CostCategory; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/quick-reply.entity.ts b/src/modules/whatsapp/entities/quick-reply.entity.ts new file mode 100644 index 0000000..ddb14bf --- /dev/null +++ b/src/modules/whatsapp/entities/quick-reply.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +@Entity({ name: 'quick_replies', schema: 'whatsapp' }) +@Unique(['tenantId', 'shortcut']) +export class QuickReply { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + @Index() + @Column({ name: 'shortcut', type: 'varchar', length: 50 }) + shortcut: string; + + @Column({ name: 'title', type: 'varchar', length: 200 }) + title: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'message_type', type: 'varchar', length: 20, default: 'text' }) + messageType: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/template.entity.ts b/src/modules/whatsapp/entities/template.entity.ts new file mode 100644 index 0000000..1100d5d --- /dev/null +++ b/src/modules/whatsapp/entities/template.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type TemplateCategory = 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'; +export type TemplateStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED' | 'DISABLED'; +export type HeaderType = 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; + +@Entity({ name: 'templates', schema: 'whatsapp' }) +@Unique(['accountId', 'name', 'language']) +export class WhatsAppTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 512 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30 }) + category: TemplateCategory; + + @Column({ name: 'language', type: 'varchar', length: 10, default: 'es_MX' }) + language: string; + + @Column({ name: 'header_type', type: 'varchar', length: 20, nullable: true }) + headerType: HeaderType; + + @Column({ name: 'header_text', type: 'text', nullable: true }) + headerText: string; + + @Column({ name: 'header_media_url', type: 'text', nullable: true }) + headerMediaUrl: string; + + @Column({ name: 'body_text', type: 'text' }) + bodyText: string; + + @Column({ name: 'body_variables', type: 'text', array: true, default: [] }) + bodyVariables: string[]; + + @Column({ name: 'footer_text', type: 'varchar', length: 60, nullable: true }) + footerText: string; + + @Column({ name: 'buttons', type: 'jsonb', default: [] }) + buttons: Record[]; + + @Column({ name: 'meta_template_id', type: 'varchar', length: 50, nullable: true }) + metaTemplateId: string; + + @Index() + @Column({ name: 'meta_status', type: 'varchar', length: 20, default: 'PENDING' }) + metaStatus: TemplateStatus; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/index.ts b/src/modules/whatsapp/index.ts new file mode 100644 index 0000000..aa71081 --- /dev/null +++ b/src/modules/whatsapp/index.ts @@ -0,0 +1,5 @@ +export { WhatsAppModule, WhatsAppModuleOptions } from './whatsapp.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/whatsapp/services/index.ts b/src/modules/whatsapp/services/index.ts new file mode 100644 index 0000000..1986065 --- /dev/null +++ b/src/modules/whatsapp/services/index.ts @@ -0,0 +1 @@ +export { WhatsAppService, MessageFilters, ContactFilters } from './whatsapp.service'; diff --git a/src/modules/whatsapp/services/whatsapp.service.ts b/src/modules/whatsapp/services/whatsapp.service.ts new file mode 100644 index 0000000..186d3a5 --- /dev/null +++ b/src/modules/whatsapp/services/whatsapp.service.ts @@ -0,0 +1,464 @@ +import { Repository, FindOptionsWhere, LessThan, Between, In } from 'typeorm'; +import { WhatsAppAccount, WhatsAppContact, WhatsAppMessage, WhatsAppTemplate } from '../entities'; + +export interface MessageFilters { + contactId?: string; + direction?: string; + messageType?: string; + status?: string; + startDate?: Date; + endDate?: Date; +} + +export interface ContactFilters { + conversationStatus?: string; + optedIn?: boolean; + tag?: string; +} + +export class WhatsAppService { + constructor( + private readonly accountRepository: Repository, + private readonly contactRepository: Repository, + private readonly messageRepository: Repository, + private readonly templateRepository: Repository + ) {} + + // ============================================ + // ACCOUNTS + // ============================================ + + async findAllAccounts(tenantId: string): Promise { + return this.accountRepository.find({ + where: { tenantId }, + order: { displayName: 'ASC' }, + }); + } + + async findActiveAccounts(tenantId: string): Promise { + return this.accountRepository.find({ + where: { tenantId, status: 'active' }, + order: { displayName: 'ASC' }, + }); + } + + async findAccount(id: string): Promise { + return this.accountRepository.findOne({ where: { id } }); + } + + async findAccountByPhoneNumber( + tenantId: string, + phoneNumber: string + ): Promise { + return this.accountRepository.findOne({ where: { tenantId, phoneNumber } }); + } + + async createAccount( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const account = this.accountRepository.create({ + ...data, + tenantId, + createdBy, + status: 'pending', + }); + return this.accountRepository.save(account); + } + + async updateAccount(id: string, data: Partial): Promise { + const account = await this.findAccount(id); + if (!account) return null; + + Object.assign(account, data); + return this.accountRepository.save(account); + } + + async updateAccountStatus(id: string, status: string): Promise { + const result = await this.accountRepository.update(id, { status: status as any }); + return (result.affected ?? 0) > 0; + } + + async incrementMessageCount(accountId: string, direction: 'sent' | 'received'): Promise { + const field = direction === 'sent' ? 'totalMessagesSent' : 'totalMessagesReceived'; + await this.accountRepository + .createQueryBuilder() + .update() + .set({ [field]: () => `${field} + 1` }) + .where('id = :id', { id: accountId }) + .execute(); + } + + // ============================================ + // CONTACTS + // ============================================ + + async findContacts( + tenantId: string, + accountId: string, + filters: ContactFilters = {}, + limit: number = 50 + ): Promise { + const where: FindOptionsWhere = { tenantId, accountId }; + + if (filters.conversationStatus) { + where.conversationStatus = filters.conversationStatus as any; + } + if (filters.optedIn !== undefined) { + where.optedIn = filters.optedIn; + } + + return this.contactRepository.find({ + where, + order: { lastMessageAt: 'DESC' }, + take: limit, + }); + } + + async findContact(id: string): Promise { + return this.contactRepository.findOne({ where: { id } }); + } + + async findContactByPhone(accountId: string, phoneNumber: string): Promise { + return this.contactRepository.findOne({ where: { accountId, phoneNumber } }); + } + + async createContact( + tenantId: string, + accountId: string, + data: Partial + ): Promise { + const contact = this.contactRepository.create({ + ...data, + tenantId, + accountId, + }); + return this.contactRepository.save(contact); + } + + async updateContact(id: string, data: Partial): Promise { + const contact = await this.findContact(id); + if (!contact) return null; + + Object.assign(contact, data); + return this.contactRepository.save(contact); + } + + async updateContactConversationWindow(id: string, expiresAt: Date): Promise { + await this.contactRepository.update(id, { + conversationWindowExpiresAt: expiresAt, + canSendTemplateOnly: false, + }); + } + + async expireConversationWindows(): Promise { + const now = new Date(); + const result = await this.contactRepository.update( + { conversationWindowExpiresAt: LessThan(now), canSendTemplateOnly: false }, + { canSendTemplateOnly: true } + ); + return result.affected ?? 0; + } + + async optInContact(id: string): Promise { + const result = await this.contactRepository.update(id, { + optedIn: true, + optedInAt: new Date(), + optedOut: false, + optedOutAt: undefined, + }); + return (result.affected ?? 0) > 0; + } + + async optOutContact(id: string): Promise { + const result = await this.contactRepository.update(id, { + optedOut: true, + optedOutAt: new Date(), + }); + return (result.affected ?? 0) > 0; + } + + async addTagToContact(id: string, tag: string): Promise { + const contact = await this.findContact(id); + if (!contact) return null; + + if (!contact.tags.includes(tag)) { + contact.tags.push(tag); + return this.contactRepository.save(contact); + } + return contact; + } + + async removeTagFromContact(id: string, tag: string): Promise { + const contact = await this.findContact(id); + if (!contact) return null; + + contact.tags = contact.tags.filter((t) => t !== tag); + return this.contactRepository.save(contact); + } + + // ============================================ + // MESSAGES + // ============================================ + + async findMessages( + accountId: string, + filters: MessageFilters = {}, + limit: number = 50 + ): Promise { + const where: FindOptionsWhere = { accountId }; + + if (filters.contactId) where.contactId = filters.contactId; + if (filters.direction) where.direction = filters.direction as any; + if (filters.messageType) where.messageType = filters.messageType as any; + if (filters.status) where.status = filters.status as any; + + if (filters.startDate && filters.endDate) { + where.createdAt = Between(filters.startDate, filters.endDate); + } + + return this.messageRepository.find({ + where, + order: { createdAt: 'DESC' }, + take: limit, + relations: ['contact'], + }); + } + + async findMessage(id: string): Promise { + return this.messageRepository.findOne({ + where: { id }, + relations: ['contact'], + }); + } + + async findMessageByWaId(waMessageId: string): Promise { + return this.messageRepository.findOne({ where: { waMessageId } }); + } + + async findConversationMessages( + contactId: string, + limit: number = 100 + ): Promise { + return this.messageRepository.find({ + where: { contactId }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + async createMessage( + tenantId: string, + accountId: string, + contactId: string, + data: Partial + ): Promise { + const message = this.messageRepository.create({ + ...data, + tenantId, + accountId, + contactId, + status: 'pending', + }); + + const savedMessage = await this.messageRepository.save(message); + + // Update contact stats + const direction = data.direction; + if (direction) { + const field = direction === 'outbound' ? 'totalMessagesSent' : 'totalMessagesReceived'; + await this.contactRepository + .createQueryBuilder() + .update() + .set({ + [field]: () => `${field} + 1`, + lastMessageAt: new Date(), + lastMessageDirection: direction, + }) + .where('id = :id', { id: contactId }) + .execute(); + + // Update account stats + await this.incrementMessageCount(accountId, direction === 'outbound' ? 'sent' : 'received'); + } + + return savedMessage; + } + + async updateMessageStatus( + id: string, + status: string, + timestamp?: Date + ): Promise { + const message = await this.findMessage(id); + if (!message) return null; + + message.status = status as any; + message.statusUpdatedAt = timestamp || new Date(); + + if (status === 'sent' && !message.sentAt) message.sentAt = timestamp || new Date(); + if (status === 'delivered' && !message.deliveredAt) message.deliveredAt = timestamp || new Date(); + if (status === 'read' && !message.readAt) message.readAt = timestamp || new Date(); + + return this.messageRepository.save(message); + } + + async updateMessageError(id: string, errorCode: string, errorMessage: string): Promise { + await this.messageRepository.update(id, { + status: 'failed', + errorCode, + errorMessage, + statusUpdatedAt: new Date(), + }); + } + + // ============================================ + // TEMPLATES + // ============================================ + + async findTemplates( + tenantId: string, + accountId: string, + category?: string + ): Promise { + const where: FindOptionsWhere = { tenantId, accountId, isActive: true }; + if (category) where.category = category as any; + + return this.templateRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + async findApprovedTemplates( + tenantId: string, + accountId: string + ): Promise { + return this.templateRepository.find({ + where: { tenantId, accountId, metaStatus: 'APPROVED', isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findTemplate(id: string): Promise { + return this.templateRepository.findOne({ where: { id } }); + } + + async findTemplateByName( + accountId: string, + name: string, + language: string = 'es_MX' + ): Promise { + return this.templateRepository.findOne({ where: { accountId, name, language } }); + } + + async createTemplate( + tenantId: string, + accountId: string, + data: Partial + ): Promise { + const template = this.templateRepository.create({ + ...data, + tenantId, + accountId, + metaStatus: 'PENDING', + }); + return this.templateRepository.save(template); + } + + async updateTemplate( + id: string, + data: Partial + ): Promise { + const template = await this.findTemplate(id); + if (!template) return null; + + // Increment version on update + Object.assign(template, data, { version: template.version + 1 }); + return this.templateRepository.save(template); + } + + async updateTemplateStatus( + id: string, + metaStatus: string, + metaTemplateId?: string, + rejectionReason?: string + ): Promise { + const template = await this.findTemplate(id); + if (!template) return null; + + template.metaStatus = metaStatus as any; + if (metaTemplateId) template.metaTemplateId = metaTemplateId; + if (rejectionReason) template.rejectionReason = rejectionReason; + if (metaStatus === 'APPROVED') template.approvedAt = new Date(); + + return this.templateRepository.save(template); + } + + async incrementTemplateUsage(id: string): Promise { + await this.templateRepository + .createQueryBuilder() + .update() + .set({ + usageCount: () => 'usage_count + 1', + lastUsedAt: new Date(), + }) + .where('id = :id', { id }) + .execute(); + } + + async deactivateTemplate(id: string): Promise { + const result = await this.templateRepository.update(id, { isActive: false }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // STATISTICS + // ============================================ + + async getAccountStats( + accountId: string, + startDate: Date, + endDate: Date + ): Promise<{ + totalMessages: number; + sent: number; + received: number; + delivered: number; + read: number; + failed: number; + }> { + const stats = await this.messageRepository + .createQueryBuilder('msg') + .select('COUNT(*)', 'total') + .addSelect("SUM(CASE WHEN msg.direction = 'outbound' THEN 1 ELSE 0 END)", 'sent') + .addSelect("SUM(CASE WHEN msg.direction = 'inbound' THEN 1 ELSE 0 END)", 'received') + .addSelect("SUM(CASE WHEN msg.status = 'delivered' THEN 1 ELSE 0 END)", 'delivered') + .addSelect("SUM(CASE WHEN msg.status = 'read' THEN 1 ELSE 0 END)", 'readCount') + .addSelect("SUM(CASE WHEN msg.status = 'failed' THEN 1 ELSE 0 END)", 'failed') + .where('msg.account_id = :accountId', { accountId }) + .andWhere('msg.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + return { + totalMessages: parseInt(stats?.total) || 0, + sent: parseInt(stats?.sent) || 0, + received: parseInt(stats?.received) || 0, + delivered: parseInt(stats?.delivered) || 0, + read: parseInt(stats?.readCount) || 0, + failed: parseInt(stats?.failed) || 0, + }; + } + + async getContactsWithExpiredWindow(accountId: string): Promise { + const now = new Date(); + return this.contactRepository.find({ + where: { + accountId, + conversationWindowExpiresAt: LessThan(now), + canSendTemplateOnly: false, + }, + }); + } +} diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts new file mode 100644 index 0000000..891f388 --- /dev/null +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { WhatsAppService } from './services'; +import { WhatsAppController } from './controllers'; +import { + WhatsAppAccount, + WhatsAppContact, + WhatsAppMessage, + WhatsAppTemplate, +} from './entities'; + +export interface WhatsAppModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class WhatsAppModule { + public router: Router; + public whatsappService: WhatsAppService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: WhatsAppModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const accountRepository = this.dataSource.getRepository(WhatsAppAccount); + const contactRepository = this.dataSource.getRepository(WhatsAppContact); + const messageRepository = this.dataSource.getRepository(WhatsAppMessage); + const templateRepository = this.dataSource.getRepository(WhatsAppTemplate); + + this.whatsappService = new WhatsAppService( + accountRepository, + contactRepository, + messageRepository, + templateRepository + ); + } + + private initializeRoutes(): void { + const whatsappController = new WhatsAppController(this.whatsappService); + this.router.use(`${this.basePath}/whatsapp`, whatsappController.router); + } + + static getEntities(): Function[] { + return [ + WhatsAppAccount, + WhatsAppContact, + WhatsAppMessage, + WhatsAppTemplate, + ]; + } +} diff --git a/src/shared/services/feature-flags.service.ts b/src/shared/services/feature-flags.service.ts new file mode 100644 index 0000000..005fedb --- /dev/null +++ b/src/shared/services/feature-flags.service.ts @@ -0,0 +1,195 @@ +/** + * Feature Flags Service + * Permite activar/desactivar funcionalidades por tenant, usuario o porcentaje + */ + +export interface FeatureFlag { + enabled: boolean; + enabledTenants?: string[]; + disabledTenants?: string[]; + enabledUsers?: string[]; + disabledUsers?: string[]; + rolloutPercentage?: number; + description?: string; + metadata?: Record; +} + +export interface FeatureFlagContext { + tenantId?: string; + userId?: string; + profileCode?: string; + platform?: string; +} + +export class FeatureFlagService { + private flags: Map = new Map(); + private static instance: FeatureFlagService; + + private constructor() { + this.loadDefaultFlags(); + } + + static getInstance(): FeatureFlagService { + if (!FeatureFlagService.instance) { + FeatureFlagService.instance = new FeatureFlagService(); + } + return FeatureFlagService.instance; + } + + private loadDefaultFlags(): void { + // Flags por defecto + this.flags.set('mobile_app_enabled', { + enabled: true, + description: 'Habilita el acceso a la aplicacion movil', + }); + + this.flags.set('biometric_auth', { + enabled: true, + description: 'Habilita autenticacion biometrica', + }); + + this.flags.set('offline_mode', { + enabled: true, + description: 'Habilita modo offline en la app movil', + }); + + this.flags.set('payment_terminals', { + enabled: true, + description: 'Habilita integracion con terminales de pago', + }); + + this.flags.set('geofencing', { + enabled: true, + description: 'Habilita validacion de geofencing', + }); + + this.flags.set('push_notifications', { + enabled: true, + description: 'Habilita notificaciones push', + }); + + this.flags.set('usage_billing', { + enabled: true, + description: 'Habilita facturacion por uso', + }); + } + + async isEnabled(flagName: string, context?: FeatureFlagContext): Promise { + const flag = this.flags.get(flagName); + + if (!flag) { + return false; + } + + // Global flag deshabilitado + if (!flag.enabled) { + return false; + } + + // Tenant especificamente deshabilitado + if (context?.tenantId && flag.disabledTenants?.includes(context.tenantId)) { + return false; + } + + // Tenant especificamente habilitado (lista blanca) + if (context?.tenantId && flag.enabledTenants?.length) { + if (!flag.enabledTenants.includes(context.tenantId)) { + return false; + } + } + + // Usuario especificamente deshabilitado + if (context?.userId && flag.disabledUsers?.includes(context.userId)) { + return false; + } + + // Usuario especificamente habilitado (para beta testing) + if (context?.userId && flag.enabledUsers?.length) { + if (!flag.enabledUsers.includes(context.userId)) { + // Si hay lista de usuarios habilitados y este no esta, verificar rollout + if (flag.rolloutPercentage === undefined) { + return false; + } + } else { + return true; + } + } + + // Percentage rollout + if (flag.rolloutPercentage !== undefined && flag.rolloutPercentage < 100) { + const identifier = context?.userId || context?.tenantId || flagName; + const hash = this.hashString(`${flagName}-${identifier}`); + return (hash % 100) < flag.rolloutPercentage; + } + + return true; + } + + async setFlag(flagName: string, config: FeatureFlag): Promise { + this.flags.set(flagName, config); + // Aqui se podria persistir en BD si es necesario + } + + async getFlag(flagName: string): Promise { + return this.flags.get(flagName); + } + + async getAllFlags(): Promise> { + return new Map(this.flags); + } + + async updateFlag(flagName: string, updates: Partial): Promise { + const existing = this.flags.get(flagName); + if (existing) { + this.flags.set(flagName, { ...existing, ...updates }); + } + } + + async deleteFlag(flagName: string): Promise { + this.flags.delete(flagName); + } + + async enableForTenant(flagName: string, tenantId: string): Promise { + const flag = this.flags.get(flagName); + if (flag) { + const enabledTenants = flag.enabledTenants || []; + if (!enabledTenants.includes(tenantId)) { + enabledTenants.push(tenantId); + } + flag.enabledTenants = enabledTenants; + + // Remover de deshabilitados si estaba + if (flag.disabledTenants) { + flag.disabledTenants = flag.disabledTenants.filter((t) => t !== tenantId); + } + } + } + + async disableForTenant(flagName: string, tenantId: string): Promise { + const flag = this.flags.get(flagName); + if (flag) { + const disabledTenants = flag.disabledTenants || []; + if (!disabledTenants.includes(tenantId)) { + disabledTenants.push(tenantId); + } + flag.disabledTenants = disabledTenants; + + // Remover de habilitados si estaba + if (flag.enabledTenants) { + flag.enabledTenants = flag.enabledTenants.filter((t) => t !== tenantId); + } + } + } + + private hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return Math.abs(hash); + } +} + +// Export singleton instance +export const featureFlagService = FeatureFlagService.getInstance(); diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index ff03ec0..0ea3523 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -1,7 +1,6 @@ -export { - BaseService, - PaginatedResult, - BasePaginationFilters, - QueryOptions, - BaseServiceConfig, -} from './base.service.js'; +export { + FeatureFlagService, + FeatureFlag, + FeatureFlagContext, + featureFlagService, +} from './feature-flags.service'; diff --git a/src/shared/utils/circuit-breaker.ts b/src/shared/utils/circuit-breaker.ts new file mode 100644 index 0000000..41053b2 --- /dev/null +++ b/src/shared/utils/circuit-breaker.ts @@ -0,0 +1,158 @@ +/** + * Circuit Breaker Pattern Implementation + * Previene llamadas a servicios externos cuando estos estan fallando + */ + +export class CircuitBreakerOpenError extends Error { + constructor(public readonly circuitName: string) { + super(`Circuit breaker '${circuitName}' is OPEN. Service temporarily unavailable.`); + this.name = 'CircuitBreakerOpenError'; + } +} + +export type CircuitBreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + +export interface CircuitBreakerOptions { + failureThreshold?: number; + resetTimeout?: number; + halfOpenRequests?: number; + onStateChange?: (name: string, from: CircuitBreakerState, to: CircuitBreakerState) => void; +} + +export class CircuitBreaker { + private failures: number = 0; + private successes: number = 0; + private lastFailureTime: number = 0; + private state: CircuitBreakerState = 'CLOSED'; + private halfOpenAttempts: number = 0; + + private readonly failureThreshold: number; + private readonly resetTimeout: number; + private readonly halfOpenRequests: number; + private readonly onStateChange?: (name: string, from: CircuitBreakerState, to: CircuitBreakerState) => void; + + constructor( + private readonly name: string, + options: CircuitBreakerOptions = {} + ) { + this.failureThreshold = options.failureThreshold ?? 5; + this.resetTimeout = options.resetTimeout ?? 60000; // 1 minuto + this.halfOpenRequests = options.halfOpenRequests ?? 3; + this.onStateChange = options.onStateChange; + } + + async execute(fn: () => Promise): Promise { + if (this.state === 'OPEN') { + if (Date.now() - this.lastFailureTime >= this.resetTimeout) { + this.transitionTo('HALF_OPEN'); + } else { + throw new CircuitBreakerOpenError(this.name); + } + } + + if (this.state === 'HALF_OPEN' && this.halfOpenAttempts >= this.halfOpenRequests) { + throw new CircuitBreakerOpenError(this.name); + } + + try { + if (this.state === 'HALF_OPEN') { + this.halfOpenAttempts++; + } + + const result = await fn(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess(): void { + if (this.state === 'HALF_OPEN') { + this.successes++; + if (this.successes >= this.halfOpenRequests) { + this.transitionTo('CLOSED'); + } + } else { + this.failures = 0; + } + } + + private onFailure(): void { + this.failures++; + this.lastFailureTime = Date.now(); + + if (this.state === 'HALF_OPEN') { + this.transitionTo('OPEN'); + } else if (this.failures >= this.failureThreshold) { + this.transitionTo('OPEN'); + } + } + + private transitionTo(newState: CircuitBreakerState): void { + const oldState = this.state; + this.state = newState; + + if (newState === 'CLOSED') { + this.failures = 0; + this.successes = 0; + this.halfOpenAttempts = 0; + } else if (newState === 'HALF_OPEN') { + this.successes = 0; + this.halfOpenAttempts = 0; + } + + if (this.onStateChange) { + this.onStateChange(this.name, oldState, newState); + } + } + + getState(): CircuitBreakerState { + return this.state; + } + + getStats(): { + name: string; + state: CircuitBreakerState; + failures: number; + successes: number; + lastFailureTime: number; + } { + return { + name: this.name, + state: this.state, + failures: this.failures, + successes: this.successes, + lastFailureTime: this.lastFailureTime, + }; + } + + reset(): void { + this.transitionTo('CLOSED'); + } +} + +// Singleton registry para circuit breakers +class CircuitBreakerRegistry { + private breakers: Map = new Map(); + + get(name: string, options?: CircuitBreakerOptions): CircuitBreaker { + let breaker = this.breakers.get(name); + if (!breaker) { + breaker = new CircuitBreaker(name, options); + this.breakers.set(name, breaker); + } + return breaker; + } + + getAll(): Map { + return this.breakers; + } + + getAllStats(): Array> { + return Array.from(this.breakers.values()).map((b) => b.getStats()); + } +} + +export const circuitBreakerRegistry = new CircuitBreakerRegistry(); diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..be02c10 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,7 @@ +export { + CircuitBreaker, + CircuitBreakerOpenError, + CircuitBreakerState, + CircuitBreakerOptions, + circuitBreakerRegistry, +} from './circuit-breaker';