From acaec5684937c8fed7ab09c4f0ec2de7fe142241 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 12 Feb 2026 21:11:24 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A812=E6=9C=8D=E5=8A=A1DDD?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20+=20=E5=85=AC=E5=91=8A=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E7=B3=BB=E7=BB=9F=20(=E7=A7=BB=E6=A4=8D?= =?UTF-8?q?=E8=87=AArwadurian)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、DDD + Clean Architecture 重构 (全12服务) 对全部12个微服务(9 NestJS + 3 Go)实施严格的DDD分层: ### NestJS 服务 (9个): - auth-service: JWT策略已在domain层 - user-service: 新增 5 个 repository interface + implementation, 5 个 value objects (Email/Phone/KycLevel/Money), 3 组 domain events - issuer-service: 新增 5 个 repository interface + implementation, 3 个 value objects, 2 组 domain events, external AI client port - clearing-service: 新增 5 个 repository interface + implementation, 2 个 value objects, domain events - compliance-service: 新增 7 个 repository interface + implementation, 2 个 value objects, domain events, audit logger service - ai-service: 新增 conversation repository + TypeORM entity, AI agent client port 移至 domain/ports/ - notification-service: 新增 notification repository interface + implementation, channel providers, value objects - telemetry-service: 新增 3 个 repository interface + implementation - admin-service: 新增 app-version repository interface + implementation ### Go 服务 (3个): - trading-service: 重构为 domain/application/infrastructure 分层, 新增 repository interface + postgres 实现, matching engine 移入 application/service, 新增 value objects (Price/Quantity/OrderSide/OrderType), Kafka event publisher - translate-service: 新增 repository interface + postgres 实现, value objects (Address/ChainType) - chain-indexer: 新增 repository interface + postgres 实现, value objects (BlockHeight/TxHash), Kafka event publisher ### 关键模式: - NestJS: Symbol token DI (provide: Symbol → useClass: Impl) - Go: compile-time interface check (var _ Interface = (*Impl)(nil)) - TypeORM entity 保留 domain methods (pragmatic DDD) - Repository interface 在 domain/, 实现在 infrastructure/persistence/ ## 二、公告定向推送系统 (ported from rwadurian) 在 notification-service 中新增 Announcement 公告体系, 支持管理端向全体/标签/指定用户推送消息: ### 数据库: - 038_create_announcements.sql: 5张新表 - announcements (公告主表) - announcement_tag_targets (标签定向) - announcement_user_targets (用户定向) - announcement_reads (已读记录) - user_tags (用户标签) ### 三种定向模式: - ALL: 推送给全体用户 - BY_TAG: 按标签筛选用户 (用户标签与公告标签有交集) - SPECIFIC: 指定用户ID列表 ### 新增文件 (15个): - 5 个 TypeORM entity (Announcement + Read + TagTarget + UserTarget + UserTag) - 2 个 repository interface (IAnnouncementRepository 11方法, IUserTagRepository 6方法) - 2 个 repository 实现 (TypeORM QueryBuilder, targeting filter SQL) - 2 个 application service (AnnouncementService, UserTagService) - 2 个 DTO 文件 (announcement.dto.ts, user-tag.dto.ts) - 1 个 controller 文件 (含3个controller: AdminAnnouncement/AdminUserTag/UserAnnouncement) - 1 个 migration SQL ### API 端点: 管理端: POST /admin/announcements 创建公告(含定向配置) GET /admin/announcements 公告列表 GET /admin/announcements/:id 公告详情 PUT /admin/announcements/:id 更新公告 DELETE /admin/announcements/:id 删除公告 GET /admin/user-tags 所有标签(含用户数) POST /admin/user-tags/:userId 添加用户标签 DELETE /admin/user-tags/:userId 移除用户标签 PUT /admin/user-tags/:userId/sync 同步用户标签 用户端: GET /announcements 用户公告(按定向过滤+已读状态) GET /announcements/unread-count 未读数 PUT /announcements/:id/read 标记已读 PUT /announcements/read-all 全部已读 ### 设计决策: - Announcement 与现有 Notification 并存 (双轨): Notification = 事件驱动1:1通知, Announcement = 管理端广播/定向 - rwadurian accountSequences → gcx userIds (UUID) - rwadurian Prisma → gcx TypeORM Co-Authored-By: Claude Opus 4.6 --- .../005_create_address_mappings.sql | 16 +- backend/migrations/036_create_blocks.sql | 11 + .../037_create_chain_transactions.sql | 14 + .../migrations/038_create_announcements.sql | 74 +++++ .../admin-service/src/admin.module.ts | 8 +- .../services/app-version.service.ts | 32 +- .../domain/ports/package-parser.interface.ts | 20 ++ .../app-version.repository.interface.ts | 15 + .../parsers/package-parser.service.ts | 11 +- .../persistence/app-version.repository.ts | 57 ++++ .../controllers/admin-version.controller.ts | 6 +- backend/services/ai-service/src/ai.module.ts | 56 +++- .../services/admin-agent.service.ts | 124 +++---- .../services/ai-anomaly.service.ts | 65 ++-- .../application/services/ai-chat.service.ts | 73 ++--- .../application/services/ai-credit.service.ts | 76 ++--- .../services/ai-pricing.service.ts | 79 ++--- .../domain/entities/ai-conversation.entity.ts | 70 ++++ .../ai-service/src/domain/events/ai.events.ts | 42 +++ .../domain/ports/ai-agent.client.interface.ts | 146 +++++++++ .../conversation.repository.interface.ts | 11 + .../value-objects/credit-score-result.vo.ts | 102 ++++++ .../value-objects/pricing-suggestion.vo.ts | 85 +++++ .../ai-agent.client.interface.ts | 146 +++++++++ .../external-agents/ai-agent.client.ts | 145 +++++++++ .../persistence/conversation.repository.ts | 38 +++ .../http/controllers/ai.controller.ts | 40 ++- .../src/interface/http/dto/anomaly.dto.ts | 33 ++ .../src/interface/http/dto/chat.dto.ts | 33 ++ .../interface/http/dto/credit-score.dto.ts | 50 +++ .../src/interface/http/dto/pricing.dto.ts | 46 +++ .../services/chain-indexer/cmd/server/main.go | 84 ++++- backend/services/chain-indexer/go.mod | 55 +++- backend/services/chain-indexer/go.sum | 194 +++++++++++ .../application/service/indexer_service.go | 144 +++++++++ .../internal/domain/entity/block.go | 96 +++++- .../internal/domain/event/events.go | 82 +++++ .../domain/repository/block_repository.go | 27 ++ .../repository/transaction_repository.go | 23 ++ .../internal/domain/vo/block_height.go | 42 +++ .../internal/domain/vo/tx_hash.go | 43 +++ .../chain-indexer/internal/indexer/.gitkeep | 0 .../chain-indexer/internal/indexer/indexer.go | 81 ----- .../infrastructure/kafka/event_publisher.go | 73 +++++ .../postgres/block_repository.go | 114 +++++++ .../postgres/transaction_repository.go | 105 ++++++ .../http/handler/admin_chain_handler.go | 23 +- .../services/admin-finance.service.ts | 191 +++++------ .../services/admin-reports.service.ts | 45 ++- .../application/services/breakage.service.ts | 62 +++- .../application/services/refund.service.ts | 66 +++- .../services/settlement.service.ts | 121 +++++-- .../clearing-service/src/clearing.module.ts | 36 ++- .../src/domain/events/clearing.events.ts | 36 +++ .../breakage.repository.interface.ts | 9 + .../journal-entry.repository.interface.ts | 12 + .../refund.repository.interface.ts | 20 ++ .../report.repository.interface.ts | 14 + .../settlement.repository.interface.ts | 23 ++ .../domain/value-objects/breakage-rate.vo.ts | 72 +++++ .../value-objects/settlement-amount.vo.ts | 86 +++++ .../persistence/breakage.repository.ts | 28 ++ .../persistence/journal-entry.repository.ts | 50 +++ .../persistence/refund.repository.ts | 84 +++++ .../persistence/report.repository.ts | 43 +++ .../persistence/settlement.repository.ts | 101 ++++++ .../controllers/admin-finance.controller.ts | 40 +-- .../controllers/admin-reports.controller.ts | 23 +- .../http/controllers/clearing.controller.ts | 32 +- .../src/interface/http/dto/index.ts | 4 + .../src/interface/http/dto/pagination.dto.ts | 14 + .../src/interface/http/dto/refund.dto.ts | 39 +++ .../src/interface/http/dto/report.dto.ts | 33 ++ .../src/interface/http/dto/settlement.dto.ts | 40 +++ .../services/admin-compliance.service.ts | 97 +++--- .../services/admin-dispute.service.ts | 99 +++--- .../services/admin-insurance.service.ts | 115 +++---- .../services/admin-risk.service.ts | 136 ++++---- .../src/application/services/aml.service.ts | 25 +- .../src/application/services/ofac.service.ts | 15 +- .../src/application/services/sar.service.ts | 20 +- .../services/travel-rule.service.ts | 15 +- .../src/compliance.module.ts | 51 ++- .../src/domain/events/compliance.events.ts | 65 ++++ .../domain/ports/audit-logger.interface.ts | 23 ++ .../aml-alert.repository.interface.ts | 38 +++ .../audit-log.repository.interface.ts | 21 ++ .../dispute.repository.interface.ts | 16 + .../insurance-claim.repository.interface.ts | 20 ++ .../ofac-screening.repository.interface.ts | 13 + .../sar-report.repository.interface.ts | 18 ++ .../travel-rule.repository.interface.ts | 10 + .../domain/value-objects/alert-severity.vo.ts | 100 ++++++ .../src/domain/value-objects/risk-score.vo.ts | 77 +++++ .../persistence/aml-alert.repository.ts | 103 ++++++ .../persistence/audit-log.repository.ts | 57 ++++ .../persistence/dispute.repository.ts | 46 +++ .../persistence/insurance-claim.repository.ts | 56 ++++ .../persistence/ofac-screening.repository.ts | 38 +++ .../persistence/sar-report.repository.ts | 51 +++ .../persistence/travel-rule.repository.ts | 29 ++ .../services/audit-logger.service.ts | 33 ++ .../admin-compliance.controller.ts | 48 ++- .../controllers/admin-dispute.controller.ts | 31 +- .../controllers/admin-insurance.controller.ts | 24 +- .../http/controllers/admin-risk.controller.ts | 42 +-- .../http/controllers/compliance.controller.ts | 36 +-- .../http/dto/admin-compliance.dto.ts | 74 +++++ .../interface/http/dto/admin-dispute.dto.ts | 50 +++ .../interface/http/dto/admin-insurance.dto.ts | 22 ++ .../src/interface/http/dto/admin-risk.dto.ts | 18 ++ .../src/interface/http/dto/compliance.dto.ts | 66 ++++ .../src/interface/http/dto/index.ts | 6 + .../src/interface/http/dto/pagination.dto.ts | 20 ++ .../admin-coupon-analytics.service.ts | 219 +++---------- .../services/admin-coupon.service.ts | 87 ++--- .../services/admin-issuer.service.ts | 156 ++++----- .../services/admin-merchant.service.ts | 113 ++----- .../application/services/coupon.service.ts | 78 +++-- .../services/credit-scoring.service.ts | 43 ++- .../application/services/issuer.service.ts | 50 ++- .../src/domain/events/coupon.events.ts | 59 ++++ .../src/domain/events/issuer.events.ts | 33 ++ .../ports/ai-service.client.interface.ts | 31 ++ .../coupon-rule.repository.interface.ts | 11 + .../coupon.repository.interface.ts | 83 +++++ .../credit-metric.repository.interface.ts | 9 + .../issuer.repository.interface.ts | 20 ++ .../store.repository.interface.ts | 12 + .../value-objects/coupon-quantity.vo.ts | 68 ++++ .../domain/value-objects/credit-score.vo.ts | 77 +++++ .../src/domain/value-objects/money.vo.ts | 96 ++++++ .../external/ai-service.client.ts | 61 ++++ .../persistence/coupon-rule.repository.ts | 35 ++ .../persistence/coupon.repository.ts | 305 ++++++++++++++++++ .../persistence/credit-metric.repository.ts | 29 ++ .../persistence/issuer.repository.ts | 81 +++++ .../persistence/store.repository.ts | 45 +++ .../controllers/admin-analytics.controller.ts | 26 +- .../controllers/admin-coupon.controller.ts | 63 +--- .../controllers/admin-issuer.controller.ts | 60 +--- .../controllers/admin-merchant.controller.ts | 34 +- .../http/controllers/coupon.controller.ts | 40 ++- .../src/interface/http/dto/coupon.dto.ts | 159 ++++++++- .../src/interface/http/dto/issuer.dto.ts | 89 ++++- .../src/interface/http/guards/roles.guard.ts | 48 +++ .../issuer-service/src/issuer.module.ts | 43 +++ .../services/admin-notification.service.ts | 52 +-- .../services/announcement.service.ts | 226 +++++++++++++ .../services/event-consumer.service.ts | 14 +- .../services/notification.service.ts | 122 ++++--- .../application/services/user-tag.service.ts | 42 +++ .../entities/announcement-read.entity.ts | 26 ++ .../announcement-tag-target.entity.ts | 15 + .../announcement-user-target.entity.ts | 15 + .../domain/entities/announcement.entity.ts | 127 ++++++++ .../src/domain/entities/user-tag.entity.ts | 26 ++ .../src/domain/events/notification.events.ts | 59 ++++ .../ports/notification-provider.interface.ts | 22 ++ .../announcement.repository.interface.ts | 34 ++ .../notification.repository.interface.ts | 16 + .../user-tag.repository.interface.ts | 10 + .../value-objects/notification-channel.vo.ts | 52 +++ .../value-objects/notification-status.vo.ts | 85 +++++ .../persistence/announcement.repository.ts | 282 ++++++++++++++++ .../persistence/notification.repository.ts | 77 +++++ .../persistence/user-tag.repository.ts | 67 ++++ .../providers/email-notification.provider.ts | 22 ++ .../notification-provider.interface.ts | 22 ++ .../providers/push-notification.provider.ts | 22 ++ .../providers/sms-notification.provider.ts | 22 ++ .../admin-notification.controller.ts | 3 +- .../controllers/announcement.controller.ts | 212 ++++++++++++ .../controllers/notification.controller.ts | 10 +- .../interface/http/dto/announcement.dto.ts | 209 ++++++++++++ .../src/interface/http/dto/broadcast.dto.ts | 28 ++ .../src/interface/http/dto/index.ts | 4 + .../src/interface/http/dto/mark-read.dto.ts | 8 + .../http/dto/notification-query.dto.ts | 20 ++ .../http/dto/send-notification.dto.ts | 27 ++ .../src/interface/http/dto/user-tag.dto.ts | 23 ++ .../src/notification.module.ts | 76 ++++- .../services/telemetry-scheduler.service.ts | 103 ++---- .../application/services/telemetry.service.ts | 83 +++-- .../ports/presence.service.interface.ts | 27 ++ .../telemetry-metrics.service.interface.ts | 30 ++ .../telemetry-producer.service.interface.ts | 20 ++ ...daily-active-stats.repository.interface.ts | 8 + .../online-snapshot.repository.interface.ts | 9 + .../telemetry-event.repository.interface.ts | 24 ++ .../kafka/telemetry-producer.service.ts | 3 +- .../metrics/telemetry-metrics.service.ts | 35 +- .../daily-active-stats.repository.ts | 24 ++ .../persistence/online-snapshot.repository.ts | 28 ++ .../persistence/telemetry-event.repository.ts | 89 +++++ .../redis/presence-redis.service.ts | 3 +- .../controllers/admin-telemetry.controller.ts | 50 +-- .../http/controllers/metrics.controller.ts | 6 +- .../telemetry-service/src/telemetry.module.ts | 22 +- .../trading-service/cmd/server/main.go | 78 ++++- backend/services/trading-service/go.mod | 35 +- backend/services/trading-service/go.sum | 108 ++++++- .../application/service/matching_service.go | 138 ++++++++ .../application/service/trade_service.go | 152 +++++++++ .../internal/domain/entity/order.go | 107 ++++-- .../internal/domain/entity/orderbook.go | 219 +++++++++++++ .../internal/domain/entity/trade.go | 73 ++++- .../internal/domain/event/events.go | 129 ++++++++ .../domain/repository/order_repository.go | 31 ++ .../domain/repository/trade_repository.go | 29 ++ .../internal/domain/vo/order_side.go | 41 +++ .../internal/domain/vo/order_type.go | 33 ++ .../internal/domain/vo/price.go | 100 ++++++ .../internal/domain/vo/quantity.go | 80 +++++ .../infrastructure/kafka/event_publisher.go | 72 +++++ .../postgres/order_repository.go | 207 ++++++++++++ .../postgres/trade_repository.go | 146 +++++++++ .../http/handler/admin_mm_handler.go | 56 ++-- .../http/handler/admin_trade_handler.go | 47 +-- .../interface/http/handler/trade_handler.go | 90 ++++-- .../internal/matching/.gitkeep | 0 .../internal/matching/engine.go | 208 ------------ .../internal/orderbook/.gitkeep | 0 .../internal/orderbook/orderbook.go | 115 ------- .../translate-service/cmd/server/main.go | 50 ++- backend/services/translate-service/go.mod | 38 ++- backend/services/translate-service/go.sum | 118 +++++++ .../application/service/translate_service.go | 99 +++--- .../internal/domain/entity/address_mapping.go | 85 ++++- .../repository/address_mapping_repository.go | 26 ++ .../internal/domain/vo/address.go | 45 +++ .../internal/domain/vo/chain_type.go | 51 +++ .../postgres/address_mapping_repository.go | 117 +++++++ .../http/handler/translate_handler.go | 40 ++- .../services/admin-analytics.service.ts | 109 ++----- .../services/admin-dashboard.service.ts | 41 +-- .../services/admin-system.service.ts | 24 +- .../services/admin-user.service.ts | 75 ++--- .../src/application/services/kyc.service.ts | 19 +- .../application/services/message.service.ts | 8 +- .../services/user-profile.service.ts | 8 +- .../application/services/wallet.service.ts | 99 +++--- .../src/domain/events/kyc.events.ts | 24 ++ .../src/domain/events/user.events.ts | 26 ++ .../src/domain/events/wallet.events.ts | 26 ++ .../repositories/kyc.repository.interface.ts | 13 + .../message.repository.interface.ts | 11 + .../transaction.repository.interface.ts | 19 ++ .../repositories/user.repository.interface.ts | 34 ++ .../wallet.repository.interface.ts | 10 + .../src/domain/value-objects/email.vo.ts | 33 ++ .../src/domain/value-objects/kyc-level.vo.ts | 76 +++++ .../src/domain/value-objects/money.vo.ts | 83 +++++ .../src/domain/value-objects/phone.vo.ts | 33 ++ .../persistence/kyc.repository.ts | 11 +- .../persistence/message.repository.ts | 3 +- .../persistence/transaction.repository.ts | 49 ++- .../persistence/user.repository.ts | 112 ++++++- .../persistence/wallet.repository.ts | 23 +- .../controllers/admin-system.controller.ts | 11 +- .../http/controllers/admin-user.controller.ts | 9 +- .../interface/http/dto/admin-system.dto.ts | 27 ++ .../src/interface/http/dto/admin-user.dto.ts | 20 ++ .../src/interface/http/dto/message.dto.ts | 20 ++ .../services/user-service/src/user.module.ts | 20 +- 265 files changed, 12391 insertions(+), 2710 deletions(-) create mode 100644 backend/migrations/036_create_blocks.sql create mode 100644 backend/migrations/037_create_chain_transactions.sql create mode 100644 backend/migrations/038_create_announcements.sql create mode 100644 backend/services/admin-service/src/domain/ports/package-parser.interface.ts create mode 100644 backend/services/admin-service/src/domain/repositories/app-version.repository.interface.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/app-version.repository.ts create mode 100644 backend/services/ai-service/src/domain/entities/ai-conversation.entity.ts create mode 100644 backend/services/ai-service/src/domain/events/ai.events.ts create mode 100644 backend/services/ai-service/src/domain/ports/ai-agent.client.interface.ts create mode 100644 backend/services/ai-service/src/domain/repositories/conversation.repository.interface.ts create mode 100644 backend/services/ai-service/src/domain/value-objects/credit-score-result.vo.ts create mode 100644 backend/services/ai-service/src/domain/value-objects/pricing-suggestion.vo.ts create mode 100644 backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.interface.ts create mode 100644 backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.ts create mode 100644 backend/services/ai-service/src/infrastructure/persistence/conversation.repository.ts create mode 100644 backend/services/ai-service/src/interface/http/dto/anomaly.dto.ts create mode 100644 backend/services/ai-service/src/interface/http/dto/chat.dto.ts create mode 100644 backend/services/ai-service/src/interface/http/dto/credit-score.dto.ts create mode 100644 backend/services/ai-service/src/interface/http/dto/pricing.dto.ts create mode 100644 backend/services/chain-indexer/go.sum create mode 100644 backend/services/chain-indexer/internal/application/service/indexer_service.go create mode 100644 backend/services/chain-indexer/internal/domain/event/events.go create mode 100644 backend/services/chain-indexer/internal/domain/repository/block_repository.go create mode 100644 backend/services/chain-indexer/internal/domain/repository/transaction_repository.go create mode 100644 backend/services/chain-indexer/internal/domain/vo/block_height.go create mode 100644 backend/services/chain-indexer/internal/domain/vo/tx_hash.go delete mode 100644 backend/services/chain-indexer/internal/indexer/.gitkeep delete mode 100644 backend/services/chain-indexer/internal/indexer/indexer.go create mode 100644 backend/services/chain-indexer/internal/infrastructure/kafka/event_publisher.go create mode 100644 backend/services/chain-indexer/internal/infrastructure/postgres/block_repository.go create mode 100644 backend/services/chain-indexer/internal/infrastructure/postgres/transaction_repository.go create mode 100644 backend/services/clearing-service/src/domain/events/clearing.events.ts create mode 100644 backend/services/clearing-service/src/domain/repositories/breakage.repository.interface.ts create mode 100644 backend/services/clearing-service/src/domain/repositories/journal-entry.repository.interface.ts create mode 100644 backend/services/clearing-service/src/domain/repositories/refund.repository.interface.ts create mode 100644 backend/services/clearing-service/src/domain/repositories/report.repository.interface.ts create mode 100644 backend/services/clearing-service/src/domain/repositories/settlement.repository.interface.ts create mode 100644 backend/services/clearing-service/src/domain/value-objects/breakage-rate.vo.ts create mode 100644 backend/services/clearing-service/src/domain/value-objects/settlement-amount.vo.ts create mode 100644 backend/services/clearing-service/src/infrastructure/persistence/breakage.repository.ts create mode 100644 backend/services/clearing-service/src/infrastructure/persistence/journal-entry.repository.ts create mode 100644 backend/services/clearing-service/src/infrastructure/persistence/refund.repository.ts create mode 100644 backend/services/clearing-service/src/infrastructure/persistence/report.repository.ts create mode 100644 backend/services/clearing-service/src/infrastructure/persistence/settlement.repository.ts create mode 100644 backend/services/clearing-service/src/interface/http/dto/index.ts create mode 100644 backend/services/clearing-service/src/interface/http/dto/pagination.dto.ts create mode 100644 backend/services/clearing-service/src/interface/http/dto/refund.dto.ts create mode 100644 backend/services/clearing-service/src/interface/http/dto/report.dto.ts create mode 100644 backend/services/clearing-service/src/interface/http/dto/settlement.dto.ts create mode 100644 backend/services/compliance-service/src/domain/events/compliance.events.ts create mode 100644 backend/services/compliance-service/src/domain/ports/audit-logger.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/aml-alert.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/audit-log.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/dispute.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/insurance-claim.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/ofac-screening.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/sar-report.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/repositories/travel-rule.repository.interface.ts create mode 100644 backend/services/compliance-service/src/domain/value-objects/alert-severity.vo.ts create mode 100644 backend/services/compliance-service/src/domain/value-objects/risk-score.vo.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/aml-alert.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/audit-log.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/dispute.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/insurance-claim.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/ofac-screening.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/sar-report.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/persistence/travel-rule.repository.ts create mode 100644 backend/services/compliance-service/src/infrastructure/services/audit-logger.service.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/admin-compliance.dto.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/admin-dispute.dto.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/admin-insurance.dto.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/admin-risk.dto.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/compliance.dto.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/index.ts create mode 100644 backend/services/compliance-service/src/interface/http/dto/pagination.dto.ts create mode 100644 backend/services/issuer-service/src/domain/events/coupon.events.ts create mode 100644 backend/services/issuer-service/src/domain/events/issuer.events.ts create mode 100644 backend/services/issuer-service/src/domain/ports/ai-service.client.interface.ts create mode 100644 backend/services/issuer-service/src/domain/repositories/coupon-rule.repository.interface.ts create mode 100644 backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts create mode 100644 backend/services/issuer-service/src/domain/repositories/credit-metric.repository.interface.ts create mode 100644 backend/services/issuer-service/src/domain/repositories/issuer.repository.interface.ts create mode 100644 backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts create mode 100644 backend/services/issuer-service/src/domain/value-objects/coupon-quantity.vo.ts create mode 100644 backend/services/issuer-service/src/domain/value-objects/credit-score.vo.ts create mode 100644 backend/services/issuer-service/src/domain/value-objects/money.vo.ts create mode 100644 backend/services/issuer-service/src/infrastructure/external/ai-service.client.ts create mode 100644 backend/services/issuer-service/src/infrastructure/persistence/coupon-rule.repository.ts create mode 100644 backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts create mode 100644 backend/services/issuer-service/src/infrastructure/persistence/credit-metric.repository.ts create mode 100644 backend/services/issuer-service/src/infrastructure/persistence/issuer.repository.ts create mode 100644 backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts create mode 100644 backend/services/issuer-service/src/interface/http/guards/roles.guard.ts create mode 100644 backend/services/notification-service/src/application/services/announcement.service.ts create mode 100644 backend/services/notification-service/src/application/services/user-tag.service.ts create mode 100644 backend/services/notification-service/src/domain/entities/announcement-read.entity.ts create mode 100644 backend/services/notification-service/src/domain/entities/announcement-tag-target.entity.ts create mode 100644 backend/services/notification-service/src/domain/entities/announcement-user-target.entity.ts create mode 100644 backend/services/notification-service/src/domain/entities/announcement.entity.ts create mode 100644 backend/services/notification-service/src/domain/entities/user-tag.entity.ts create mode 100644 backend/services/notification-service/src/domain/events/notification.events.ts create mode 100644 backend/services/notification-service/src/domain/ports/notification-provider.interface.ts create mode 100644 backend/services/notification-service/src/domain/repositories/announcement.repository.interface.ts create mode 100644 backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts create mode 100644 backend/services/notification-service/src/domain/repositories/user-tag.repository.interface.ts create mode 100644 backend/services/notification-service/src/domain/value-objects/notification-channel.vo.ts create mode 100644 backend/services/notification-service/src/domain/value-objects/notification-status.vo.ts create mode 100644 backend/services/notification-service/src/infrastructure/persistence/announcement.repository.ts create mode 100644 backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts create mode 100644 backend/services/notification-service/src/infrastructure/persistence/user-tag.repository.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/email-notification.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/notification-provider.interface.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts create mode 100644 backend/services/notification-service/src/infrastructure/providers/sms-notification.provider.ts create mode 100644 backend/services/notification-service/src/interface/http/controllers/announcement.controller.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/announcement.dto.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/broadcast.dto.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/index.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/mark-read.dto.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/notification-query.dto.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/send-notification.dto.ts create mode 100644 backend/services/notification-service/src/interface/http/dto/user-tag.dto.ts create mode 100644 backend/services/telemetry-service/src/domain/ports/presence.service.interface.ts create mode 100644 backend/services/telemetry-service/src/domain/ports/telemetry-metrics.service.interface.ts create mode 100644 backend/services/telemetry-service/src/domain/ports/telemetry-producer.service.interface.ts create mode 100644 backend/services/telemetry-service/src/domain/repositories/daily-active-stats.repository.interface.ts create mode 100644 backend/services/telemetry-service/src/domain/repositories/online-snapshot.repository.interface.ts create mode 100644 backend/services/telemetry-service/src/domain/repositories/telemetry-event.repository.interface.ts create mode 100644 backend/services/telemetry-service/src/infrastructure/persistence/daily-active-stats.repository.ts create mode 100644 backend/services/telemetry-service/src/infrastructure/persistence/online-snapshot.repository.ts create mode 100644 backend/services/telemetry-service/src/infrastructure/persistence/telemetry-event.repository.ts create mode 100644 backend/services/trading-service/internal/application/service/matching_service.go create mode 100644 backend/services/trading-service/internal/application/service/trade_service.go create mode 100644 backend/services/trading-service/internal/domain/entity/orderbook.go create mode 100644 backend/services/trading-service/internal/domain/event/events.go create mode 100644 backend/services/trading-service/internal/domain/repository/order_repository.go create mode 100644 backend/services/trading-service/internal/domain/repository/trade_repository.go create mode 100644 backend/services/trading-service/internal/domain/vo/order_side.go create mode 100644 backend/services/trading-service/internal/domain/vo/order_type.go create mode 100644 backend/services/trading-service/internal/domain/vo/price.go create mode 100644 backend/services/trading-service/internal/domain/vo/quantity.go create mode 100644 backend/services/trading-service/internal/infrastructure/kafka/event_publisher.go create mode 100644 backend/services/trading-service/internal/infrastructure/postgres/order_repository.go create mode 100644 backend/services/trading-service/internal/infrastructure/postgres/trade_repository.go delete mode 100644 backend/services/trading-service/internal/matching/.gitkeep delete mode 100644 backend/services/trading-service/internal/matching/engine.go delete mode 100644 backend/services/trading-service/internal/orderbook/.gitkeep delete mode 100644 backend/services/trading-service/internal/orderbook/orderbook.go create mode 100644 backend/services/translate-service/go.sum create mode 100644 backend/services/translate-service/internal/domain/repository/address_mapping_repository.go create mode 100644 backend/services/translate-service/internal/domain/vo/address.go create mode 100644 backend/services/translate-service/internal/domain/vo/chain_type.go create mode 100644 backend/services/translate-service/internal/infrastructure/postgres/address_mapping_repository.go create mode 100644 backend/services/user-service/src/domain/events/kyc.events.ts create mode 100644 backend/services/user-service/src/domain/events/user.events.ts create mode 100644 backend/services/user-service/src/domain/events/wallet.events.ts create mode 100644 backend/services/user-service/src/domain/repositories/kyc.repository.interface.ts create mode 100644 backend/services/user-service/src/domain/repositories/message.repository.interface.ts create mode 100644 backend/services/user-service/src/domain/repositories/transaction.repository.interface.ts create mode 100644 backend/services/user-service/src/domain/repositories/user.repository.interface.ts create mode 100644 backend/services/user-service/src/domain/repositories/wallet.repository.interface.ts create mode 100644 backend/services/user-service/src/domain/value-objects/email.vo.ts create mode 100644 backend/services/user-service/src/domain/value-objects/kyc-level.vo.ts create mode 100644 backend/services/user-service/src/domain/value-objects/money.vo.ts create mode 100644 backend/services/user-service/src/domain/value-objects/phone.vo.ts create mode 100644 backend/services/user-service/src/interface/http/dto/admin-system.dto.ts create mode 100644 backend/services/user-service/src/interface/http/dto/admin-user.dto.ts create mode 100644 backend/services/user-service/src/interface/http/dto/message.dto.ts diff --git a/backend/migrations/005_create_address_mappings.sql b/backend/migrations/005_create_address_mappings.sql index ad63908..d96748a 100644 --- a/backend/migrations/005_create_address_mappings.sql +++ b/backend/migrations/005_create_address_mappings.sql @@ -1,9 +1,15 @@ -- 005: Address mappings (translate-service core) CREATE TABLE IF NOT EXISTS address_mappings ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - chain_address VARCHAR(42) NOT NULL UNIQUE, - signature TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + internal_address VARCHAR(128) NOT NULL, + chain_address VARCHAR(128) NOT NULL, + chain_type VARCHAR(20) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_address_mappings_chain_address ON address_mappings(chain_address); +CREATE UNIQUE INDEX IF NOT EXISTS idx_address_mappings_internal ON address_mappings(internal_address); +CREATE INDEX IF NOT EXISTS idx_address_mappings_chain ON address_mappings(chain_type, chain_address); +CREATE INDEX IF NOT EXISTS idx_address_mappings_user ON address_mappings(user_id); diff --git a/backend/migrations/036_create_blocks.sql b/backend/migrations/036_create_blocks.sql new file mode 100644 index 0000000..242903b --- /dev/null +++ b/backend/migrations/036_create_blocks.sql @@ -0,0 +1,11 @@ +-- 036: Indexed blockchain blocks (chain-indexer) +CREATE TABLE IF NOT EXISTS blocks ( + height BIGINT PRIMARY KEY, + hash VARCHAR(128) NOT NULL UNIQUE, + tx_count INT NOT NULL DEFAULT 0, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_blocks_hash ON blocks(hash); +CREATE INDEX idx_blocks_created_at ON blocks(created_at DESC); diff --git a/backend/migrations/037_create_chain_transactions.sql b/backend/migrations/037_create_chain_transactions.sql new file mode 100644 index 0000000..1a227d2 --- /dev/null +++ b/backend/migrations/037_create_chain_transactions.sql @@ -0,0 +1,14 @@ +-- 037: Indexed on-chain transactions (chain-indexer) +CREATE TABLE IF NOT EXISTS chain_transactions ( + hash VARCHAR(128) PRIMARY KEY, + block_height BIGINT NOT NULL REFERENCES blocks(height), + from_addr VARCHAR(128) NOT NULL, + to_addr VARCHAR(128) NOT NULL, + amount VARCHAR(78) NOT NULL DEFAULT '0', + status VARCHAR(20) NOT NULL DEFAULT 'confirmed', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chain_tx_block ON chain_transactions(block_height); +CREATE INDEX idx_chain_tx_from ON chain_transactions(from_addr); +CREATE INDEX idx_chain_tx_to ON chain_transactions(to_addr); diff --git a/backend/migrations/038_create_announcements.sql b/backend/migrations/038_create_announcements.sql new file mode 100644 index 0000000..1d45d5b --- /dev/null +++ b/backend/migrations/038_create_announcements.sql @@ -0,0 +1,74 @@ +-- ============================================================= +-- 038: Announcement targeting system (ported from rwadurian) +-- 公告定向推送系统: 全体用户 / 按标签 / 指定用户 +-- ============================================================= + +-- 1. 公告主表 +CREATE TABLE IF NOT EXISTS announcements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'SYSTEM' + CHECK (type IN ('SYSTEM','ACTIVITY','REWARD','UPGRADE','ANNOUNCEMENT')), + priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL' + CHECK (priority IN ('LOW','NORMAL','HIGH','URGENT')), + target_type VARCHAR(10) NOT NULL DEFAULT 'ALL' + CHECK (target_type IN ('ALL','BY_TAG','SPECIFIC')), + target_config JSONB, + image_url VARCHAR(500), + link_url VARCHAR(500), + is_enabled BOOLEAN NOT NULL DEFAULT true, + published_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_announcements_type ON announcements(type); +CREATE INDEX idx_announcements_target ON announcements(target_type); +CREATE INDEX idx_announcements_enabled ON announcements(is_enabled, published_at); + +-- 2. 公告标签定向表 +CREATE TABLE IF NOT EXISTS announcement_tag_targets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE, + tag VARCHAR(100) NOT NULL +); + +CREATE INDEX idx_ann_tag_targets_ann ON announcement_tag_targets(announcement_id); +CREATE INDEX idx_ann_tag_targets_tag ON announcement_tag_targets(tag); + +-- 3. 公告用户定向表 +CREATE TABLE IF NOT EXISTS announcement_user_targets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE, + user_id UUID NOT NULL +); + +CREATE INDEX idx_ann_user_targets_ann ON announcement_user_targets(announcement_id); +CREATE INDEX idx_ann_user_targets_user ON announcement_user_targets(user_id); + +-- 4. 公告已读记录表 +CREATE TABLE IF NOT EXISTS announcement_reads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + announcement_id UUID NOT NULL REFERENCES announcements(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (announcement_id, user_id) +); + +CREATE INDEX idx_ann_reads_user ON announcement_reads(user_id); +CREATE INDEX idx_ann_reads_ann ON announcement_reads(announcement_id); + +-- 5. 用户标签表 +CREATE TABLE IF NOT EXISTS user_tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + tag VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, tag) +); + +CREATE INDEX idx_user_tags_user ON user_tags(user_id); +CREATE INDEX idx_user_tags_tag ON user_tags(tag); diff --git a/backend/services/admin-service/src/admin.module.ts b/backend/services/admin-service/src/admin.module.ts index 53d445e..1ea05fa 100644 --- a/backend/services/admin-service/src/admin.module.ts +++ b/backend/services/admin-service/src/admin.module.ts @@ -10,7 +10,12 @@ import { AppVersion } from './domain/entities/app-version.entity'; import { AppVersionService } from './application/services/app-version.service'; import { FileStorageService } from './application/services/file-storage.service'; +// Domain +import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository.interface'; +import { PACKAGE_PARSER } from './domain/ports/package-parser.interface'; + // Infrastructure +import { AppVersionRepository } from './infrastructure/persistence/app-version.repository'; import { PackageParserService } from './infrastructure/parsers/package-parser.service'; // Interface - Controllers @@ -48,9 +53,10 @@ import { HealthController } from './interface/http/controllers/health.controller AdminVersionController, ], providers: [ + { provide: APP_VERSION_REPOSITORY, useClass: AppVersionRepository }, AppVersionService, FileStorageService, - PackageParserService, + { provide: PACKAGE_PARSER, useClass: PackageParserService }, ], }) export class AdminModule {} diff --git a/backend/services/admin-service/src/application/services/app-version.service.ts b/backend/services/admin-service/src/application/services/app-version.service.ts index d6bc18d..afbdf16 100644 --- a/backend/services/admin-service/src/application/services/app-version.service.ts +++ b/backend/services/admin-service/src/application/services/app-version.service.ts @@ -1,7 +1,5 @@ -import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AppVersion } from '../../domain/entities/app-version.entity'; +import { Injectable, NotFoundException, ConflictException, Inject, Logger } from '@nestjs/common'; +import { APP_VERSION_REPOSITORY, IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface'; import { Platform } from '../../domain/enums/platform.enum'; @Injectable() @@ -9,15 +7,12 @@ export class AppVersionService { private readonly logger = new Logger(AppVersionService.name); constructor( - @InjectRepository(AppVersion) private readonly versionRepo: Repository, + @Inject(APP_VERSION_REPOSITORY) private readonly versionRepo: IAppVersionRepository, ) {} /** Check for update - mobile client API */ async checkUpdate(platform: Platform, currentVersionCode: number) { - const latest = await this.versionRepo.findOne({ - where: { platform, isEnabled: true }, - order: { versionCode: 'DESC' }, - }); + const latest = await this.versionRepo.findLatestEnabled(platform); if (!latest || latest.versionCode <= currentVersionCode) { return { needUpdate: false }; @@ -40,19 +35,12 @@ export class AppVersionService { /** List versions (admin) */ async listVersions(platform?: Platform, includeDisabled = false) { - const where: any = {}; - if (platform) where.platform = platform; - if (!includeDisabled) where.isEnabled = true; - - return this.versionRepo.find({ - where, - order: { versionCode: 'DESC' }, - }); + return this.versionRepo.findByFilters(platform, includeDisabled); } /** Get version detail */ async getVersion(id: string) { - const version = await this.versionRepo.findOne({ where: { id } }); + const version = await this.versionRepo.findById(id); if (!version) throw new NotFoundException('Version not found'); return version; } @@ -73,9 +61,7 @@ export class AppVersionService { createdBy?: string; }) { // Check duplicate - const existing = await this.versionRepo.findOne({ - where: { platform: data.platform, versionCode: data.versionCode }, - }); + const existing = await this.versionRepo.findByPlatformAndCode(data.platform, data.versionCode); if (existing) { throw new ConflictException( `Version code ${data.versionCode} already exists for ${data.platform}`, @@ -108,14 +94,14 @@ export class AppVersionService { /** Toggle enable/disable */ async toggleVersion(id: string, isEnabled: boolean) { await this.getVersion(id); // Verify exists - await this.versionRepo.update(id, { isEnabled }); + await this.versionRepo.updatePartial(id, { isEnabled }); return { success: true }; } /** Delete version */ async deleteVersion(id: string) { await this.getVersion(id); // Verify exists - await this.versionRepo.delete(id); + await this.versionRepo.deleteById(id); return { success: true }; } diff --git a/backend/services/admin-service/src/domain/ports/package-parser.interface.ts b/backend/services/admin-service/src/domain/ports/package-parser.interface.ts new file mode 100644 index 0000000..0694f21 --- /dev/null +++ b/backend/services/admin-service/src/domain/ports/package-parser.interface.ts @@ -0,0 +1,20 @@ +/** + * Package Parser domain port. + * + * Abstracts APK/IPA binary parsing for version management. + * The concrete implementation lives in infrastructure/parsers/. + */ + +export const PACKAGE_PARSER = Symbol('IPackageParser'); + +export interface ParsedPackageInfo { + packageName: string; + versionCode: number; + versionName: string; + minSdkVersion?: string; + platform: 'ANDROID' | 'IOS'; +} + +export interface IPackageParser { + parse(buffer: Buffer, filename: string): Promise; +} diff --git a/backend/services/admin-service/src/domain/repositories/app-version.repository.interface.ts b/backend/services/admin-service/src/domain/repositories/app-version.repository.interface.ts new file mode 100644 index 0000000..6faa2a8 --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/app-version.repository.interface.ts @@ -0,0 +1,15 @@ +import { AppVersion } from '../entities/app-version.entity'; +import { Platform } from '../enums/platform.enum'; + +export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY'); + +export interface IAppVersionRepository { + findById(id: string): Promise; + findLatestEnabled(platform: Platform): Promise; + findByPlatformAndCode(platform: Platform, versionCode: number): Promise; + findByFilters(platform?: Platform, includeDisabled?: boolean): Promise; + create(data: Partial): AppVersion; + save(entity: AppVersion): Promise; + updatePartial(id: string, data: Partial): Promise; + deleteById(id: string): Promise; +} diff --git a/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts b/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts index 8e4a5bb..df02f6f 100644 --- a/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts +++ b/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts @@ -1,15 +1,8 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; - -interface ParsedPackageInfo { - packageName: string; - versionCode: number; - versionName: string; - minSdkVersion?: string; - platform: 'ANDROID' | 'IOS'; -} +import { IPackageParser, ParsedPackageInfo } from '../../domain/ports/package-parser.interface'; @Injectable() -export class PackageParserService { +export class PackageParserService implements IPackageParser { private readonly logger = new Logger(PackageParserService.name); async parse(buffer: Buffer, filename: string): Promise { diff --git a/backend/services/admin-service/src/infrastructure/persistence/app-version.repository.ts b/backend/services/admin-service/src/infrastructure/persistence/app-version.repository.ts new file mode 100644 index 0000000..192e818 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/app-version.repository.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AppVersion } from '../../domain/entities/app-version.entity'; +import { Platform } from '../../domain/enums/platform.enum'; +import { IAppVersionRepository } from '../../domain/repositories/app-version.repository.interface'; + +@Injectable() +export class AppVersionRepository implements IAppVersionRepository { + constructor( + @InjectRepository(AppVersion) private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findLatestEnabled(platform: Platform): Promise { + return this.repo.findOne({ + where: { platform, isEnabled: true }, + order: { versionCode: 'DESC' }, + }); + } + + async findByPlatformAndCode(platform: Platform, versionCode: number): Promise { + return this.repo.findOne({ + where: { platform, versionCode }, + }); + } + + async findByFilters(platform?: Platform, includeDisabled = false): Promise { + const where: any = {}; + if (platform) where.platform = platform; + if (!includeDisabled) where.isEnabled = true; + + return this.repo.find({ + where, + order: { versionCode: 'DESC' }, + }); + } + + create(data: Partial): AppVersion { + return this.repo.create(data); + } + + async save(entity: AppVersion): Promise { + return this.repo.save(entity); + } + + async updatePartial(id: string, data: Partial): Promise { + await this.repo.update(id, data); + } + + async deleteById(id: string): Promise { + await this.repo.delete(id); + } +} diff --git a/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts index 666ae9d..2a173fd 100644 --- a/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts +++ b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts @@ -1,5 +1,5 @@ import { - Controller, Get, Post, Put, Patch, Delete, + Controller, Get, Post, Put, Patch, Delete, Inject, Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; @@ -7,7 +7,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagg import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AppVersionService } from '../../../application/services/app-version.service'; import { FileStorageService } from '../../../application/services/file-storage.service'; -import { PackageParserService } from '../../../infrastructure/parsers/package-parser.service'; +import { PACKAGE_PARSER, IPackageParser } from '../../../domain/ports/package-parser.interface'; import { Platform } from '../../../domain/enums/platform.enum'; @ApiTags('admin-versions') @@ -19,7 +19,7 @@ export class AdminVersionController { constructor( private readonly versionService: AppVersionService, private readonly fileStorage: FileStorageService, - private readonly packageParser: PackageParserService, + @Inject(PACKAGE_PARSER) private readonly packageParser: IPackageParser, ) {} @Get() diff --git a/backend/services/ai-service/src/ai.module.ts b/backend/services/ai-service/src/ai.module.ts index 3c92fde..6a0ad5e 100644 --- a/backend/services/ai-service/src/ai.module.ts +++ b/backend/services/ai-service/src/ai.module.ts @@ -1,21 +1,73 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; + +// Domain entities +import { AiConversation } from './domain/entities/ai-conversation.entity'; + +// Domain repository tokens +import { CONVERSATION_REPOSITORY } from './domain/repositories/conversation.repository.interface'; + +// Infrastructure — persistence +import { ConversationRepository } from './infrastructure/persistence/conversation.repository'; + +// Infrastructure — external agents +import { AI_AGENT_CLIENT } from './domain/ports/ai-agent.client.interface'; +import { AiAgentClient } from './infrastructure/external-agents/ai-agent.client'; + +// Application services import { AiChatService } from './application/services/ai-chat.service'; import { AiCreditService } from './application/services/ai-credit.service'; import { AiPricingService } from './application/services/ai-pricing.service'; import { AiAnomalyService } from './application/services/ai-anomaly.service'; import { AdminAgentService } from './application/services/admin-agent.service'; + +// Interface — HTTP controllers import { AiController } from './interface/http/controllers/ai.controller'; import { AdminAgentController } from './interface/http/controllers/admin-agent.controller'; @Module({ imports: [ + ConfigModule.forRoot({ isGlobal: true }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres' as const, + host: config.get('DB_HOST', 'localhost'), + port: config.get('DB_PORT', 5432), + username: config.get('DB_USER', 'genex'), + password: config.get('DB_PASS', 'genex'), + database: config.get('DB_NAME', 'genex_ai'), + entities: [AiConversation], + synchronize: false, + }), + }), + TypeOrmModule.forFeature([AiConversation]), PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_ACCESS_SECRET', 'dev-access-secret'), + }), + }), ], controllers: [AiController, AdminAgentController], - providers: [AiChatService, AiCreditService, AiPricingService, AiAnomalyService, AdminAgentService], + providers: [ + // Infrastructure bindings (interface → implementation) + { provide: AI_AGENT_CLIENT, useClass: AiAgentClient }, + { provide: CONVERSATION_REPOSITORY, useClass: ConversationRepository }, + + // Application services + AiChatService, + AiCreditService, + AiPricingService, + AiAnomalyService, + AdminAgentService, + ], exports: [AiChatService, AiCreditService, AiPricingService, AiAnomalyService], }) export class AiModule {} diff --git a/backend/services/ai-service/src/application/services/admin-agent.service.ts b/backend/services/ai-service/src/application/services/admin-agent.service.ts index e8c763f..b9e79f8 100644 --- a/backend/services/ai-service/src/application/services/admin-agent.service.ts +++ b/backend/services/ai-service/src/application/services/admin-agent.service.ts @@ -1,18 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface AgentStats { - sessionsToday: number; - totalSessions: number; - avgResponseTimeMs: number; - satisfactionScore: number; - activeModules: number; -} - -export interface TopQuestion { - question: string; - count: number; - category: string; -} +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + AI_AGENT_CLIENT, + IAiAgentClient, + AgentStatsResponse, + AgentTopQuestion, + AgentSessionsResponse, + AgentSatisfactionMetrics, +} from '../../domain/ports/ai-agent.client.interface'; export interface AiModuleInfo { id: string; @@ -24,44 +18,24 @@ export interface AiModuleInfo { config: Record; } -export interface SessionSummary { - sessionId: string; - userId: string; - messageCount: number; - startedAt: string; - lastMessageAt: string; - satisfactionRating: number | null; -} - -export interface SatisfactionMetrics { - averageRating: number; - totalRatings: number; - distribution: Record; - trend: { period: string; rating: number }[]; -} - @Injectable() export class AdminAgentService { - private readonly logger = new Logger('AdminAgentService'); - private readonly agentUrl: string; - private readonly apiKey: string; + private readonly logger = new Logger(AdminAgentService.name); // In-memory module config (in production, this would come from DB or external service) private moduleConfigs: Map> = new Map(); - constructor() { - this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'; - this.apiKey = process.env.AI_AGENT_API_KEY || ''; - } + constructor( + @Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient, + ) {} /** * Get aggregate AI agent session stats. * Tries external agent cluster first, falls back to mock data. */ - async getStats(): Promise { + async getStats(): Promise { try { - const res = await this.callAgent('/api/v1/admin/stats'); - if (res) return res; + return await this.agentClient.getStats(); } catch (error) { this.logger.warn(`External agent stats unavailable: ${error.message}`); } @@ -79,10 +53,9 @@ export class AdminAgentService { /** * Get most commonly asked questions. */ - async getTopQuestions(limit = 10): Promise { + async getTopQuestions(limit = 10): Promise { try { - const res = await this.callAgent(`/api/v1/admin/top-questions?limit=${limit}`); - if (res) return res; + return await this.agentClient.getTopQuestions(limit); } catch (error) { this.logger.warn(`External agent top-questions unavailable: ${error.message}`); } @@ -108,7 +81,7 @@ export class AdminAgentService { async getModules(): Promise { const now = new Date().toISOString(); - const modules: AiModuleInfo[] = [ + return [ { id: 'chat', name: 'AI Chat Assistant', @@ -146,8 +119,6 @@ export class AdminAgentService { config: this.moduleConfigs.get('anomaly') || { riskThreshold: 50, alertEnabled: true }, }, ]; - - return modules; } /** @@ -163,30 +134,42 @@ export class AdminAgentService { // Try to propagate to external agent try { - await this.callAgent(`/api/v1/admin/modules/${moduleId}/config`, 'POST', merged); + await this.agentClient.configureModule(moduleId, merged); } catch { this.logger.warn(`Could not propagate config to external agent for module ${moduleId}`); } const modules = await this.getModules(); const updated = modules.find((m) => m.id === moduleId); - return updated || { id: moduleId, name: moduleId, description: '', enabled: true, accuracy: 0, lastUpdated: new Date().toISOString(), config: merged }; + return ( + updated || { + id: moduleId, + name: moduleId, + description: '', + enabled: true, + accuracy: 0, + lastUpdated: new Date().toISOString(), + config: merged, + } + ); } /** * Get recent AI chat sessions. */ - async getSessions(page: number, limit: number): Promise<{ items: SessionSummary[]; total: number; page: number; limit: number }> { + async getSessions( + page: number, + limit: number, + ): Promise { try { - const res = await this.callAgent(`/api/v1/admin/sessions?page=${page}&limit=${limit}`); - if (res) return res; + return await this.agentClient.getSessions(page, limit); } catch (error) { this.logger.warn(`External agent sessions unavailable: ${error.message}`); } // Mock session data const now = Date.now(); - const mockSessions: SessionSummary[] = Array.from({ length: Math.min(limit, 10) }, (_, i) => ({ + const items = Array.from({ length: Math.min(limit, 10) }, (_, i) => ({ sessionId: `session-${1000 - i - (page - 1) * limit}`, userId: `user-${Math.floor(Math.random() * 500) + 1}`, messageCount: Math.floor(Math.random() * 20) + 1, @@ -195,16 +178,15 @@ export class AdminAgentService { satisfactionRating: Math.random() > 0.3 ? Math.floor(Math.random() * 2) + 4 : null, })); - return { items: mockSessions, total: 100, page, limit }; + return { items, total: 100, page, limit }; } /** * Get satisfaction metrics for AI chat sessions. */ - async getSatisfactionMetrics(): Promise { + async getSatisfactionMetrics(): Promise { try { - const res = await this.callAgent('/api/v1/admin/satisfaction'); - if (res) return res; + return await this.agentClient.getSatisfactionMetrics(); } catch (error) { this.logger.warn(`External agent satisfaction unavailable: ${error.message}`); } @@ -230,32 +212,4 @@ export class AdminAgentService { ], }; } - - /** - * Call the external AI agent cluster. - */ - private async callAgent(path: string, method = 'GET', body?: any): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - const options: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), - }, - signal: controller.signal, - }; - if (body && method !== 'GET') { - options.body = JSON.stringify(body); - } - - const res = await fetch(`${this.agentUrl}${path}`, options); - if (!res.ok) throw new Error(`Agent returned ${res.status}`); - return res.json(); - } finally { - clearTimeout(timeoutId); - } - } } diff --git a/backend/services/ai-service/src/application/services/ai-anomaly.service.ts b/backend/services/ai-service/src/application/services/ai-anomaly.service.ts index b9bf418..d4df61f 100644 --- a/backend/services/ai-service/src/application/services/ai-anomaly.service.ts +++ b/backend/services/ai-service/src/application/services/ai-anomaly.service.ts @@ -1,37 +1,34 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface AnomalyCheckRequest { - userId: string; - transactionType: string; - amount: number; - metadata?: Record; -} - -export interface AnomalyCheckResponse { - isAnomalous: boolean; - riskScore: number; - reasons: string[]; -} +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + AI_AGENT_CLIENT, + IAiAgentClient, + AgentAnomalyRequest, +} from '../../domain/ports/ai-agent.client.interface'; +import { AnomalyDetectRequestDto, AnomalyDetectResponseDto } from '../../interface/http/dto/anomaly.dto'; @Injectable() export class AiAnomalyService { - private readonly logger = new Logger('AiAnomaly'); - private readonly agentUrl: string; - private readonly apiKey: string; + private readonly logger = new Logger(AiAnomalyService.name); - constructor() { - this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'; - this.apiKey = process.env.AI_AGENT_API_KEY || ''; - } + constructor( + @Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient, + ) {} + + async check(req: AnomalyDetectRequestDto): Promise { + const agentReq: AgentAnomalyRequest = { + userId: req.userId, + transactionType: req.transactionType, + amount: req.amount, + metadata: req.metadata, + }; - async check(req: AnomalyCheckRequest): Promise { try { - const res = await fetch(`${this.agentUrl}/api/v1/anomaly/check`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) }, - body: JSON.stringify(req), - }); - if (res.ok) return res.json(); + const raw = await this.agentClient.anomalyDetect(agentReq); + return { + isAnomalous: raw.isAnomalous, + riskScore: raw.riskScore, + reasons: raw.reasons, + }; } catch (error) { this.logger.warn(`External AI anomaly detection unavailable: ${error.message}`); } @@ -40,12 +37,18 @@ export class AiAnomalyService { return this.localAnomalyCheck(req); } - private localAnomalyCheck(req: AnomalyCheckRequest): AnomalyCheckResponse { + private localAnomalyCheck(req: AnomalyDetectRequestDto): AnomalyDetectResponseDto { const reasons: string[] = []; let riskScore = 0; - if (req.amount >= 10000) { reasons.push('Large transaction amount'); riskScore += 40; } - if (req.amount >= 2500 && req.amount < 3000) { reasons.push('Near structuring threshold'); riskScore += 30; } + if (req.amount >= 10000) { + reasons.push('Large transaction amount'); + riskScore += 40; + } + if (req.amount >= 2500 && req.amount < 3000) { + reasons.push('Near structuring threshold'); + riskScore += 30; + } return { isAnomalous: riskScore >= 50, diff --git a/backend/services/ai-service/src/application/services/ai-chat.service.ts b/backend/services/ai-service/src/application/services/ai-chat.service.ts index 2a2aa08..81d3224 100644 --- a/backend/services/ai-service/src/application/services/ai-chat.service.ts +++ b/backend/services/ai-service/src/application/services/ai-chat.service.ts @@ -1,43 +1,33 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface ChatRequest { - userId: string; - message: string; - sessionId?: string; - context?: Record; -} - -export interface ChatResponse { - reply: string; - sessionId: string; - suggestions?: string[]; -} +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + AI_AGENT_CLIENT, + IAiAgentClient, + AgentChatRequest, +} from '../../domain/ports/ai-agent.client.interface'; +import { ChatRequestDto, ChatResponseDto } from '../../interface/http/dto/chat.dto'; @Injectable() export class AiChatService { - private readonly logger = new Logger('AiChat'); - private readonly agentUrl: string; - private readonly apiKey: string; - private readonly timeout: number; + private readonly logger = new Logger(AiChatService.name); - constructor() { - this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'; - this.apiKey = process.env.AI_AGENT_API_KEY || ''; - this.timeout = parseInt(process.env.AI_AGENT_TIMEOUT || '30000', 10); - } + constructor( + @Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient, + ) {} + + async chat(req: ChatRequestDto): Promise { + const agentReq: AgentChatRequest = { + userId: req.userId, + message: req.message, + sessionId: req.sessionId, + context: req.context, + }; - async chat(req: ChatRequest): Promise { try { - const response = await this.callAgent('/api/v1/chat', { - user_id: req.userId, - message: req.message, - session_id: req.sessionId, - context: req.context, - }); + const response = await this.agentClient.chat(agentReq); return { - reply: response.reply || response.message || 'I apologize, I could not process your request.', - sessionId: response.session_id || req.sessionId || `session-${Date.now()}`, - suggestions: response.suggestions || [], + reply: response.reply || 'I apologize, I could not process your request.', + sessionId: response.sessionId, + suggestions: response.suggestions, }; } catch (error) { this.logger.error(`Chat failed: ${error.message}`); @@ -49,21 +39,4 @@ export class AiChatService { }; } } - - private async callAgent(path: string, body: any): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - try { - const res = await fetch(`${this.agentUrl}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) throw new Error(`Agent returned ${res.status}`); - return res.json(); - } finally { - clearTimeout(timeoutId); - } - } } diff --git a/backend/services/ai-service/src/application/services/ai-credit.service.ts b/backend/services/ai-service/src/application/services/ai-credit.service.ts index 4061a25..4ba4279 100644 --- a/backend/services/ai-service/src/application/services/ai-credit.service.ts +++ b/backend/services/ai-service/src/application/services/ai-credit.service.ts @@ -1,55 +1,45 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface CreditScoreRequest { - userId: string; - issuerId?: string; - redemptionRate: number; - breakageRate: number; - tenureDays: number; - satisfactionScore: number; -} - -export interface CreditScoreResponse { - score: number; - level: string; - factors: Record; - recommendations?: string[]; -} +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + AI_AGENT_CLIENT, + IAiAgentClient, + AgentCreditScoreRequest, +} from '../../domain/ports/ai-agent.client.interface'; +import { CreditScoreRequestDto, CreditScoreResponseDto } from '../../interface/http/dto/credit-score.dto'; +import { CreditScoreResult } from '../../domain/value-objects/credit-score-result.vo'; @Injectable() export class AiCreditService { - private readonly logger = new Logger('AiCredit'); - private readonly agentUrl: string; - private readonly apiKey: string; + private readonly logger = new Logger(AiCreditService.name); - constructor() { - this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'; - this.apiKey = process.env.AI_AGENT_API_KEY || ''; - } + constructor( + @Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient, + ) {} + + async getScore(req: CreditScoreRequestDto): Promise { + const agentReq: AgentCreditScoreRequest = { + userId: req.userId, + issuerId: req.issuerId, + redemptionRate: req.redemptionRate, + breakageRate: req.breakageRate, + tenureDays: req.tenureDays, + satisfactionScore: req.satisfactionScore, + }; - async getScore(req: CreditScoreRequest): Promise { try { - const res = await fetch(`${this.agentUrl}/api/v1/credit/score`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) }, - body: JSON.stringify(req), - }); - if (res.ok) return res.json(); + const raw = await this.agentClient.creditScore(agentReq); + const result = CreditScoreResult.fromResponse(raw); + return result.toPlain(); } catch (error) { this.logger.warn(`External AI credit scoring unavailable: ${error.message}`); } - // Fallback: local 4-factor calculation - return this.localCreditScore(req); - } - - private localCreditScore(req: CreditScoreRequest): CreditScoreResponse { - const r = Math.min(100, req.redemptionRate * 100) * 0.35; - const b = Math.min(100, (1 - req.breakageRate) * 100) * 0.25; - const t = Math.min(100, (req.tenureDays / 365) * 100) * 0.20; - const s = Math.min(100, req.satisfactionScore) * 0.20; - const score = Math.round(r + b + t + s); - const level = score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : score >= 20 ? 'D' : 'F'; - return { score, level, factors: { redemption: r, breakage: b, tenure: t, satisfaction: s } }; + // Fallback: local 4-factor calculation via domain value object + const result = CreditScoreResult.fromFactors({ + redemptionRate: req.redemptionRate, + breakageRate: req.breakageRate, + tenureDays: req.tenureDays, + satisfactionScore: req.satisfactionScore, + }); + return result.toPlain(); } } diff --git a/backend/services/ai-service/src/application/services/ai-pricing.service.ts b/backend/services/ai-service/src/application/services/ai-pricing.service.ts index cd73966..ed13adc 100644 --- a/backend/services/ai-service/src/application/services/ai-pricing.service.ts +++ b/backend/services/ai-service/src/application/services/ai-pricing.service.ts @@ -1,57 +1,46 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface PricingSuggestionRequest { - couponId: string; - faceValue: number; - daysToExpiry: number; - totalDays: number; - redemptionRate: number; - liquidityPremium: number; -} - -export interface PricingSuggestionResponse { - suggestedPrice: number; - confidence: number; - factors: Record; -} +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + AI_AGENT_CLIENT, + IAiAgentClient, + AgentPricingRequest, +} from '../../domain/ports/ai-agent.client.interface'; +import { PricingRequestDto, PricingResponseDto } from '../../interface/http/dto/pricing.dto'; +import { PricingSuggestion } from '../../domain/value-objects/pricing-suggestion.vo'; @Injectable() export class AiPricingService { - private readonly logger = new Logger('AiPricing'); - private readonly agentUrl: string; - private readonly apiKey: string; + private readonly logger = new Logger(AiPricingService.name); - constructor() { - this.agentUrl = process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'; - this.apiKey = process.env.AI_AGENT_API_KEY || ''; - } + constructor( + @Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient, + ) {} + + async getSuggestion(req: PricingRequestDto): Promise { + const agentReq: AgentPricingRequest = { + couponId: req.couponId, + faceValue: req.faceValue, + daysToExpiry: req.daysToExpiry, + totalDays: req.totalDays, + redemptionRate: req.redemptionRate, + liquidityPremium: req.liquidityPremium, + }; - async getSuggestion(req: PricingSuggestionRequest): Promise { try { - const res = await fetch(`${this.agentUrl}/api/v1/pricing/suggest`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}) }, - body: JSON.stringify(req), - }); - if (res.ok) return res.json(); + const raw = await this.agentClient.pricing(agentReq); + const result = PricingSuggestion.fromResponse(raw); + return result.toPlain(); } catch (error) { this.logger.warn(`External AI pricing unavailable: ${error.message}`); } - // Fallback: local 3-factor pricing model P = F × (1 - dt - rc - lp) - return this.localPricing(req); - } - - private localPricing(req: PricingSuggestionRequest): PricingSuggestionResponse { - const dt = req.totalDays > 0 ? Math.max(0, 1 - req.daysToExpiry / req.totalDays) * 0.3 : 0; - const rc = (1 - req.redemptionRate) * 0.2; - const lp = req.liquidityPremium; - const discount = dt + rc + lp; - const price = Math.max(req.faceValue * 0.1, req.faceValue * (1 - discount)); - return { - suggestedPrice: Math.round(price * 100) / 100, - confidence: 0.7, - factors: { timeDecay: dt, redemptionCredit: rc, liquidityPremium: lp }, - }; + // Fallback: local 3-factor pricing model via domain value object + const result = PricingSuggestion.fromLocalModel({ + faceValue: req.faceValue, + daysToExpiry: req.daysToExpiry, + totalDays: req.totalDays, + redemptionRate: req.redemptionRate, + liquidityPremium: req.liquidityPremium, + }); + return result.toPlain(); } } diff --git a/backend/services/ai-service/src/domain/entities/ai-conversation.entity.ts b/backend/services/ai-service/src/domain/entities/ai-conversation.entity.ts new file mode 100644 index 0000000..3a56f67 --- /dev/null +++ b/backend/services/ai-service/src/domain/entities/ai-conversation.entity.ts @@ -0,0 +1,70 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +export interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; + suggestions?: string[]; + timestamp: Date; +} + +@Entity('ai_conversations') +@Index('idx_ai_conversations_user', ['userId']) +@Index('idx_ai_conversations_session', ['sessionId'], { unique: true }) +export class AiConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'session_id', type: 'varchar', length: 100 }) + sessionId: string; + + @Column({ type: 'jsonb', default: '[]' }) + messages: ConversationMessage[]; + + @Column({ name: 'satisfaction_rating', type: 'smallint', nullable: true }) + satisfactionRating: number | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // ── Domain Methods ────────────────────────────────────────────────────────── + + addUserMessage(content: string): void { + this.messages.push({ + role: 'user', + content, + timestamp: new Date(), + }); + } + + addAssistantMessage(content: string, suggestions?: string[]): void { + this.messages.push({ + role: 'assistant', + content, + suggestions, + timestamp: new Date(), + }); + } + + rate(rating: number): void { + if (rating < 1 || rating > 5) { + throw new Error('Satisfaction rating must be between 1 and 5'); + } + this.satisfactionRating = rating; + } + + get messageCount(): number { + return this.messages.length; + } + + get lastMessageAt(): Date { + return this.messages.length > 0 + ? this.messages[this.messages.length - 1].timestamp + : this.createdAt; + } +} diff --git a/backend/services/ai-service/src/domain/events/ai.events.ts b/backend/services/ai-service/src/domain/events/ai.events.ts new file mode 100644 index 0000000..e625ee3 --- /dev/null +++ b/backend/services/ai-service/src/domain/events/ai.events.ts @@ -0,0 +1,42 @@ +/** + * AI Domain Events + * + * Emitted when significant domain actions occur within the AI service. + * These can be published to Kafka or consumed by other bounded contexts. + */ + +export interface ChatCompletedEvent { + userId: string; + sessionId: string; + messageCount: number; + hasError: boolean; + responseTimeMs: number; + timestamp: string; +} + +export interface CreditScoreCalculatedEvent { + userId: string; + issuerId: string | null; + score: number; + level: string; + source: 'external' | 'local-fallback'; + timestamp: string; +} + +export interface AnomalyDetectedEvent { + userId: string; + transactionType: string; + amount: number; + riskScore: number; + reasons: string[]; + source: 'external' | 'local-fallback'; + timestamp: string; +} + +export interface PricingSuggestionGeneratedEvent { + couponId: string; + suggestedPrice: number; + confidence: number; + source: 'external' | 'local-fallback'; + timestamp: string; +} diff --git a/backend/services/ai-service/src/domain/ports/ai-agent.client.interface.ts b/backend/services/ai-service/src/domain/ports/ai-agent.client.interface.ts new file mode 100644 index 0000000..c5f6b00 --- /dev/null +++ b/backend/services/ai-service/src/domain/ports/ai-agent.client.interface.ts @@ -0,0 +1,146 @@ +/** + * AI Agent Client Interface + * + * Anti-corruption layer abstraction for all external AI agent cluster communication. + * Application services depend on this interface, never on concrete HTTP implementations. + */ + +export const AI_AGENT_CLIENT = Symbol('IAiAgentClient'); + +// ── Chat ────────────────────────────────────────────────────────────────────── + +export interface AgentChatRequest { + userId: string; + message: string; + sessionId?: string; + context?: Record; +} + +export interface AgentChatResponse { + reply: string; + sessionId: string; + suggestions: string[]; +} + +// ── Credit Scoring ──────────────────────────────────────────────────────────── + +export interface AgentCreditScoreRequest { + userId: string; + issuerId?: string; + redemptionRate: number; + breakageRate: number; + tenureDays: number; + satisfactionScore: number; +} + +export interface AgentCreditScoreResponse { + score: number; + level: string; + factors: Record; + recommendations?: string[]; +} + +// ── Pricing ─────────────────────────────────────────────────────────────────── + +export interface AgentPricingRequest { + couponId: string; + faceValue: number; + daysToExpiry: number; + totalDays: number; + redemptionRate: number; + liquidityPremium: number; +} + +export interface AgentPricingResponse { + suggestedPrice: number; + confidence: number; + factors: Record; +} + +// ── Anomaly Detection ───────────────────────────────────────────────────────── + +export interface AgentAnomalyRequest { + userId: string; + transactionType: string; + amount: number; + metadata?: Record; +} + +export interface AgentAnomalyResponse { + isAnomalous: boolean; + riskScore: number; + reasons: string[]; +} + +// ── Admin ───────────────────────────────────────────────────────────────────── + +export interface AgentStatsResponse { + sessionsToday: number; + totalSessions: number; + avgResponseTimeMs: number; + satisfactionScore: number; + activeModules: number; +} + +export interface AgentTopQuestion { + question: string; + count: number; + category: string; +} + +export interface AgentSessionSummary { + sessionId: string; + userId: string; + messageCount: number; + startedAt: string; + lastMessageAt: string; + satisfactionRating: number | null; +} + +export interface AgentSessionsResponse { + items: AgentSessionSummary[]; + total: number; + page: number; + limit: number; +} + +export interface AgentSatisfactionMetrics { + averageRating: number; + totalRatings: number; + distribution: Record; + trend: { period: string; rating: number }[]; +} + +// ── Interface ───────────────────────────────────────────────────────────────── + +export interface IAiAgentClient { + /** Send a chat message to the AI agent. */ + chat(req: AgentChatRequest): Promise; + + /** Request a credit score from the AI agent. */ + creditScore(req: AgentCreditScoreRequest): Promise; + + /** Request a pricing suggestion from the AI agent. */ + pricing(req: AgentPricingRequest): Promise; + + /** Request anomaly detection from the AI agent. */ + anomalyDetect(req: AgentAnomalyRequest): Promise; + + /** Get aggregate agent stats (admin). */ + getStats(): Promise; + + /** Get top questions (admin). */ + getTopQuestions(limit: number): Promise; + + /** Get sessions (admin). */ + getSessions(page: number, limit: number): Promise; + + /** Get satisfaction metrics (admin). */ + getSatisfactionMetrics(): Promise; + + /** Propagate module config to external agent. */ + configureModule(moduleId: string, config: Record): Promise; + + /** Check external agent health. */ + healthCheck(): Promise; +} diff --git a/backend/services/ai-service/src/domain/repositories/conversation.repository.interface.ts b/backend/services/ai-service/src/domain/repositories/conversation.repository.interface.ts new file mode 100644 index 0000000..3eabe45 --- /dev/null +++ b/backend/services/ai-service/src/domain/repositories/conversation.repository.interface.ts @@ -0,0 +1,11 @@ +import { AiConversation } from '../entities/ai-conversation.entity'; + +export interface IConversationRepository { + findById(id: string): Promise; + findBySessionId(sessionId: string): Promise; + findByUserId(userId: string, skip: number, take: number): Promise<[AiConversation[], number]>; + save(conversation: AiConversation): Promise; + countByUserId(userId: string): Promise; +} + +export const CONVERSATION_REPOSITORY = Symbol('IConversationRepository'); diff --git a/backend/services/ai-service/src/domain/value-objects/credit-score-result.vo.ts b/backend/services/ai-service/src/domain/value-objects/credit-score-result.vo.ts new file mode 100644 index 0000000..bd7efaf --- /dev/null +++ b/backend/services/ai-service/src/domain/value-objects/credit-score-result.vo.ts @@ -0,0 +1,102 @@ +/** + * Credit Score Result Value Object + * + * Encapsulates a credit score with its contributing factors and risk level. + * Immutable once created; all validation happens at construction time. + */ +export class CreditScoreResult { + readonly score: number; + readonly level: CreditLevel; + readonly factors: CreditScoreFactors; + readonly recommendations: string[]; + + private constructor(props: { + score: number; + level: CreditLevel; + factors: CreditScoreFactors; + recommendations: string[]; + }) { + this.score = props.score; + this.level = props.level; + this.factors = props.factors; + this.recommendations = props.recommendations; + } + + /** + * Create a CreditScoreResult from raw factor values. + * Applies weighting: redemption 35%, breakage 25%, tenure 20%, satisfaction 20%. + */ + static fromFactors(params: { + redemptionRate: number; + breakageRate: number; + tenureDays: number; + satisfactionScore: number; + }): CreditScoreResult { + const r = Math.min(100, params.redemptionRate * 100) * 0.35; + const b = Math.min(100, (1 - params.breakageRate) * 100) * 0.25; + const t = Math.min(100, (params.tenureDays / 365) * 100) * 0.20; + const s = Math.min(100, params.satisfactionScore) * 0.20; + const score = Math.round(r + b + t + s); + const level = CreditScoreResult.scoreToLevel(score); + + return new CreditScoreResult({ + score, + level, + factors: { redemption: r, breakage: b, tenure: t, satisfaction: s }, + recommendations: [], + }); + } + + /** + * Reconstitute from external AI agent response data. + */ + static fromResponse(data: { + score: number; + level: string; + factors: Record; + recommendations?: string[]; + }): CreditScoreResult { + return new CreditScoreResult({ + score: data.score, + level: (data.level as CreditLevel) || CreditScoreResult.scoreToLevel(data.score), + factors: { + redemption: data.factors.redemption ?? 0, + breakage: data.factors.breakage ?? 0, + tenure: data.factors.tenure ?? 0, + satisfaction: data.factors.satisfaction ?? 0, + }, + recommendations: data.recommendations ?? [], + }); + } + + private static scoreToLevel(score: number): CreditLevel { + if (score >= 80) return 'A'; + if (score >= 60) return 'B'; + if (score >= 40) return 'C'; + if (score >= 20) return 'D'; + return 'F'; + } + + toPlain(): { + score: number; + level: string; + factors: Record; + recommendations: string[]; + } { + return { + score: this.score, + level: this.level, + factors: { ...this.factors }, + recommendations: [...this.recommendations], + }; + } +} + +export type CreditLevel = 'A' | 'B' | 'C' | 'D' | 'F'; + +export interface CreditScoreFactors { + redemption: number; + breakage: number; + tenure: number; + satisfaction: number; +} diff --git a/backend/services/ai-service/src/domain/value-objects/pricing-suggestion.vo.ts b/backend/services/ai-service/src/domain/value-objects/pricing-suggestion.vo.ts new file mode 100644 index 0000000..fe88730 --- /dev/null +++ b/backend/services/ai-service/src/domain/value-objects/pricing-suggestion.vo.ts @@ -0,0 +1,85 @@ +/** + * Pricing Suggestion Value Object + * + * Encapsulates a suggested price with confidence level and contributing factors. + * Immutable once created; all validation happens at construction time. + */ +export class PricingSuggestion { + readonly suggestedPrice: number; + readonly confidence: number; + readonly factors: PricingFactors; + + private constructor(props: { + suggestedPrice: number; + confidence: number; + factors: PricingFactors; + }) { + this.suggestedPrice = props.suggestedPrice; + this.confidence = props.confidence; + this.factors = props.factors; + } + + /** + * Create a PricingSuggestion using the local 3-factor pricing model. + * Formula: P = F x (1 - dt - rc - lp) + */ + static fromLocalModel(params: { + faceValue: number; + daysToExpiry: number; + totalDays: number; + redemptionRate: number; + liquidityPremium: number; + }): PricingSuggestion { + const dt = + params.totalDays > 0 + ? Math.max(0, 1 - params.daysToExpiry / params.totalDays) * 0.3 + : 0; + const rc = (1 - params.redemptionRate) * 0.2; + const lp = params.liquidityPremium; + const discount = dt + rc + lp; + const price = Math.max(params.faceValue * 0.1, params.faceValue * (1 - discount)); + + return new PricingSuggestion({ + suggestedPrice: Math.round(price * 100) / 100, + confidence: 0.7, + factors: { timeDecay: dt, redemptionCredit: rc, liquidityPremium: lp }, + }); + } + + /** + * Reconstitute from external AI agent response data. + */ + static fromResponse(data: { + suggestedPrice: number; + confidence: number; + factors: Record; + }): PricingSuggestion { + return new PricingSuggestion({ + suggestedPrice: data.suggestedPrice, + confidence: data.confidence, + factors: { + timeDecay: data.factors.timeDecay ?? 0, + redemptionCredit: data.factors.redemptionCredit ?? 0, + liquidityPremium: data.factors.liquidityPremium ?? 0, + }, + }); + } + + toPlain(): { + suggestedPrice: number; + confidence: number; + factors: Record; + } { + return { + suggestedPrice: this.suggestedPrice, + confidence: this.confidence, + factors: { ...this.factors }, + }; + } +} + +export interface PricingFactors { + timeDecay: number; + redemptionCredit: number; + liquidityPremium: number; +} diff --git a/backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.interface.ts b/backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.interface.ts new file mode 100644 index 0000000..c5f6b00 --- /dev/null +++ b/backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.interface.ts @@ -0,0 +1,146 @@ +/** + * AI Agent Client Interface + * + * Anti-corruption layer abstraction for all external AI agent cluster communication. + * Application services depend on this interface, never on concrete HTTP implementations. + */ + +export const AI_AGENT_CLIENT = Symbol('IAiAgentClient'); + +// ── Chat ────────────────────────────────────────────────────────────────────── + +export interface AgentChatRequest { + userId: string; + message: string; + sessionId?: string; + context?: Record; +} + +export interface AgentChatResponse { + reply: string; + sessionId: string; + suggestions: string[]; +} + +// ── Credit Scoring ──────────────────────────────────────────────────────────── + +export interface AgentCreditScoreRequest { + userId: string; + issuerId?: string; + redemptionRate: number; + breakageRate: number; + tenureDays: number; + satisfactionScore: number; +} + +export interface AgentCreditScoreResponse { + score: number; + level: string; + factors: Record; + recommendations?: string[]; +} + +// ── Pricing ─────────────────────────────────────────────────────────────────── + +export interface AgentPricingRequest { + couponId: string; + faceValue: number; + daysToExpiry: number; + totalDays: number; + redemptionRate: number; + liquidityPremium: number; +} + +export interface AgentPricingResponse { + suggestedPrice: number; + confidence: number; + factors: Record; +} + +// ── Anomaly Detection ───────────────────────────────────────────────────────── + +export interface AgentAnomalyRequest { + userId: string; + transactionType: string; + amount: number; + metadata?: Record; +} + +export interface AgentAnomalyResponse { + isAnomalous: boolean; + riskScore: number; + reasons: string[]; +} + +// ── Admin ───────────────────────────────────────────────────────────────────── + +export interface AgentStatsResponse { + sessionsToday: number; + totalSessions: number; + avgResponseTimeMs: number; + satisfactionScore: number; + activeModules: number; +} + +export interface AgentTopQuestion { + question: string; + count: number; + category: string; +} + +export interface AgentSessionSummary { + sessionId: string; + userId: string; + messageCount: number; + startedAt: string; + lastMessageAt: string; + satisfactionRating: number | null; +} + +export interface AgentSessionsResponse { + items: AgentSessionSummary[]; + total: number; + page: number; + limit: number; +} + +export interface AgentSatisfactionMetrics { + averageRating: number; + totalRatings: number; + distribution: Record; + trend: { period: string; rating: number }[]; +} + +// ── Interface ───────────────────────────────────────────────────────────────── + +export interface IAiAgentClient { + /** Send a chat message to the AI agent. */ + chat(req: AgentChatRequest): Promise; + + /** Request a credit score from the AI agent. */ + creditScore(req: AgentCreditScoreRequest): Promise; + + /** Request a pricing suggestion from the AI agent. */ + pricing(req: AgentPricingRequest): Promise; + + /** Request anomaly detection from the AI agent. */ + anomalyDetect(req: AgentAnomalyRequest): Promise; + + /** Get aggregate agent stats (admin). */ + getStats(): Promise; + + /** Get top questions (admin). */ + getTopQuestions(limit: number): Promise; + + /** Get sessions (admin). */ + getSessions(page: number, limit: number): Promise; + + /** Get satisfaction metrics (admin). */ + getSatisfactionMetrics(): Promise; + + /** Propagate module config to external agent. */ + configureModule(moduleId: string, config: Record): Promise; + + /** Check external agent health. */ + healthCheck(): Promise; +} diff --git a/backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.ts b/backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.ts new file mode 100644 index 0000000..c5318ce --- /dev/null +++ b/backend/services/ai-service/src/infrastructure/external-agents/ai-agent.client.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + IAiAgentClient, + AgentChatRequest, + AgentChatResponse, + AgentCreditScoreRequest, + AgentCreditScoreResponse, + AgentPricingRequest, + AgentPricingResponse, + AgentAnomalyRequest, + AgentAnomalyResponse, + AgentStatsResponse, + AgentTopQuestion, + AgentSessionsResponse, + AgentSatisfactionMetrics, +} from '../../domain/ports/ai-agent.client.interface'; + +/** + * Concrete implementation of IAiAgentClient. + * + * Encapsulates ALL HTTP communication with the external AI agent cluster. + * This is the single place where fetch() calls, timeouts, headers, and + * API key handling live. No other layer should make direct HTTP calls. + */ +@Injectable() +export class AiAgentClient implements IAiAgentClient { + private readonly logger = new Logger(AiAgentClient.name); + private readonly agentUrl: string; + private readonly apiKey: string; + private readonly timeout: number; + + constructor(private readonly config: ConfigService) { + this.agentUrl = this.config.get('AI_AGENT_CLUSTER_URL', 'http://localhost:8000'); + this.apiKey = this.config.get('AI_AGENT_API_KEY', ''); + this.timeout = this.config.get('AI_AGENT_TIMEOUT', 30000); + } + + // ── Core AI capabilities ────────────────────────────────────────────────── + + async chat(req: AgentChatRequest): Promise { + const raw = await this.post('/api/v1/chat', { + user_id: req.userId, + message: req.message, + session_id: req.sessionId, + context: req.context, + }); + return { + reply: raw.reply || raw.message || '', + sessionId: raw.session_id || req.sessionId || `session-${Date.now()}`, + suggestions: raw.suggestions || [], + }; + } + + async creditScore(req: AgentCreditScoreRequest): Promise { + return this.post('/api/v1/credit/score', req); + } + + async pricing(req: AgentPricingRequest): Promise { + return this.post('/api/v1/pricing/suggest', req); + } + + async anomalyDetect(req: AgentAnomalyRequest): Promise { + return this.post('/api/v1/anomaly/check', req); + } + + // ── Admin endpoints ─────────────────────────────────────────────────────── + + async getStats(): Promise { + return this.get('/api/v1/admin/stats'); + } + + async getTopQuestions(limit: number): Promise { + return this.get(`/api/v1/admin/top-questions?limit=${limit}`); + } + + async getSessions(page: number, limit: number): Promise { + return this.get(`/api/v1/admin/sessions?page=${page}&limit=${limit}`); + } + + async getSatisfactionMetrics(): Promise { + return this.get('/api/v1/admin/satisfaction'); + } + + async configureModule(moduleId: string, config: Record): Promise { + await this.post(`/api/v1/admin/modules/${moduleId}/config`, config); + } + + async healthCheck(): Promise { + try { + const res = await this.fetchWithTimeout(`${this.agentUrl}/health`, { + method: 'GET', + headers: this.buildHeaders(), + }); + return res.ok; + } catch { + return false; + } + } + + // ── Private HTTP helpers ────────────────────────────────────────────────── + + private async post(path: string, body: any): Promise { + const res = await this.fetchWithTimeout(`${this.agentUrl}${path}`, { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`AI Agent returned HTTP ${res.status} for POST ${path}`); + } + return res.json(); + } + + private async get(path: string): Promise { + const res = await this.fetchWithTimeout(`${this.agentUrl}${path}`, { + method: 'GET', + headers: this.buildHeaders(), + }); + if (!res.ok) { + throw new Error(`AI Agent returned HTTP ${res.status} for GET ${path}`); + } + return res.json(); + } + + private async fetchWithTimeout(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + } + + private buildHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + return headers; + } +} diff --git a/backend/services/ai-service/src/infrastructure/persistence/conversation.repository.ts b/backend/services/ai-service/src/infrastructure/persistence/conversation.repository.ts new file mode 100644 index 0000000..74d24d3 --- /dev/null +++ b/backend/services/ai-service/src/infrastructure/persistence/conversation.repository.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AiConversation } from '../../domain/entities/ai-conversation.entity'; +import { IConversationRepository } from '../../domain/repositories/conversation.repository.interface'; + +@Injectable() +export class ConversationRepository implements IConversationRepository { + constructor( + @InjectRepository(AiConversation) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findBySessionId(sessionId: string): Promise { + return this.repo.findOne({ where: { sessionId } }); + } + + async findByUserId(userId: string, skip: number, take: number): Promise<[AiConversation[], number]> { + return this.repo.findAndCount({ + where: { userId }, + skip, + take, + order: { updatedAt: 'DESC' }, + }); + } + + async save(conversation: AiConversation): Promise { + return this.repo.save(conversation); + } + + async countByUserId(userId: string): Promise { + return this.repo.count({ where: { userId } }); + } +} diff --git a/backend/services/ai-service/src/interface/http/controllers/ai.controller.ts b/backend/services/ai-service/src/interface/http/controllers/ai.controller.ts index 7d48881..f8c1783 100644 --- a/backend/services/ai-service/src/interface/http/controllers/ai.controller.ts +++ b/backend/services/ai-service/src/interface/http/controllers/ai.controller.ts @@ -1,10 +1,18 @@ -import { Controller, Post, Get, Body, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Controller, Post, Get, Body, Inject, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { AiChatService } from '../../../application/services/ai-chat.service'; import { AiCreditService } from '../../../application/services/ai-credit.service'; import { AiPricingService } from '../../../application/services/ai-pricing.service'; import { AiAnomalyService } from '../../../application/services/ai-anomaly.service'; +import { ChatRequestDto, ChatResponseDto } from '../dto/chat.dto'; +import { CreditScoreRequestDto, CreditScoreResponseDto } from '../dto/credit-score.dto'; +import { PricingRequestDto, PricingResponseDto } from '../dto/pricing.dto'; +import { AnomalyDetectRequestDto, AnomalyDetectResponseDto } from '../dto/anomaly.dto'; +import { + AI_AGENT_CLIENT, + IAiAgentClient, +} from '../../../domain/ports/ai-agent.client.interface'; @ApiTags('AI') @Controller('ai') @@ -14,13 +22,15 @@ export class AiController { private readonly creditService: AiCreditService, private readonly pricingService: AiPricingService, private readonly anomalyService: AiAnomalyService, + @Inject(AI_AGENT_CLIENT) private readonly agentClient: IAiAgentClient, ) {} @Post('chat') @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Chat with AI assistant' }) - async chat(@Body() body: { userId: string; message: string; sessionId?: string }) { + @ApiResponse({ status: 200, description: 'Chat response', type: ChatResponseDto }) + async chat(@Body() body: ChatRequestDto) { return { code: 0, data: await this.chatService.chat(body) }; } @@ -28,7 +38,8 @@ export class AiController { @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Get AI credit score' }) - async creditScore(@Body() body: any) { + @ApiResponse({ status: 200, description: 'Credit score result', type: CreditScoreResponseDto }) + async creditScore(@Body() body: CreditScoreRequestDto) { return { code: 0, data: await this.creditService.getScore(body) }; } @@ -36,7 +47,8 @@ export class AiController { @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Get AI pricing suggestion' }) - async pricingSuggestion(@Body() body: any) { + @ApiResponse({ status: 200, description: 'Pricing suggestion', type: PricingResponseDto }) + async pricingSuggestion(@Body() body: PricingRequestDto) { return { code: 0, data: await this.pricingService.getSuggestion(body) }; } @@ -44,18 +56,22 @@ export class AiController { @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Check for anomalous activity' }) - async anomalyCheck(@Body() body: any) { + @ApiResponse({ status: 200, description: 'Anomaly check result', type: AnomalyDetectResponseDto }) + async anomalyCheck(@Body() body: AnomalyDetectRequestDto) { return { code: 0, data: await this.anomalyService.check(body) }; } @Get('health') @ApiOperation({ summary: 'AI service health + external agent status' }) async health() { - let agentHealthy = false; - try { - const res = await fetch(`${process.env.AI_AGENT_CLUSTER_URL || 'http://localhost:8000'}/health`); - agentHealthy = res.ok; - } catch {} - return { code: 0, data: { service: 'ai-service', status: 'ok', externalAgent: agentHealthy ? 'connected' : 'unavailable' } }; + const agentHealthy = await this.agentClient.healthCheck(); + return { + code: 0, + data: { + service: 'ai-service', + status: 'ok', + externalAgent: agentHealthy ? 'connected' : 'unavailable', + }, + }; } } diff --git a/backend/services/ai-service/src/interface/http/dto/anomaly.dto.ts b/backend/services/ai-service/src/interface/http/dto/anomaly.dto.ts new file mode 100644 index 0000000..6627f27 --- /dev/null +++ b/backend/services/ai-service/src/interface/http/dto/anomaly.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsNumber, IsOptional, IsObject, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AnomalyDetectRequestDto { + @ApiProperty({ description: 'User ID', example: 'user-123' }) + @IsString() + userId: string; + + @ApiProperty({ description: 'Transaction type', example: 'trade' }) + @IsString() + transactionType: string; + + @ApiProperty({ description: 'Transaction amount', example: 5000 }) + @IsNumber() + @Min(0) + amount: number; + + @ApiPropertyOptional({ description: 'Additional transaction metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class AnomalyDetectResponseDto { + @ApiProperty({ description: 'Whether the transaction is anomalous' }) + isAnomalous: boolean; + + @ApiProperty({ description: 'Risk score (0-100)' }) + riskScore: number; + + @ApiProperty({ description: 'Reasons for anomaly detection', type: [String] }) + reasons: string[]; +} diff --git a/backend/services/ai-service/src/interface/http/dto/chat.dto.ts b/backend/services/ai-service/src/interface/http/dto/chat.dto.ts new file mode 100644 index 0000000..3eeecea --- /dev/null +++ b/backend/services/ai-service/src/interface/http/dto/chat.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsOptional, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ChatRequestDto { + @ApiProperty({ description: 'User ID', example: 'user-123' }) + @IsString() + userId: string; + + @ApiProperty({ description: 'Chat message content', example: 'How do I redeem a coupon?' }) + @IsString() + message: string; + + @ApiPropertyOptional({ description: 'Existing session ID to continue conversation' }) + @IsOptional() + @IsString() + sessionId?: string; + + @ApiPropertyOptional({ description: 'Additional context for the AI agent' }) + @IsOptional() + @IsObject() + context?: Record; +} + +export class ChatResponseDto { + @ApiProperty({ description: 'AI reply text' }) + reply: string; + + @ApiProperty({ description: 'Session ID for conversation continuity' }) + sessionId: string; + + @ApiPropertyOptional({ description: 'Suggested follow-up actions', type: [String] }) + suggestions?: string[]; +} diff --git a/backend/services/ai-service/src/interface/http/dto/credit-score.dto.ts b/backend/services/ai-service/src/interface/http/dto/credit-score.dto.ts new file mode 100644 index 0000000..148a05d --- /dev/null +++ b/backend/services/ai-service/src/interface/http/dto/credit-score.dto.ts @@ -0,0 +1,50 @@ +import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreditScoreRequestDto { + @ApiProperty({ description: 'User ID', example: 'user-123' }) + @IsString() + userId: string; + + @ApiPropertyOptional({ description: 'Issuer ID for issuer-specific scoring' }) + @IsOptional() + @IsString() + issuerId?: string; + + @ApiProperty({ description: 'Coupon redemption rate (0-1)', example: 0.75 }) + @IsNumber() + @Min(0) + @Max(1) + redemptionRate: number; + + @ApiProperty({ description: 'Coupon breakage rate (0-1)', example: 0.15 }) + @IsNumber() + @Min(0) + @Max(1) + breakageRate: number; + + @ApiProperty({ description: 'Account tenure in days', example: 365 }) + @IsNumber() + @Min(0) + tenureDays: number; + + @ApiProperty({ description: 'Customer satisfaction score (0-100)', example: 85 }) + @IsNumber() + @Min(0) + @Max(100) + satisfactionScore: number; +} + +export class CreditScoreResponseDto { + @ApiProperty({ description: 'Composite credit score (0-100)' }) + score: number; + + @ApiProperty({ description: 'Credit level (A/B/C/D/F)', enum: ['A', 'B', 'C', 'D', 'F'] }) + level: string; + + @ApiProperty({ description: 'Score factor breakdown' }) + factors: Record; + + @ApiPropertyOptional({ description: 'Improvement recommendations', type: [String] }) + recommendations?: string[]; +} diff --git a/backend/services/ai-service/src/interface/http/dto/pricing.dto.ts b/backend/services/ai-service/src/interface/http/dto/pricing.dto.ts new file mode 100644 index 0000000..5f1ae60 --- /dev/null +++ b/backend/services/ai-service/src/interface/http/dto/pricing.dto.ts @@ -0,0 +1,46 @@ +import { IsString, IsNumber, Min, Max } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class PricingRequestDto { + @ApiProperty({ description: 'Coupon ID', example: 'coupon-456' }) + @IsString() + couponId: string; + + @ApiProperty({ description: 'Coupon face value', example: 100 }) + @IsNumber() + @Min(0) + faceValue: number; + + @ApiProperty({ description: 'Days remaining to expiry', example: 90 }) + @IsNumber() + @Min(0) + daysToExpiry: number; + + @ApiProperty({ description: 'Total validity period in days', example: 365 }) + @IsNumber() + @Min(0) + totalDays: number; + + @ApiProperty({ description: 'Historical redemption rate (0-1)', example: 0.8 }) + @IsNumber() + @Min(0) + @Max(1) + redemptionRate: number; + + @ApiProperty({ description: 'Liquidity premium discount (0-1)', example: 0.05 }) + @IsNumber() + @Min(0) + @Max(1) + liquidityPremium: number; +} + +export class PricingResponseDto { + @ApiProperty({ description: 'Suggested trading price' }) + suggestedPrice: number; + + @ApiProperty({ description: 'Confidence level (0-1)' }) + confidence: number; + + @ApiProperty({ description: 'Pricing factor breakdown' }) + factors: Record; +} diff --git a/backend/services/chain-indexer/cmd/server/main.go b/backend/services/chain-indexer/cmd/server/main.go index 96d99b6..0c8612d 100644 --- a/backend/services/chain-indexer/cmd/server/main.go +++ b/backend/services/chain-indexer/cmd/server/main.go @@ -2,16 +2,23 @@ package main import ( "context" + "fmt" "net/http" "os" "os/signal" + "strings" "syscall" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" + pgdriver "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" - "github.com/genex/chain-indexer/internal/indexer" + appservice "github.com/genex/chain-indexer/internal/application/service" + "github.com/genex/chain-indexer/internal/infrastructure/kafka" + "github.com/genex/chain-indexer/internal/infrastructure/postgres" "github.com/genex/chain-indexer/internal/interface/http/handler" "github.com/genex/chain-indexer/internal/interface/http/middleware" ) @@ -25,29 +32,42 @@ func main() { port = "3009" } - idx := indexer.NewIndexer(logger) - idx.Start() + // ── Infrastructure layer ──────────────────────────────────────────── + db := mustInitDB(logger) + blockRepo := postgres.NewPostgresBlockRepository(db) + txRepo := postgres.NewPostgresTransactionRepository(db) + + eventPublisher := mustInitKafka(logger) + defer eventPublisher.Close() + + // ── Application layer ─────────────────────────────────────────────── + indexerSvc := appservice.NewIndexerService(logger, blockRepo, txRepo, eventPublisher) + indexerSvc.Start() + + // ── Interface layer (HTTP) ────────────────────────────────────────── r := gin.New() r.Use(gin.Recovery()) + // Health checks r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok", "service": "chain-indexer", "lastHeight": idx.GetLastHeight()}) + c.JSON(200, gin.H{"status": "ok", "service": "chain-indexer", "lastHeight": indexerSvc.GetLastHeight()}) }) r.GET("/health/ready", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ready"}) }) r.GET("/health/live", func(c *gin.Context) { c.JSON(200, gin.H{"status": "alive"}) }) + // Public API routes api := r.Group("/api/v1/chain") api.GET("/blocks", func(c *gin.Context) { - blocks := idx.GetRecentBlocks(20) - c.JSON(200, gin.H{"code": 0, "data": gin.H{"blocks": blocks, "lastHeight": idx.GetLastHeight()}}) + blocks := indexerSvc.GetRecentBlocks(20) + c.JSON(200, gin.H{"code": 0, "data": gin.H{"blocks": blocks, "lastHeight": indexerSvc.GetLastHeight()}}) }) api.GET("/status", func(c *gin.Context) { - c.JSON(200, gin.H{"code": 0, "data": gin.H{"lastHeight": idx.GetLastHeight(), "syncing": true}}) + c.JSON(200, gin.H{"code": 0, "data": gin.H{"lastHeight": indexerSvc.GetLastHeight(), "syncing": true}}) }) // Admin routes (require JWT + admin role) - adminChainHandler := handler.NewAdminChainHandler(idx) + adminChainHandler := handler.NewAdminChainHandler(indexerSvc) admin := r.Group("/api/v1/admin/chain") admin.Use(middleware.JWTAuth(), middleware.RequireAdmin()) { @@ -70,9 +90,55 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - idx.Stop() + indexerSvc.Stop() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() server.Shutdown(ctx) logger.Info("Chain Indexer stopped") } + +func mustInitDB(logger *zap.Logger) *gorm.DB { + host := getEnv("DB_HOST", "localhost") + dbPort := getEnv("DB_PORT", "5432") + user := getEnv("DB_USERNAME", "genex") + pass := getEnv("DB_PASSWORD", "genex_dev_password") + name := getEnv("DB_NAME", "genex") + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, dbPort, user, pass, name) + + db, err := gorm.Open(pgdriver.Open(dsn), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Warn), + }) + if err != nil { + logger.Fatal("Failed to connect to PostgreSQL", zap.Error(err)) + } + + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + + logger.Info("PostgreSQL connected", zap.String("host", host), zap.String("db", name)) + return db +} + +func mustInitKafka(logger *zap.Logger) *kafka.KafkaEventPublisher { + brokersEnv := getEnv("KAFKA_BROKERS", "localhost:9092") + brokers := strings.Split(brokersEnv, ",") + + publisher, err := kafka.NewKafkaEventPublisher(brokers) + if err != nil { + logger.Fatal("Failed to connect to Kafka", zap.Error(err)) + } + + logger.Info("Kafka producer connected", zap.Strings("brokers", brokers)) + return publisher +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/services/chain-indexer/go.mod b/backend/services/chain-indexer/go.mod index bc62b28..336a557 100644 --- a/backend/services/chain-indexer/go.mod +++ b/backend/services/chain-indexer/go.mod @@ -3,9 +3,60 @@ module github.com/genex/chain-indexer go 1.22 require ( + github.com/IBM/sarama v1.43.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/segmentio/kafka-go v0.4.47 - github.com/jackc/pgx/v5 v5.5.1 go.uber.org/zap v1.27.0 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.10 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eapache/go-resiliency v1.6.0 // indirect + github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect + github.com/eapache/queue v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rogpeppe/go-internal v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/services/chain-indexer/go.sum b/backend/services/chain-indexer/go.sum new file mode 100644 index 0000000..af4b1d1 --- /dev/null +++ b/backend/services/chain-indexer/go.sum @@ -0,0 +1,194 @@ +github.com/IBM/sarama v1.43.0 h1:YFFDn8mMI2QL0wOrG0J2sFoVIAFl7hS9JQi2YZsXtJc= +github.com/IBM/sarama v1.43.0/go.mod h1:zlE6HEbC/SMQ9mhEYaF7nNLYOUyrs0obySKCckWP9BM= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eapache/go-resiliency v1.6.0 h1:CqGDTLtpwuWKn6Nj3uNUdflaq+/kIPsg0gfNzHton30= +github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= +github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= +github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/services/chain-indexer/internal/application/service/indexer_service.go b/backend/services/chain-indexer/internal/application/service/indexer_service.go new file mode 100644 index 0000000..43a6ee5 --- /dev/null +++ b/backend/services/chain-indexer/internal/application/service/indexer_service.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/genex/chain-indexer/internal/domain/entity" + "github.com/genex/chain-indexer/internal/domain/event" + "github.com/genex/chain-indexer/internal/domain/repository" +) + +// IndexerService is the application service that orchestrates block indexing. +// It depends on domain repository and event publisher interfaces — not concrete +// implementations — following the Dependency Inversion Principle. +type IndexerService struct { + logger *zap.Logger + blockRepo repository.BlockRepository + txRepo repository.TransactionRepository + publisher event.EventPublisher + + mu sync.RWMutex + isRunning bool + stopCh chan struct{} +} + +// NewIndexerService creates a new IndexerService with all dependencies injected. +func NewIndexerService( + logger *zap.Logger, + blockRepo repository.BlockRepository, + txRepo repository.TransactionRepository, + publisher event.EventPublisher, +) *IndexerService { + return &IndexerService{ + logger: logger, + blockRepo: blockRepo, + txRepo: txRepo, + publisher: publisher, + stopCh: make(chan struct{}), + } +} + +// Start begins the mock block indexing loop. +func (s *IndexerService) Start() { + s.mu.Lock() + s.isRunning = true + s.mu.Unlock() + + s.logger.Info("Chain indexer started (mock mode)") + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + if err := s.indexNextBlock(); err != nil { + s.logger.Error("Failed to index block", zap.Error(err)) + } + } + } + }() +} + +// Stop halts the indexing loop. +func (s *IndexerService) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.isRunning { + s.isRunning = false + close(s.stopCh) + s.logger.Info("Chain indexer stopped") + } +} + +// GetLastHeight returns the height of the most recently indexed block. +func (s *IndexerService) GetLastHeight() int64 { + ctx := context.Background() + latest, err := s.blockRepo.FindLatest(ctx) + if err != nil || latest == nil { + return 0 + } + return latest.Height +} + +// GetRecentBlocks returns the N most recently indexed blocks. +func (s *IndexerService) GetRecentBlocks(limit int) []entity.Block { + ctx := context.Background() + blocks, err := s.blockRepo.FindRecent(ctx, limit) + if err != nil { + s.logger.Error("Failed to get recent blocks", zap.Error(err)) + return nil + } + + // Convert []*entity.Block to []entity.Block for backward compatibility + result := make([]entity.Block, len(blocks)) + for i, b := range blocks { + result[i] = *b + } + return result +} + +// indexNextBlock creates and indexes a mock block, persists it through the +// repository, and publishes a domain event. +func (s *IndexerService) indexNextBlock() error { + ctx := context.Background() + + // Determine next height + lastHeight := s.GetLastHeight() + nextHeight := lastHeight + 1 + + // Create block via domain factory + block, err := entity.NewBlock( + nextHeight, + fmt.Sprintf("0x%064d", nextHeight), + time.Now(), + 0, + ) + if err != nil { + return fmt.Errorf("failed to create block entity: %w", err) + } + + // Persist through repository + if err := s.blockRepo.SaveBlock(ctx, block); err != nil { + return fmt.Errorf("failed to save block: %w", err) + } + + // Publish domain event + evt := event.NewBlockIndexedEvent(block.Height, block.Hash, block.TxCount, block.Timestamp) + if err := s.publisher.Publish(evt); err != nil { + s.logger.Warn("Failed to publish block indexed event", zap.Error(err)) + // Non-fatal: don't fail the indexing operation + } + + s.logger.Debug("Indexed mock block", zap.Int64("height", nextHeight)) + return nil +} diff --git a/backend/services/chain-indexer/internal/domain/entity/block.go b/backend/services/chain-indexer/internal/domain/entity/block.go index 94a4370..00f76dc 100644 --- a/backend/services/chain-indexer/internal/domain/entity/block.go +++ b/backend/services/chain-indexer/internal/domain/entity/block.go @@ -1,7 +1,11 @@ package entity -import "time" +import ( + "fmt" + "time" +) +// Block is the aggregate root representing an indexed blockchain block. type Block struct { Height int64 `json:"height"` Hash string `json:"hash"` @@ -9,6 +13,50 @@ type Block struct { TxCount int `json:"txCount"` } +// NewBlock is the factory method that creates a validated Block entity. +func NewBlock(height int64, hash string, timestamp time.Time, txCount int) (*Block, error) { + if height < 0 { + return nil, fmt.Errorf("block height must be non-negative, got %d", height) + } + if hash == "" { + return nil, fmt.Errorf("block hash must not be empty") + } + if txCount < 0 { + return nil, fmt.Errorf("transaction count must be non-negative, got %d", txCount) + } + return &Block{ + Height: height, + Hash: hash, + Timestamp: timestamp, + TxCount: txCount, + }, nil +} + +// Validate checks all invariants on an existing Block. +func (b *Block) Validate() error { + if b.Height < 0 { + return fmt.Errorf("block height must be non-negative") + } + if b.Hash == "" { + return fmt.Errorf("block hash must not be empty") + } + if b.TxCount < 0 { + return fmt.Errorf("transaction count must be non-negative") + } + return nil +} + +// IsGenesis reports whether this is the genesis block (height 0). +func (b *Block) IsGenesis() bool { + return b.Height == 0 +} + +// HasTransactions reports whether this block contains any transactions. +func (b *Block) HasTransactions() bool { + return b.TxCount > 0 +} + +// ChainTransaction represents an indexed on-chain transaction. type ChainTransaction struct { Hash string `json:"hash"` BlockHeight int64 `json:"blockHeight"` @@ -18,3 +66,49 @@ type ChainTransaction struct { Status string `json:"status"` Timestamp time.Time `json:"timestamp"` } + +// NewChainTransaction is the factory method that creates a validated ChainTransaction. +func NewChainTransaction(hash string, blockHeight int64, from, to, amount, status string, timestamp time.Time) (*ChainTransaction, error) { + if hash == "" { + return nil, fmt.Errorf("transaction hash must not be empty") + } + if blockHeight < 0 { + return nil, fmt.Errorf("block height must be non-negative") + } + if from == "" { + return nil, fmt.Errorf("from address must not be empty") + } + return &ChainTransaction{ + Hash: hash, + BlockHeight: blockHeight, + From: from, + To: to, + Amount: amount, + Status: status, + Timestamp: timestamp, + }, nil +} + +// Validate checks all invariants on an existing ChainTransaction. +func (tx *ChainTransaction) Validate() error { + if tx.Hash == "" { + return fmt.Errorf("transaction hash must not be empty") + } + if tx.BlockHeight < 0 { + return fmt.Errorf("block height must be non-negative") + } + if tx.From == "" { + return fmt.Errorf("from address must not be empty") + } + return nil +} + +// IsConfirmed reports whether the transaction has a "confirmed" status. +func (tx *ChainTransaction) IsConfirmed() bool { + return tx.Status == "confirmed" +} + +// IsPending reports whether the transaction has a "pending" status. +func (tx *ChainTransaction) IsPending() bool { + return tx.Status == "pending" +} diff --git a/backend/services/chain-indexer/internal/domain/event/events.go b/backend/services/chain-indexer/internal/domain/event/events.go new file mode 100644 index 0000000..8f41dd0 --- /dev/null +++ b/backend/services/chain-indexer/internal/domain/event/events.go @@ -0,0 +1,82 @@ +package event + +import "time" + +// DomainEvent is the base interface for all domain events. +type DomainEvent interface { + // EventName returns the fully-qualified event name. + EventName() string + // OccurredAt returns the timestamp when the event was created. + OccurredAt() time.Time +} + +// BlockIndexedEvent is published when a new block has been successfully indexed. +type BlockIndexedEvent struct { + Height int64 `json:"height"` + Hash string `json:"hash"` + TxCount int `json:"txCount"` + Timestamp time.Time `json:"timestamp"` + occurredAt time.Time +} + +// NewBlockIndexedEvent creates a new BlockIndexedEvent. +func NewBlockIndexedEvent(height int64, hash string, txCount int, blockTime time.Time) *BlockIndexedEvent { + return &BlockIndexedEvent{ + Height: height, + Hash: hash, + TxCount: txCount, + Timestamp: blockTime, + occurredAt: time.Now(), + } +} + +// EventName returns the event name. +func (e *BlockIndexedEvent) EventName() string { + return "chain.block.indexed" +} + +// OccurredAt returns when the event was created. +func (e *BlockIndexedEvent) OccurredAt() time.Time { + return e.occurredAt +} + +// TransactionIndexedEvent is published when a transaction has been indexed. +type TransactionIndexedEvent struct { + TxHash string `json:"txHash"` + BlockHeight int64 `json:"blockHeight"` + From string `json:"from"` + To string `json:"to"` + Amount string `json:"amount"` + Status string `json:"status"` + occurredAt time.Time +} + +// NewTransactionIndexedEvent creates a new TransactionIndexedEvent. +func NewTransactionIndexedEvent(txHash string, blockHeight int64, from, to, amount, status string) *TransactionIndexedEvent { + return &TransactionIndexedEvent{ + TxHash: txHash, + BlockHeight: blockHeight, + From: from, + To: to, + Amount: amount, + Status: status, + occurredAt: time.Now(), + } +} + +// EventName returns the event name. +func (e *TransactionIndexedEvent) EventName() string { + return "chain.transaction.indexed" +} + +// OccurredAt returns when the event was created. +func (e *TransactionIndexedEvent) OccurredAt() time.Time { + return e.occurredAt +} + +// EventPublisher defines the contract for publishing domain events. +// Infrastructure layer (e.g. Kafka) provides the concrete implementation. +type EventPublisher interface { + // Publish sends a domain event to the event bus. + Publish(event DomainEvent) error +} diff --git a/backend/services/chain-indexer/internal/domain/repository/block_repository.go b/backend/services/chain-indexer/internal/domain/repository/block_repository.go new file mode 100644 index 0000000..9e251ab --- /dev/null +++ b/backend/services/chain-indexer/internal/domain/repository/block_repository.go @@ -0,0 +1,27 @@ +package repository + +import ( + "context" + + "github.com/genex/chain-indexer/internal/domain/entity" +) + +// BlockRepository defines the contract for block persistence. +// Infrastructure layer must provide the concrete implementation. +type BlockRepository interface { + // SaveBlock persists a block. If a block at the same height exists, it is overwritten. + SaveBlock(ctx context.Context, block *entity.Block) error + + // FindByHeight retrieves a block by its height. Returns nil if not found. + FindByHeight(ctx context.Context, height int64) (*entity.Block, error) + + // FindLatest returns the most recently indexed block. Returns nil if no blocks exist. + FindLatest(ctx context.Context) (*entity.Block, error) + + // FindRange returns all blocks in the height range [fromHeight, toHeight] inclusive, + // ordered by height ascending. + FindRange(ctx context.Context, fromHeight, toHeight int64) ([]*entity.Block, error) + + // FindRecent returns the most recent N blocks, ordered by height descending. + FindRecent(ctx context.Context, limit int) ([]*entity.Block, error) +} diff --git a/backend/services/chain-indexer/internal/domain/repository/transaction_repository.go b/backend/services/chain-indexer/internal/domain/repository/transaction_repository.go new file mode 100644 index 0000000..393ef81 --- /dev/null +++ b/backend/services/chain-indexer/internal/domain/repository/transaction_repository.go @@ -0,0 +1,23 @@ +package repository + +import ( + "context" + + "github.com/genex/chain-indexer/internal/domain/entity" +) + +// TransactionRepository defines the contract for on-chain transaction persistence. +// Infrastructure layer must provide the concrete implementation. +type TransactionRepository interface { + // Save persists a chain transaction. + Save(ctx context.Context, tx *entity.ChainTransaction) error + + // SaveBatch persists multiple transactions in a single operation. + SaveBatch(ctx context.Context, txs []*entity.ChainTransaction) error + + // FindByHash retrieves a transaction by its hash. Returns nil if not found. + FindByHash(ctx context.Context, hash string) (*entity.ChainTransaction, error) + + // FindByBlock returns all transactions belonging to a given block height. + FindByBlock(ctx context.Context, blockHeight int64) ([]*entity.ChainTransaction, error) +} diff --git a/backend/services/chain-indexer/internal/domain/vo/block_height.go b/backend/services/chain-indexer/internal/domain/vo/block_height.go new file mode 100644 index 0000000..3e2debf --- /dev/null +++ b/backend/services/chain-indexer/internal/domain/vo/block_height.go @@ -0,0 +1,42 @@ +package vo + +import "fmt" + +// BlockHeight is a value object representing a blockchain block height (number). +// Block heights must be non-negative. +type BlockHeight struct { + value int64 +} + +// NewBlockHeight creates a validated BlockHeight value object. +func NewBlockHeight(height int64) (BlockHeight, error) { + if height < 0 { + return BlockHeight{}, fmt.Errorf("block height must be non-negative, got %d", height) + } + return BlockHeight{value: height}, nil +} + +// Value returns the underlying int64 value. +func (h BlockHeight) Value() int64 { + return h.value +} + +// IsGenesis reports whether this is the genesis block (height 0). +func (h BlockHeight) IsGenesis() bool { + return h.value == 0 +} + +// Next returns the next block height. +func (h BlockHeight) Next() BlockHeight { + return BlockHeight{value: h.value + 1} +} + +// String returns the string representation. +func (h BlockHeight) String() string { + return fmt.Sprintf("%d", h.value) +} + +// IsValid reports whether the block height is valid (non-negative). +func (h BlockHeight) IsValid() bool { + return h.value >= 0 +} diff --git a/backend/services/chain-indexer/internal/domain/vo/tx_hash.go b/backend/services/chain-indexer/internal/domain/vo/tx_hash.go new file mode 100644 index 0000000..b7890fc --- /dev/null +++ b/backend/services/chain-indexer/internal/domain/vo/tx_hash.go @@ -0,0 +1,43 @@ +package vo + +import ( + "fmt" + "regexp" + "strings" +) + +// TransactionHash is a value object representing an on-chain transaction hash. +type TransactionHash struct { + value string +} + +// txHashPattern matches a hex-encoded transaction hash (0x-prefixed, 64 hex chars for 32 bytes). +var txHashPattern = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) + +// NewTransactionHash creates a validated TransactionHash value object. +func NewTransactionHash(raw string) (TransactionHash, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return TransactionHash{}, fmt.Errorf("transaction hash must not be empty") + } + if !txHashPattern.MatchString(trimmed) { + return TransactionHash{}, fmt.Errorf("invalid transaction hash format: %s", trimmed) + } + // Normalise to lowercase + return TransactionHash{value: strings.ToLower(trimmed)}, nil +} + +// Value returns the string value of the transaction hash. +func (h TransactionHash) Value() string { + return h.value +} + +// String returns the string representation. +func (h TransactionHash) String() string { + return h.value +} + +// IsValid reports whether the hash passes format validation. +func (h TransactionHash) IsValid() bool { + return txHashPattern.MatchString(h.value) +} diff --git a/backend/services/chain-indexer/internal/indexer/.gitkeep b/backend/services/chain-indexer/internal/indexer/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/services/chain-indexer/internal/indexer/indexer.go b/backend/services/chain-indexer/internal/indexer/indexer.go deleted file mode 100644 index d66cf51..0000000 --- a/backend/services/chain-indexer/internal/indexer/indexer.go +++ /dev/null @@ -1,81 +0,0 @@ -package indexer - -import ( - "fmt" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/genex/chain-indexer/internal/domain/entity" -) - -type Indexer struct { - logger *zap.Logger - lastHeight int64 - blocks []entity.Block - transactions []entity.ChainTransaction - mu sync.RWMutex - isRunning bool -} - -func NewIndexer(logger *zap.Logger) *Indexer { - return &Indexer{logger: logger} -} - -func (idx *Indexer) Start() { - idx.isRunning = true - idx.logger.Info("Chain indexer started (mock mode)") - - go func() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - for idx.isRunning { - select { - case <-ticker.C: - idx.mockIndexBlock() - } - } - }() -} - -func (idx *Indexer) Stop() { - idx.isRunning = false - idx.logger.Info("Chain indexer stopped") -} - -func (idx *Indexer) GetLastHeight() int64 { - idx.mu.RLock() - defer idx.mu.RUnlock() - return idx.lastHeight -} - -func (idx *Indexer) GetRecentBlocks(limit int) []entity.Block { - idx.mu.RLock() - defer idx.mu.RUnlock() - start := len(idx.blocks) - limit - if start < 0 { - start = 0 - } - result := make([]entity.Block, len(idx.blocks[start:])) - copy(result, idx.blocks[start:]) - return result -} - -func (idx *Indexer) mockIndexBlock() { - idx.mu.Lock() - defer idx.mu.Unlock() - idx.lastHeight++ - block := entity.Block{ - Height: idx.lastHeight, - Hash: fmt.Sprintf("0x%064d", idx.lastHeight), - Timestamp: time.Now(), - TxCount: 0, - } - idx.blocks = append(idx.blocks, block) - // Keep only last 1000 blocks in memory - if len(idx.blocks) > 1000 { - idx.blocks = idx.blocks[len(idx.blocks)-1000:] - } - idx.logger.Debug("Indexed mock block", zap.Int64("height", idx.lastHeight)) -} diff --git a/backend/services/chain-indexer/internal/infrastructure/kafka/event_publisher.go b/backend/services/chain-indexer/internal/infrastructure/kafka/event_publisher.go new file mode 100644 index 0000000..2fa5356 --- /dev/null +++ b/backend/services/chain-indexer/internal/infrastructure/kafka/event_publisher.go @@ -0,0 +1,73 @@ +package kafka + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/IBM/sarama" + "github.com/genex/chain-indexer/internal/domain/event" +) + +// Compile-time check: KafkaEventPublisher implements event.EventPublisher. +var _ event.EventPublisher = (*KafkaEventPublisher)(nil) + +// KafkaEventPublisher implements event.EventPublisher by publishing domain events +// to Kafka using the IBM/sarama client. +type KafkaEventPublisher struct { + producer sarama.SyncProducer +} + +// NewKafkaEventPublisher creates a new Kafka event publisher connected to the given brokers. +func NewKafkaEventPublisher(brokers []string) (*KafkaEventPublisher, error) { + config := sarama.NewConfig() + config.Producer.RequiredAcks = sarama.WaitForAll + config.Producer.Retry.Max = 3 + config.Producer.Return.Successes = true + + producer, err := sarama.NewSyncProducer(brokers, config) + if err != nil { + return nil, fmt.Errorf("failed to create Kafka producer: %w", err) + } + + return &KafkaEventPublisher{producer: producer}, nil +} + +// Publish serializes a domain event to JSON and publishes it to the appropriate Kafka topic. +func (p *KafkaEventPublisher) Publish(evt event.DomainEvent) error { + payload, err := json.Marshal(evt) + if err != nil { + return fmt.Errorf("failed to marshal event %s: %w", evt.EventName(), err) + } + + topic := resolveTopic(evt.EventName()) + + msg := &sarama.ProducerMessage{ + Topic: topic, + Key: sarama.StringEncoder(evt.EventName()), + Value: sarama.ByteEncoder(payload), + } + + _, _, err = p.producer.SendMessage(msg) + if err != nil { + return fmt.Errorf("failed to publish event %s to topic %s: %w", evt.EventName(), topic, err) + } + + return nil +} + +// Close shuts down the Kafka producer gracefully. +func (p *KafkaEventPublisher) Close() error { + if p.producer != nil { + return p.producer.Close() + } + return nil +} + +// resolveTopic maps event names to Kafka topics. +func resolveTopic(eventName string) string { + if strings.HasPrefix(eventName, "chain.block.") { + return "chain.blocks" + } + return "chain.transactions" +} diff --git a/backend/services/chain-indexer/internal/infrastructure/postgres/block_repository.go b/backend/services/chain-indexer/internal/infrastructure/postgres/block_repository.go new file mode 100644 index 0000000..439615a --- /dev/null +++ b/backend/services/chain-indexer/internal/infrastructure/postgres/block_repository.go @@ -0,0 +1,114 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/genex/chain-indexer/internal/domain/entity" + "github.com/genex/chain-indexer/internal/domain/repository" + "gorm.io/gorm" +) + +// Compile-time check: PostgresBlockRepository implements repository.BlockRepository. +var _ repository.BlockRepository = (*PostgresBlockRepository)(nil) + +// blockModel is the GORM persistence model for the blocks table. +type blockModel struct { + Height int64 `gorm:"column:height;primaryKey"` + Hash string `gorm:"column:hash;not null;uniqueIndex"` + TxCount int `gorm:"column:tx_count;not null;default:0"` + IndexedAt time.Time `gorm:"column:indexed_at;autoCreateTime"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` +} + +func (blockModel) TableName() string { return "blocks" } + +func (m *blockModel) toEntity() *entity.Block { + return &entity.Block{ + Height: m.Height, + Hash: m.Hash, + Timestamp: m.IndexedAt, + TxCount: m.TxCount, + } +} + +func blockFromEntity(e *entity.Block) *blockModel { + return &blockModel{ + Height: e.Height, + Hash: e.Hash, + TxCount: e.TxCount, + IndexedAt: e.Timestamp, + } +} + +// PostgresBlockRepository is the GORM-backed implementation of repository.BlockRepository. +type PostgresBlockRepository struct { + db *gorm.DB +} + +// NewPostgresBlockRepository creates a new repository backed by PostgreSQL via GORM. +func NewPostgresBlockRepository(db *gorm.DB) *PostgresBlockRepository { + return &PostgresBlockRepository{db: db} +} + +func (r *PostgresBlockRepository) SaveBlock(ctx context.Context, block *entity.Block) error { + if block == nil { + return fmt.Errorf("block must not be nil") + } + model := blockFromEntity(block) + return r.db.WithContext(ctx).Save(model).Error +} + +func (r *PostgresBlockRepository) FindByHeight(ctx context.Context, height int64) (*entity.Block, error) { + var model blockModel + err := r.db.WithContext(ctx).Where("height = ?", height).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresBlockRepository) FindLatest(ctx context.Context) (*entity.Block, error) { + var model blockModel + err := r.db.WithContext(ctx).Order("height DESC").First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresBlockRepository) FindRange(ctx context.Context, fromHeight, toHeight int64) ([]*entity.Block, error) { + var models []blockModel + err := r.db.WithContext(ctx). + Where("height >= ? AND height <= ?", fromHeight, toHeight). + Order("height ASC"). + Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Block, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresBlockRepository) FindRecent(ctx context.Context, limit int) ([]*entity.Block, error) { + var models []blockModel + err := r.db.WithContext(ctx).Order("height DESC").Limit(limit).Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Block, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} diff --git a/backend/services/chain-indexer/internal/infrastructure/postgres/transaction_repository.go b/backend/services/chain-indexer/internal/infrastructure/postgres/transaction_repository.go new file mode 100644 index 0000000..5b2c8df --- /dev/null +++ b/backend/services/chain-indexer/internal/infrastructure/postgres/transaction_repository.go @@ -0,0 +1,105 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/genex/chain-indexer/internal/domain/entity" + "github.com/genex/chain-indexer/internal/domain/repository" + "gorm.io/gorm" +) + +// Compile-time check: PostgresTransactionRepository implements repository.TransactionRepository. +var _ repository.TransactionRepository = (*PostgresTransactionRepository)(nil) + +// chainTxModel is the GORM persistence model for the chain_transactions table. +type chainTxModel struct { + Hash string `gorm:"column:hash;primaryKey"` + BlockHeight int64 `gorm:"column:block_height;not null"` + FromAddr string `gorm:"column:from_addr;not null"` + ToAddr string `gorm:"column:to_addr;not null"` + Amount string `gorm:"column:amount;not null;default:0"` + Status string `gorm:"column:status;not null;default:confirmed"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` +} + +func (chainTxModel) TableName() string { return "chain_transactions" } + +func (m *chainTxModel) toEntity() *entity.ChainTransaction { + return &entity.ChainTransaction{ + Hash: m.Hash, + BlockHeight: m.BlockHeight, + From: m.FromAddr, + To: m.ToAddr, + Amount: m.Amount, + Status: m.Status, + Timestamp: m.CreatedAt, + } +} + +func chainTxFromEntity(e *entity.ChainTransaction) *chainTxModel { + return &chainTxModel{ + Hash: e.Hash, + BlockHeight: e.BlockHeight, + FromAddr: e.From, + ToAddr: e.To, + Amount: e.Amount, + Status: e.Status, + } +} + +// PostgresTransactionRepository is the GORM-backed implementation of +// repository.TransactionRepository. +type PostgresTransactionRepository struct { + db *gorm.DB +} + +// NewPostgresTransactionRepository creates a new repository backed by PostgreSQL via GORM. +func NewPostgresTransactionRepository(db *gorm.DB) *PostgresTransactionRepository { + return &PostgresTransactionRepository{db: db} +} + +func (r *PostgresTransactionRepository) Save(ctx context.Context, tx *entity.ChainTransaction) error { + if tx == nil { + return fmt.Errorf("transaction must not be nil") + } + model := chainTxFromEntity(tx) + return r.db.WithContext(ctx).Save(model).Error +} + +func (r *PostgresTransactionRepository) SaveBatch(ctx context.Context, txs []*entity.ChainTransaction) error { + if len(txs) == 0 { + return nil + } + models := make([]chainTxModel, len(txs)) + for i, tx := range txs { + models[i] = *chainTxFromEntity(tx) + } + return r.db.WithContext(ctx).Save(&models).Error +} + +func (r *PostgresTransactionRepository) FindByHash(ctx context.Context, hash string) (*entity.ChainTransaction, error) { + var model chainTxModel + err := r.db.WithContext(ctx).Where("hash = ?", hash).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresTransactionRepository) FindByBlock(ctx context.Context, blockHeight int64) ([]*entity.ChainTransaction, error) { + var models []chainTxModel + err := r.db.WithContext(ctx).Where("block_height = ?", blockHeight).Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.ChainTransaction, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} diff --git a/backend/services/chain-indexer/internal/interface/http/handler/admin_chain_handler.go b/backend/services/chain-indexer/internal/interface/http/handler/admin_chain_handler.go index 5196d30..a58cba7 100644 --- a/backend/services/chain-indexer/internal/interface/http/handler/admin_chain_handler.go +++ b/backend/services/chain-indexer/internal/interface/http/handler/admin_chain_handler.go @@ -8,17 +8,18 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/genex/chain-indexer/internal/indexer" + "github.com/genex/chain-indexer/internal/application/service" ) // AdminChainHandler handles admin chain monitoring endpoints. +// It depends on the application service layer, not on infrastructure directly. type AdminChainHandler struct { - idx *indexer.Indexer + indexerSvc *service.IndexerService } // NewAdminChainHandler creates a new AdminChainHandler. -func NewAdminChainHandler(idx *indexer.Indexer) *AdminChainHandler { - return &AdminChainHandler{idx: idx} +func NewAdminChainHandler(indexerSvc *service.IndexerService) *AdminChainHandler { + return &AdminChainHandler{indexerSvc: indexerSvc} } // GetContracts returns smart contract deployment status. @@ -31,7 +32,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) { "type": "ERC-1155", "status": "deployed", "deployedAt": time.Now().AddDate(0, -2, 0).UTC().Format(time.RFC3339), - "blockHeight": h.idx.GetLastHeight() - 5000, + "blockHeight": h.indexerSvc.GetLastHeight() - 5000, "txCount": 12580, "version": "1.0.0", }, @@ -41,7 +42,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) { "type": "Custom", "status": "deployed", "deployedAt": time.Now().AddDate(0, -2, 0).UTC().Format(time.RFC3339), - "blockHeight": h.idx.GetLastHeight() - 4998, + "blockHeight": h.indexerSvc.GetLastHeight() - 4998, "txCount": 8920, "version": "1.0.0", }, @@ -51,7 +52,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) { "type": "Proxy", "status": "deployed", "deployedAt": time.Now().AddDate(0, -1, -15).UTC().Format(time.RFC3339), - "blockHeight": h.idx.GetLastHeight() - 3200, + "blockHeight": h.indexerSvc.GetLastHeight() - 3200, "txCount": 15340, "version": "1.1.0", }, @@ -61,7 +62,7 @@ func (h *AdminChainHandler) GetContracts(c *gin.Context) { "type": "Custom", "status": "deployed", "deployedAt": time.Now().AddDate(0, -1, 0).UTC().Format(time.RFC3339), - "blockHeight": h.idx.GetLastHeight() - 2100, + "blockHeight": h.indexerSvc.GetLastHeight() - 2100, "txCount": 3260, "version": "1.0.0", }, @@ -103,7 +104,7 @@ func (h *AdminChainHandler) GetEvents(c *gin.Context) { statuses := []string{"confirmed", "confirmed", "confirmed", "pending"} var allEvents []gin.H - lastHeight := h.idx.GetLastHeight() + lastHeight := h.indexerSvc.GetLastHeight() for i := 0; i < 100; i++ { evtType := eventTypes[rng.Intn(len(eventTypes))] @@ -204,8 +205,8 @@ func (h *AdminChainHandler) GetGasMonitor(c *gin.Context) { // GetChainStats returns chain statistics. func (h *AdminChainHandler) GetChainStats(c *gin.Context) { - lastHeight := h.idx.GetLastHeight() - blocks := h.idx.GetRecentBlocks(100) + lastHeight := h.indexerSvc.GetLastHeight() + blocks := h.indexerSvc.GetRecentBlocks(100) // Calculate real stats from indexed blocks totalTx := 0 diff --git a/backend/services/clearing-service/src/application/services/admin-finance.service.ts b/backend/services/clearing-service/src/application/services/admin-finance.service.ts index b0b45e6..ce8a811 100644 --- a/backend/services/clearing-service/src/application/services/admin-finance.service.ts +++ b/backend/services/clearing-service/src/application/services/admin-finance.service.ts @@ -1,9 +1,19 @@ -import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { Settlement, SettlementStatus } from '../../domain/entities/settlement.entity'; -import { JournalEntry, JournalType } from '../../domain/entities/journal-entry.entity'; -import { Refund, RefundStatus } from '../../domain/entities/refund.entity'; +import { JournalType } from '../../domain/entities/journal-entry.entity'; +import { RefundStatus } from '../../domain/entities/refund.entity'; +import { + SETTLEMENT_REPOSITORY, + ISettlementRepository, +} from '../../domain/repositories/settlement.repository.interface'; +import { + JOURNAL_ENTRY_REPOSITORY, + IJournalEntryRepository, +} from '../../domain/repositories/journal-entry.repository.interface'; +import { + REFUND_REPOSITORY, + IRefundRepository, +} from '../../domain/repositories/refund.repository.interface'; export interface FinanceSummary { totalFeesCollected: string; @@ -28,62 +38,38 @@ export class AdminFinanceService { private readonly logger = new Logger('AdminFinanceService'); constructor( - @InjectRepository(Settlement) private readonly settlementRepo: Repository, - @InjectRepository(JournalEntry) private readonly journalRepo: Repository, - @InjectRepository(Refund) private readonly refundRepo: Repository, + @Inject(SETTLEMENT_REPOSITORY) + private readonly settlementRepo: ISettlementRepository, + @Inject(JOURNAL_ENTRY_REPOSITORY) + private readonly journalRepo: IJournalEntryRepository, + @Inject(REFUND_REPOSITORY) + private readonly refundRepo: IRefundRepository, ) {} /** * Aggregate platform finance overview from settlements + journal entries. */ async getSummary(): Promise { - // Total fees collected from journal entries of type TRADE_FEE - const feeResult = await this.journalRepo - .createQueryBuilder('j') - .select('COALESCE(SUM(j.amount::numeric), 0)', 'total') - .where('j.entry_type = :type', { type: JournalType.TRADE_FEE }) - .getRawOne(); + const [totalFeesCollected, pendingStats, completedStats, refundStats, completedRefundTotal] = + await Promise.all([ + this.journalRepo.getSumByType(JournalType.TRADE_FEE), + this.settlementRepo.getStatsByStatus(SettlementStatus.PENDING), + this.settlementRepo.getStatsByStatus(SettlementStatus.COMPLETED), + this.refundRepo.getRefundStats(), + this.refundRepo.getCompletedRefundTotal(), + ]); - // Pending settlements - const pendingStats = await this.settlementRepo - .createQueryBuilder('s') - .select('COUNT(s.id)', 'count') - .addSelect('COALESCE(SUM(s.amount::numeric), 0)', 'total') - .where('s.status = :status', { status: SettlementStatus.PENDING }) - .getRawOne(); - - // Completed settlements - const completedStats = await this.settlementRepo - .createQueryBuilder('s') - .select('COUNT(s.id)', 'count') - .addSelect('COALESCE(SUM(s.amount::numeric), 0)', 'total') - .where('s.status = :status', { status: SettlementStatus.COMPLETED }) - .getRawOne(); - - // Refunds - const refundStats = await this.refundRepo - .createQueryBuilder('r') - .select('COUNT(r.id)', 'count') - .addSelect('COALESCE(SUM(r.amount::numeric), 0)', 'total') - .getRawOne(); - - // Pool balance = total settled - total refunds completed - const completedRefundTotal = await this.refundRepo - .createQueryBuilder('r') - .select('COALESCE(SUM(r.amount::numeric), 0)', 'total') - .where('r.status = :status', { status: RefundStatus.COMPLETED }) - .getRawOne(); - - const poolBalance = parseFloat(completedStats?.total || '0') - parseFloat(completedRefundTotal?.total || '0'); + const poolBalance = + parseFloat(completedStats.total) - parseFloat(completedRefundTotal); return { - totalFeesCollected: feeResult?.total || '0', - pendingSettlements: parseInt(pendingStats?.count || '0', 10), - pendingSettlementAmount: pendingStats?.total || '0', - completedSettlements: parseInt(completedStats?.count || '0', 10), - completedSettlementAmount: completedStats?.total || '0', - totalRefunds: parseInt(refundStats?.count || '0', 10), - totalRefundAmount: refundStats?.total || '0', + totalFeesCollected, + pendingSettlements: pendingStats.count, + pendingSettlementAmount: pendingStats.total, + completedSettlements: completedStats.count, + completedSettlementAmount: completedStats.total, + totalRefunds: refundStats.count, + totalRefundAmount: refundStats.total, poolBalance: String(poolBalance), }; } @@ -92,17 +78,11 @@ export class AdminFinanceService { * Paginated list of settlements with optional status filter. */ async getSettlements(page: number, limit: number, status?: string) { - const qb = this.settlementRepo.createQueryBuilder('s'); - - if (status) { - qb.where('s.status = :status', { status }); - } - - qb.orderBy('s.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.settlementRepo.findAndCount({ + status: status as SettlementStatus | undefined, + page, + limit, + }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } @@ -111,31 +91,11 @@ export class AdminFinanceService { * Returns the last 12 months of data. */ async getRevenueTrend(): Promise { - const feeResults = await this.journalRepo - .createQueryBuilder('j') - .select("TO_CHAR(j.created_at, 'YYYY-MM')", 'month') - .addSelect('COALESCE(SUM(j.amount::numeric), 0)', 'revenue') - .where('j.entry_type = :type', { type: JournalType.TRADE_FEE }) - .andWhere('j.created_at >= NOW() - INTERVAL \'12 months\'') - .groupBy("TO_CHAR(j.created_at, 'YYYY-MM')") - .orderBy('month', 'ASC') - .getRawMany(); - - const settlementCounts = await this.settlementRepo - .createQueryBuilder('s') - .select("TO_CHAR(s.created_at, 'YYYY-MM')", 'month') - .addSelect('COUNT(s.id)', 'settlements') - .where('s.created_at >= NOW() - INTERVAL \'12 months\'') - .groupBy("TO_CHAR(s.created_at, 'YYYY-MM')") - .getRawMany(); - - const refundCounts = await this.refundRepo - .createQueryBuilder('r') - .select("TO_CHAR(r.created_at, 'YYYY-MM')", 'month') - .addSelect('COUNT(r.id)', 'refunds') - .where('r.created_at >= NOW() - INTERVAL \'12 months\'') - .groupBy("TO_CHAR(r.created_at, 'YYYY-MM')") - .getRawMany(); + const [feeResults, settlementCounts, refundCounts] = await Promise.all([ + this.journalRepo.getMonthlyRevenueByType(JournalType.TRADE_FEE), + this.settlementRepo.getMonthlySettlementCounts(), + this.refundRepo.getMonthlyRefundCounts(), + ]); // Merge results by month const monthMap = new Map(); @@ -152,34 +112,51 @@ export class AdminFinanceService { for (const row of settlementCounts) { const existing = monthMap.get(row.month); if (existing) { - existing.settlements = parseInt(row.settlements, 10); + existing.settlements = row.settlements; } else { - monthMap.set(row.month, { month: row.month, revenue: '0', settlements: parseInt(row.settlements, 10), refunds: 0 }); + monthMap.set(row.month, { + month: row.month, + revenue: '0', + settlements: row.settlements, + refunds: 0, + }); } } for (const row of refundCounts) { const existing = monthMap.get(row.month); if (existing) { - existing.refunds = parseInt(row.refunds, 10); + existing.refunds = row.refunds; } else { - monthMap.set(row.month, { month: row.month, revenue: '0', settlements: 0, refunds: parseInt(row.refunds, 10) }); + monthMap.set(row.month, { + month: row.month, + revenue: '0', + settlements: 0, + refunds: row.refunds, + }); } } - return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); + return Array.from(monthMap.values()).sort((a, b) => + a.month.localeCompare(b.month), + ); } /** * Process a pending settlement: move status to PROCESSING then COMPLETED. */ async processSettlement(id: string): Promise { - const settlement = await this.settlementRepo.findOne({ where: { id } }); + const settlement = await this.settlementRepo.findById(id); if (!settlement) { throw new NotFoundException(`Settlement ${id} not found`); } - if (settlement.status !== SettlementStatus.PENDING && settlement.status !== SettlementStatus.PROCESSING) { - throw new BadRequestException(`Settlement ${id} cannot be processed (current status: ${settlement.status})`); + if ( + settlement.status !== SettlementStatus.PENDING && + settlement.status !== SettlementStatus.PROCESSING + ) { + throw new BadRequestException( + `Settlement ${id} cannot be processed (current status: ${settlement.status})`, + ); } if (settlement.status === SettlementStatus.PENDING) { @@ -200,12 +177,14 @@ export class AdminFinanceService { * Cancel a pending settlement. */ async cancelSettlement(id: string): Promise { - const settlement = await this.settlementRepo.findOne({ where: { id } }); + const settlement = await this.settlementRepo.findById(id); if (!settlement) { throw new NotFoundException(`Settlement ${id} not found`); } if (settlement.status !== SettlementStatus.PENDING) { - throw new BadRequestException(`Only pending settlements can be cancelled (current status: ${settlement.status})`); + throw new BadRequestException( + `Only pending settlements can be cancelled (current status: ${settlement.status})`, + ); } settlement.status = SettlementStatus.FAILED; @@ -219,17 +198,11 @@ export class AdminFinanceService { * List consumer refund records with optional status filter. */ async getConsumerRefunds(page: number, limit: number, status?: string) { - const qb = this.refundRepo.createQueryBuilder('r'); - - if (status) { - qb.where('r.status = :status', { status }); - } - - qb.orderBy('r.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.refundRepo.findAndCount({ + status: status as RefundStatus | undefined, + page, + limit, + }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } } diff --git a/backend/services/clearing-service/src/application/services/admin-reports.service.ts b/backend/services/clearing-service/src/application/services/admin-reports.service.ts index 2066a3c..ddbd063 100644 --- a/backend/services/clearing-service/src/application/services/admin-reports.service.ts +++ b/backend/services/clearing-service/src/application/services/admin-reports.service.ts @@ -1,36 +1,28 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common'; import { Report, ReportType, ReportStatus } from '../../domain/entities/report.entity'; - -export interface GenerateReportDto { - type: ReportType; - period?: string; -} +import { + REPORT_REPOSITORY, + IReportRepository, +} from '../../domain/repositories/report.repository.interface'; @Injectable() export class AdminReportsService { private readonly logger = new Logger('AdminReportsService'); constructor( - @InjectRepository(Report) private readonly reportRepo: Repository, + @Inject(REPORT_REPOSITORY) + private readonly reportRepo: IReportRepository, ) {} /** * List all reports with pagination. */ async listReports(page: number, limit: number, type?: string) { - const qb = this.reportRepo.createQueryBuilder('r'); - - if (type) { - qb.where('r.type = :type', { type }); - } - - qb.orderBy('r.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.reportRepo.findAndCount({ + type: type as ReportType | undefined, + page, + limit, + }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } @@ -38,7 +30,10 @@ export class AdminReportsService { * Trigger report generation. * Creates a report record in PENDING status, then simulates generation. */ - async generateReport(dto: GenerateReportDto, generatedBy: string): Promise { + async generateReport( + dto: { type: ReportType; period?: string }, + generatedBy: string, + ): Promise { const period = dto.period || this.getDefaultPeriod(dto.type); const title = this.buildTitle(dto.type, period); @@ -54,7 +49,9 @@ export class AdminReportsService { // Simulate async report generation // In production, this would dispatch to a job queue (Bull/BullMQ) this.generateReportAsync(saved.id).catch((err) => { - this.logger.error(`Report generation failed for ${saved.id}: ${err.message}`); + this.logger.error( + `Report generation failed for ${saved.id}: ${err.message}`, + ); }); return saved; @@ -64,7 +61,7 @@ export class AdminReportsService { * Get report by ID for download. */ async getReportForDownload(id: string): Promise { - const report = await this.reportRepo.findOne({ where: { id } }); + const report = await this.reportRepo.findById(id); if (!report) { throw new NotFoundException(`Report ${id} not found`); } @@ -79,7 +76,7 @@ export class AdminReportsService { // Simulate processing time await new Promise((resolve) => setTimeout(resolve, 2000)); - const report = await this.reportRepo.findOne({ where: { id: reportId } }); + const report = await this.reportRepo.findById(reportId); if (!report) return; try { diff --git a/backend/services/clearing-service/src/application/services/breakage.service.ts b/backend/services/clearing-service/src/application/services/breakage.service.ts index e679b98..65a6f63 100644 --- a/backend/services/clearing-service/src/application/services/breakage.service.ts +++ b/backend/services/clearing-service/src/application/services/breakage.service.ts @@ -1,28 +1,62 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject } from '@nestjs/common'; import { BreakageRecord } from '../../domain/entities/breakage-record.entity'; +import { + BREAKAGE_REPOSITORY, + IBreakageRepository, +} from '../../domain/repositories/breakage.repository.interface'; +import { BreakageRate } from '../../domain/value-objects/breakage-rate.vo'; +import { SettlementAmount } from '../../domain/value-objects/settlement-amount.vo'; @Injectable() export class BreakageService { - constructor(@InjectRepository(BreakageRecord) private readonly repo: Repository) {} + constructor( + @Inject(BREAKAGE_REPOSITORY) + private readonly breakageRepo: IBreakageRepository, + ) {} - calculateBreakage(totalIssued: number, totalRedeemed: number, faceValue: number): { breakageAmount: number; breakageRate: number } { + /** + * Pure domain calculation: compute breakage amount and rate. + * This logic belongs in the domain layer and uses Value Objects for validation. + */ + calculateBreakage( + totalIssued: number, + totalRedeemed: number, + faceValue: number, + ): { breakageAmount: SettlementAmount; breakageRate: BreakageRate } { const totalExpired = totalIssued - totalRedeemed; - const breakageRate = totalIssued > 0 ? totalExpired / totalIssued : 0; - const breakageAmount = totalExpired * faceValue; + const breakageRate = BreakageRate.calculate(totalIssued, totalRedeemed); + const breakageAmount = SettlementAmount.create(String(totalExpired * faceValue)); return { breakageAmount, breakageRate }; } - async recordBreakage(data: { couponId: string; issuerId: string; totalIssued: number; totalRedeemed: number; totalExpired: number; faceValue: number }) { - const { breakageAmount, breakageRate } = this.calculateBreakage(data.totalIssued, data.totalRedeemed, data.faceValue); - const record = this.repo.create({ - ...data, breakageAmount: String(breakageAmount), breakageRate: String(breakageRate), + async recordBreakage(data: { + couponId: string; + issuerId: string; + totalIssued: number; + totalRedeemed: number; + totalExpired: number; + faceValue: number; + }): Promise { + const { breakageAmount, breakageRate } = this.calculateBreakage( + data.totalIssued, + data.totalRedeemed, + data.faceValue, + ); + + const record = this.breakageRepo.create({ + couponId: data.couponId, + issuerId: data.issuerId, + totalIssued: data.totalIssued, + totalRedeemed: data.totalRedeemed, + totalExpired: data.totalExpired, + breakageAmount: breakageAmount.value, + breakageRate: breakageRate.value, }); - return this.repo.save(record); + + return this.breakageRepo.save(record); } - async getByIssuerId(issuerId: string) { - return this.repo.find({ where: { issuerId }, order: { calculatedAt: 'DESC' } }); + async getByIssuerId(issuerId: string): Promise { + return this.breakageRepo.findByIssuerId(issuerId); } } diff --git a/backend/services/clearing-service/src/application/services/refund.service.ts b/backend/services/clearing-service/src/application/services/refund.service.ts index 06b79ec..3841db3 100644 --- a/backend/services/clearing-service/src/application/services/refund.service.ts +++ b/backend/services/clearing-service/src/application/services/refund.service.ts @@ -1,32 +1,66 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; import { Refund, RefundStatus } from '../../domain/entities/refund.entity'; +import { + REFUND_REPOSITORY, + IRefundRepository, +} from '../../domain/repositories/refund.repository.interface'; +import { SettlementAmount } from '../../domain/value-objects/settlement-amount.vo'; @Injectable() export class RefundService { - constructor(@InjectRepository(Refund) private readonly repo: Repository) {} + constructor( + @Inject(REFUND_REPOSITORY) + private readonly refundRepo: IRefundRepository, + ) {} - async createRefund(data: { orderId: string; userId: string; amount: string; reason: string }) { - const refund = this.repo.create({ ...data, status: RefundStatus.PENDING }); - return this.repo.save(refund); + async createRefund(data: { + orderId: string; + userId: string; + amount: string; + reason: string; + }): Promise { + // Validate amount using VO + SettlementAmount.create(data.amount); + + return this.refundRepo.create({ + orderId: data.orderId, + userId: data.userId, + amount: data.amount, + reason: data.reason, + status: RefundStatus.PENDING, + }); } - async approveRefund(id: string, processedBy: string) { - await this.repo.update(id, { status: RefundStatus.APPROVED, processedBy, processedAt: new Date() }); + async approveRefund(id: string, processedBy: string): Promise { + await this.refundRepo.update(id, { + status: RefundStatus.APPROVED, + processedBy, + processedAt: new Date(), + }); } - async completeRefund(id: string) { - await this.repo.update(id, { status: RefundStatus.COMPLETED }); + async completeRefund(id: string): Promise { + await this.refundRepo.update(id, { status: RefundStatus.COMPLETED }); } - async rejectRefund(id: string, processedBy: string) { - await this.repo.update(id, { status: RefundStatus.REJECTED, processedBy, processedAt: new Date() }); + async rejectRefund(id: string, processedBy: string): Promise { + await this.refundRepo.update(id, { + status: RefundStatus.REJECTED, + processedBy, + processedAt: new Date(), + }); } - async listRefunds(page: number, limit: number, status?: string) { - const where = status ? { status: status as any } : {}; - const [items, total] = await this.repo.findAndCount({ where, skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' } }); + async listRefunds( + page: number, + limit: number, + status?: string, + ): Promise<{ items: Refund[]; total: number; page: number; limit: number }> { + const [items, total] = await this.refundRepo.findAndCount({ + status: status as RefundStatus | undefined, + page, + limit, + }); return { items, total, page, limit }; } } diff --git a/backend/services/clearing-service/src/application/services/settlement.service.ts b/backend/services/clearing-service/src/application/services/settlement.service.ts index dfeddf1..6554691 100644 --- a/backend/services/clearing-service/src/application/services/settlement.service.ts +++ b/backend/services/clearing-service/src/application/services/settlement.service.ts @@ -1,51 +1,114 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource } from 'typeorm'; +import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common'; import { Settlement, SettlementStatus } from '../../domain/entities/settlement.entity'; -import { JournalEntry, JournalType } from '../../domain/entities/journal-entry.entity'; +import { JournalType } from '../../domain/entities/journal-entry.entity'; +import { + SETTLEMENT_REPOSITORY, + ISettlementRepository, +} from '../../domain/repositories/settlement.repository.interface'; +import { SettlementAmount } from '../../domain/value-objects/settlement-amount.vo'; @Injectable() export class SettlementService { private readonly logger = new Logger('SettlementService'); + constructor( - @InjectRepository(Settlement) private readonly settlementRepo: Repository, - @InjectRepository(JournalEntry) private readonly journalRepo: Repository, - private readonly dataSource: DataSource, + @Inject(SETTLEMENT_REPOSITORY) + private readonly settlementRepo: ISettlementRepository, ) {} - async createSettlement(data: { tradeId: string; buyerId: string; sellerId: string; amount: string; buyerFee: string; sellerFee: string }) { - return this.dataSource.transaction(async (manager) => { - const settlement = manager.create(Settlement, { ...data, status: SettlementStatus.PENDING }); - const saved = await manager.save(settlement); + async createSettlement(data: { + tradeId: string; + buyerId: string; + sellerId: string; + amount: string; + buyerFee: string; + sellerFee: string; + }): Promise { + // Validate amounts using VO + const amount = SettlementAmount.create(data.amount); + const buyerFee = SettlementAmount.create(data.buyerFee); + const sellerFee = SettlementAmount.create(data.sellerFee); + const sellerPayout = amount.subtract(sellerFee); - // Create journal entries (double-entry bookkeeping) - const entries = [ - manager.create(JournalEntry, { entryType: JournalType.SETTLEMENT, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'buyer_wallet', creditAccount: 'escrow', amount: data.amount, description: `Trade settlement ${data.tradeId}` }), - manager.create(JournalEntry, { entryType: JournalType.TRADE_FEE, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'escrow', creditAccount: 'platform_revenue', amount: data.buyerFee, description: `Buyer fee for trade ${data.tradeId}` }), - manager.create(JournalEntry, { entryType: JournalType.TRADE_FEE, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'escrow', creditAccount: 'platform_revenue', amount: data.sellerFee, description: `Seller fee for trade ${data.tradeId}` }), - manager.create(JournalEntry, { entryType: JournalType.SETTLEMENT, referenceId: saved.id, referenceType: 'settlement', debitAccount: 'escrow', creditAccount: 'seller_wallet', amount: String(parseFloat(data.amount) - parseFloat(data.sellerFee)), description: `Seller payout for trade ${data.tradeId}` }), - ]; - await manager.save(entries); + const settlementData: Partial = { + tradeId: data.tradeId, + buyerId: data.buyerId, + sellerId: data.sellerId, + amount: amount.value, + buyerFee: buyerFee.value, + sellerFee: sellerFee.value, + status: SettlementStatus.PENDING, + }; - return saved; - }); + // Double-entry bookkeeping journal entries + const journalEntries = [ + { + entryType: JournalType.SETTLEMENT, + referenceType: 'settlement', + debitAccount: 'buyer_wallet', + creditAccount: 'escrow', + amount: amount.value, + description: `Trade settlement ${data.tradeId}`, + }, + { + entryType: JournalType.TRADE_FEE, + referenceType: 'settlement', + debitAccount: 'escrow', + creditAccount: 'platform_revenue', + amount: buyerFee.value, + description: `Buyer fee for trade ${data.tradeId}`, + }, + { + entryType: JournalType.TRADE_FEE, + referenceType: 'settlement', + debitAccount: 'escrow', + creditAccount: 'platform_revenue', + amount: sellerFee.value, + description: `Seller fee for trade ${data.tradeId}`, + }, + { + entryType: JournalType.SETTLEMENT, + referenceType: 'settlement', + debitAccount: 'escrow', + creditAccount: 'seller_wallet', + amount: sellerPayout.value, + description: `Seller payout for trade ${data.tradeId}`, + }, + ]; + + const saved = await this.settlementRepo.createSettlementWithJournalEntries( + settlementData, + journalEntries, + ); + + this.logger.log(`Settlement created for trade ${data.tradeId}: ${saved.id}`); + return saved; } - async completeSettlement(id: string) { - const settlement = await this.settlementRepo.findOne({ where: { id } }); - if (!settlement) throw new NotFoundException('Settlement not found'); + async completeSettlement(id: string): Promise { + const settlement = await this.settlementRepo.findById(id); + if (!settlement) { + throw new NotFoundException('Settlement not found'); + } settlement.status = SettlementStatus.COMPLETED; settlement.settledAt = new Date(); return this.settlementRepo.save(settlement); } - async getByTradeId(tradeId: string) { - return this.settlementRepo.findOne({ where: { tradeId } }); + async getByTradeId(tradeId: string): Promise { + return this.settlementRepo.findByTradeId(tradeId); } - async list(page: number, limit: number, status?: string) { - const where = status ? { status: status as any } : {}; - const [items, total] = await this.settlementRepo.findAndCount({ where, skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' } }); + async list( + page: number, + limit: number, + status?: string, + ): Promise<{ items: Settlement[]; total: number; page: number; limit: number }> { + const [items, total] = await this.settlementRepo.findAndCount({ + status: status as SettlementStatus | undefined, + page, + limit, + }); return { items, total, page, limit }; } } diff --git a/backend/services/clearing-service/src/clearing.module.ts b/backend/services/clearing-service/src/clearing.module.ts index 08ea8d3..dfe49c7 100644 --- a/backend/services/clearing-service/src/clearing.module.ts +++ b/backend/services/clearing-service/src/clearing.module.ts @@ -2,16 +2,36 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; + +// Domain entities import { Settlement } from './domain/entities/settlement.entity'; import { Refund } from './domain/entities/refund.entity'; import { BreakageRecord } from './domain/entities/breakage-record.entity'; import { JournalEntry } from './domain/entities/journal-entry.entity'; import { Report } from './domain/entities/report.entity'; + +// Domain repository interfaces (symbols) +import { SETTLEMENT_REPOSITORY } from './domain/repositories/settlement.repository.interface'; +import { REFUND_REPOSITORY } from './domain/repositories/refund.repository.interface'; +import { BREAKAGE_REPOSITORY } from './domain/repositories/breakage.repository.interface'; +import { JOURNAL_ENTRY_REPOSITORY } from './domain/repositories/journal-entry.repository.interface'; +import { REPORT_REPOSITORY } from './domain/repositories/report.repository.interface'; + +// Infrastructure persistence implementations +import { SettlementRepository } from './infrastructure/persistence/settlement.repository'; +import { RefundRepository } from './infrastructure/persistence/refund.repository'; +import { BreakageRepository } from './infrastructure/persistence/breakage.repository'; +import { JournalEntryRepository } from './infrastructure/persistence/journal-entry.repository'; +import { ReportRepository } from './infrastructure/persistence/report.repository'; + +// Application services import { SettlementService } from './application/services/settlement.service'; import { RefundService } from './application/services/refund.service'; import { BreakageService } from './application/services/breakage.service'; import { AdminFinanceService } from './application/services/admin-finance.service'; import { AdminReportsService } from './application/services/admin-reports.service'; + +// Interface controllers import { ClearingController } from './interface/http/controllers/clearing.controller'; import { AdminFinanceController } from './interface/http/controllers/admin-finance.controller'; import { AdminReportsController } from './interface/http/controllers/admin-reports.controller'; @@ -23,7 +43,21 @@ import { AdminReportsController } from './interface/http/controllers/admin-repor JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }), ], controllers: [ClearingController, AdminFinanceController, AdminReportsController], - providers: [SettlementService, RefundService, BreakageService, AdminFinanceService, AdminReportsService], + providers: [ + // Repository DI bindings (interface -> implementation) + { provide: SETTLEMENT_REPOSITORY, useClass: SettlementRepository }, + { provide: REFUND_REPOSITORY, useClass: RefundRepository }, + { provide: BREAKAGE_REPOSITORY, useClass: BreakageRepository }, + { provide: JOURNAL_ENTRY_REPOSITORY, useClass: JournalEntryRepository }, + { provide: REPORT_REPOSITORY, useClass: ReportRepository }, + + // Application services + SettlementService, + RefundService, + BreakageService, + AdminFinanceService, + AdminReportsService, + ], exports: [SettlementService, RefundService, BreakageService], }) export class ClearingModule {} diff --git a/backend/services/clearing-service/src/domain/events/clearing.events.ts b/backend/services/clearing-service/src/domain/events/clearing.events.ts new file mode 100644 index 0000000..14fc792 --- /dev/null +++ b/backend/services/clearing-service/src/domain/events/clearing.events.ts @@ -0,0 +1,36 @@ +export interface SettlementCreatedEvent { + settlementId: string; + tradeId: string; + buyerId: string; + sellerId: string; + amount: string; + buyerFee: string; + sellerFee: string; + timestamp: string; +} + +export interface SettlementCompletedEvent { + settlementId: string; + tradeId: string; + settledAt: string; + timestamp: string; +} + +export interface RefundRequestedEvent { + refundId: string; + orderId: string; + userId: string; + amount: string; + reason: string; + timestamp: string; +} + +export interface RefundCompletedEvent { + refundId: string; + orderId: string; + userId: string; + amount: string; + status: string; + processedBy: string | null; + timestamp: string; +} diff --git a/backend/services/clearing-service/src/domain/repositories/breakage.repository.interface.ts b/backend/services/clearing-service/src/domain/repositories/breakage.repository.interface.ts new file mode 100644 index 0000000..a51ea69 --- /dev/null +++ b/backend/services/clearing-service/src/domain/repositories/breakage.repository.interface.ts @@ -0,0 +1,9 @@ +import { BreakageRecord } from '../entities/breakage-record.entity'; + +export const BREAKAGE_REPOSITORY = Symbol('IBreakageRepository'); + +export interface IBreakageRepository { + save(record: BreakageRecord): Promise; + create(data: Partial): BreakageRecord; + findByIssuerId(issuerId: string): Promise; +} diff --git a/backend/services/clearing-service/src/domain/repositories/journal-entry.repository.interface.ts b/backend/services/clearing-service/src/domain/repositories/journal-entry.repository.interface.ts new file mode 100644 index 0000000..014e52f --- /dev/null +++ b/backend/services/clearing-service/src/domain/repositories/journal-entry.repository.interface.ts @@ -0,0 +1,12 @@ +import { JournalEntry, JournalType } from '../entities/journal-entry.entity'; + +export const JOURNAL_ENTRY_REPOSITORY = Symbol('IJournalEntryRepository'); + +export interface IJournalEntryRepository { + save(entry: JournalEntry): Promise; + create(data: Partial): JournalEntry; + /** Aggregate: sum of amounts by entry type */ + getSumByType(type: JournalType): Promise; + /** QueryBuilder-based: monthly revenue by type (last 12 months) */ + getMonthlyRevenueByType(type: JournalType): Promise>; +} diff --git a/backend/services/clearing-service/src/domain/repositories/refund.repository.interface.ts b/backend/services/clearing-service/src/domain/repositories/refund.repository.interface.ts new file mode 100644 index 0000000..ffca6e6 --- /dev/null +++ b/backend/services/clearing-service/src/domain/repositories/refund.repository.interface.ts @@ -0,0 +1,20 @@ +import { Refund, RefundStatus } from '../entities/refund.entity'; + +export const REFUND_REPOSITORY = Symbol('IRefundRepository'); + +export interface IRefundRepository { + findById(id: string): Promise; + findAndCount(options: { + status?: RefundStatus; + page: number; + limit: number; + }): Promise<[Refund[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + /** Aggregate: total refund count and amount */ + getRefundStats(): Promise<{ count: number; total: string }>; + /** Aggregate: completed refund total */ + getCompletedRefundTotal(): Promise; + /** QueryBuilder-based: monthly refund counts (last 12 months) */ + getMonthlyRefundCounts(): Promise>; +} diff --git a/backend/services/clearing-service/src/domain/repositories/report.repository.interface.ts b/backend/services/clearing-service/src/domain/repositories/report.repository.interface.ts new file mode 100644 index 0000000..b40567d --- /dev/null +++ b/backend/services/clearing-service/src/domain/repositories/report.repository.interface.ts @@ -0,0 +1,14 @@ +import { Report, ReportType } from '../entities/report.entity'; + +export const REPORT_REPOSITORY = Symbol('IReportRepository'); + +export interface IReportRepository { + findById(id: string): Promise; + findAndCount(options: { + type?: ReportType; + page: number; + limit: number; + }): Promise<[Report[], number]>; + create(data: Partial): Report; + save(report: Report): Promise; +} diff --git a/backend/services/clearing-service/src/domain/repositories/settlement.repository.interface.ts b/backend/services/clearing-service/src/domain/repositories/settlement.repository.interface.ts new file mode 100644 index 0000000..f953e96 --- /dev/null +++ b/backend/services/clearing-service/src/domain/repositories/settlement.repository.interface.ts @@ -0,0 +1,23 @@ +import { Settlement, SettlementStatus } from '../entities/settlement.entity'; +import { JournalEntry } from '../entities/journal-entry.entity'; + +export const SETTLEMENT_REPOSITORY = Symbol('ISettlementRepository'); + +export interface ISettlementRepository { + findById(id: string): Promise; + findByTradeId(tradeId: string): Promise; + findAndCount(options: { + status?: SettlementStatus; + page: number; + limit: number; + }): Promise<[Settlement[], number]>; + save(settlement: Settlement): Promise; + createSettlementWithJournalEntries( + settlement: Partial, + journalEntries: Partial[], + ): Promise; + /** QueryBuilder-based: stats by status */ + getStatsByStatus(status: SettlementStatus): Promise<{ count: number; total: string }>; + /** QueryBuilder-based: monthly settlement counts (last 12 months) */ + getMonthlySettlementCounts(): Promise>; +} diff --git a/backend/services/clearing-service/src/domain/value-objects/breakage-rate.vo.ts b/backend/services/clearing-service/src/domain/value-objects/breakage-rate.vo.ts new file mode 100644 index 0000000..26054db --- /dev/null +++ b/backend/services/clearing-service/src/domain/value-objects/breakage-rate.vo.ts @@ -0,0 +1,72 @@ +/** + * Value Object: BreakageRate + * Encapsulates a breakage rate as a decimal between 0 and 1 (0% - 100%). + * Stored as a string to match DB numeric(5,4) precision. + */ +export class BreakageRate { + private constructor(private readonly _value: string) {} + + /** + * Create a BreakageRate from a numeric value (0..1 range, representing 0%-100%). + */ + static create(value: number): BreakageRate { + if (isNaN(value)) { + throw new Error('Breakage rate must be a valid number'); + } + if (value < 0) { + throw new Error(`Breakage rate cannot be negative, got: ${value}`); + } + if (value > 1) { + throw new Error( + `Breakage rate cannot exceed 1.0 (100%), got: ${value}`, + ); + } + return new BreakageRate(String(value)); + } + + /** + * Reconstruct from a persisted string value (no validation, trusted source). + */ + static fromPersisted(value: string): BreakageRate { + return new BreakageRate(value); + } + + /** + * Calculate breakage rate from issued and redeemed counts. + * breakageRate = (totalIssued - totalRedeemed) / totalIssued + */ + static calculate(totalIssued: number, totalRedeemed: number): BreakageRate { + if (totalIssued < 0 || totalRedeemed < 0) { + throw new Error('Counts cannot be negative'); + } + if (totalRedeemed > totalIssued) { + throw new Error('Redeemed count cannot exceed issued count'); + } + if (totalIssued === 0) { + return new BreakageRate('0'); + } + const rate = (totalIssued - totalRedeemed) / totalIssued; + return new BreakageRate(String(rate)); + } + + get value(): string { + return this._value; + } + + toNumber(): number { + return parseFloat(this._value); + } + + /** Return the rate as a percentage string, e.g. "25.00%" */ + toPercentageString(): string { + return `${(this.toNumber() * 100).toFixed(2)}%`; + } + + equals(other: BreakageRate): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/clearing-service/src/domain/value-objects/settlement-amount.vo.ts b/backend/services/clearing-service/src/domain/value-objects/settlement-amount.vo.ts new file mode 100644 index 0000000..5c6324e --- /dev/null +++ b/backend/services/clearing-service/src/domain/value-objects/settlement-amount.vo.ts @@ -0,0 +1,86 @@ +/** + * Value Object: SettlementAmount + * Encapsulates a financial amount with validation. + * Amounts are stored as strings to preserve precision (matches DB numeric type). + */ +export class SettlementAmount { + private constructor(private readonly _value: string) {} + + /** + * Create a SettlementAmount from a string value. + * Validates that the value is a positive numeric string with up to 20 digits + * and 8 decimal places. + */ + static create(value: string): SettlementAmount { + if (!value || value.trim() === '') { + throw new Error('Settlement amount cannot be empty'); + } + + const numeric = parseFloat(value); + if (isNaN(numeric)) { + throw new Error(`Invalid settlement amount: "${value}" is not a valid number`); + } + + if (numeric < 0) { + throw new Error(`Settlement amount must be non-negative, got: ${value}`); + } + + // Validate precision: up to 20 digits total, 8 decimal places + const parts = value.split('.'); + const integerPart = parts[0].replace(/^-/, ''); + const decimalPart = parts[1] || ''; + + if (integerPart.length > 12) { + throw new Error( + `Settlement amount integer part exceeds maximum 12 digits: ${integerPart.length}`, + ); + } + + if (decimalPart.length > 8) { + throw new Error( + `Settlement amount decimal part exceeds maximum 8 digits: ${decimalPart.length}`, + ); + } + + return new SettlementAmount(value); + } + + /** + * Reconstruct from a persisted string value (no validation, trusted source). + */ + static fromPersisted(value: string): SettlementAmount { + return new SettlementAmount(value); + } + + get value(): string { + return this._value; + } + + toNumber(): number { + return parseFloat(this._value); + } + + /** + * Subtract another amount and return the result. + */ + subtract(other: SettlementAmount): SettlementAmount { + const result = this.toNumber() - other.toNumber(); + return SettlementAmount.create(String(result)); + } + + /** + * Add another amount and return the result. + */ + add(other: SettlementAmount): SettlementAmount { + const result = this.toNumber() + other.toNumber(); + return SettlementAmount.create(String(result)); + } + + equals(other: SettlementAmount): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/clearing-service/src/infrastructure/persistence/breakage.repository.ts b/backend/services/clearing-service/src/infrastructure/persistence/breakage.repository.ts new file mode 100644 index 0000000..5f3a42f --- /dev/null +++ b/backend/services/clearing-service/src/infrastructure/persistence/breakage.repository.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BreakageRecord } from '../../domain/entities/breakage-record.entity'; +import { IBreakageRepository } from '../../domain/repositories/breakage.repository.interface'; + +@Injectable() +export class BreakageRepository implements IBreakageRepository { + constructor( + @InjectRepository(BreakageRecord) + private readonly repo: Repository, + ) {} + + async save(record: BreakageRecord): Promise { + return this.repo.save(record); + } + + create(data: Partial): BreakageRecord { + return this.repo.create(data); + } + + async findByIssuerId(issuerId: string): Promise { + return this.repo.find({ + where: { issuerId }, + order: { calculatedAt: 'DESC' }, + }); + } +} diff --git a/backend/services/clearing-service/src/infrastructure/persistence/journal-entry.repository.ts b/backend/services/clearing-service/src/infrastructure/persistence/journal-entry.repository.ts new file mode 100644 index 0000000..aac75d5 --- /dev/null +++ b/backend/services/clearing-service/src/infrastructure/persistence/journal-entry.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JournalEntry, JournalType } from '../../domain/entities/journal-entry.entity'; +import { IJournalEntryRepository } from '../../domain/repositories/journal-entry.repository.interface'; + +@Injectable() +export class JournalEntryRepository implements IJournalEntryRepository { + constructor( + @InjectRepository(JournalEntry) + private readonly repo: Repository, + ) {} + + async save(entry: JournalEntry): Promise { + return this.repo.save(entry); + } + + create(data: Partial): JournalEntry { + return this.repo.create(data); + } + + async getSumByType(type: JournalType): Promise { + const result = await this.repo + .createQueryBuilder('j') + .select('COALESCE(SUM(j.amount::numeric), 0)', 'total') + .where('j.entry_type = :type', { type }) + .getRawOne(); + + return result?.total || '0'; + } + + async getMonthlyRevenueByType( + type: JournalType, + ): Promise> { + const results = await this.repo + .createQueryBuilder('j') + .select("TO_CHAR(j.created_at, 'YYYY-MM')", 'month') + .addSelect('COALESCE(SUM(j.amount::numeric), 0)', 'revenue') + .where('j.entry_type = :type', { type }) + .andWhere("j.created_at >= NOW() - INTERVAL '12 months'") + .groupBy("TO_CHAR(j.created_at, 'YYYY-MM')") + .orderBy('month', 'ASC') + .getRawMany(); + + return results.map((row) => ({ + month: row.month, + revenue: row.revenue, + })); + } +} diff --git a/backend/services/clearing-service/src/infrastructure/persistence/refund.repository.ts b/backend/services/clearing-service/src/infrastructure/persistence/refund.repository.ts new file mode 100644 index 0000000..472c1d5 --- /dev/null +++ b/backend/services/clearing-service/src/infrastructure/persistence/refund.repository.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Refund, RefundStatus } from '../../domain/entities/refund.entity'; +import { IRefundRepository } from '../../domain/repositories/refund.repository.interface'; + +@Injectable() +export class RefundRepository implements IRefundRepository { + constructor( + @InjectRepository(Refund) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findAndCount(options: { + status?: RefundStatus; + page: number; + limit: number; + }): Promise<[Refund[], number]> { + const qb = this.repo.createQueryBuilder('r'); + + if (options.status) { + qb.where('r.status = :status', { status: options.status }); + } + + qb.orderBy('r.created_at', 'DESC') + .skip((options.page - 1) * options.limit) + .take(options.limit); + + return qb.getManyAndCount(); + } + + async create(data: Partial): Promise { + const refund = this.repo.create(data); + return this.repo.save(refund); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + } + + async getRefundStats(): Promise<{ count: number; total: string }> { + const result = await this.repo + .createQueryBuilder('r') + .select('COUNT(r.id)', 'count') + .addSelect('COALESCE(SUM(r.amount::numeric), 0)', 'total') + .getRawOne(); + + return { + count: parseInt(result?.count || '0', 10), + total: result?.total || '0', + }; + } + + async getCompletedRefundTotal(): Promise { + const result = await this.repo + .createQueryBuilder('r') + .select('COALESCE(SUM(r.amount::numeric), 0)', 'total') + .where('r.status = :status', { status: RefundStatus.COMPLETED }) + .getRawOne(); + + return result?.total || '0'; + } + + async getMonthlyRefundCounts(): Promise< + Array<{ month: string; refunds: number }> + > { + const results = await this.repo + .createQueryBuilder('r') + .select("TO_CHAR(r.created_at, 'YYYY-MM')", 'month') + .addSelect('COUNT(r.id)', 'refunds') + .where("r.created_at >= NOW() - INTERVAL '12 months'") + .groupBy("TO_CHAR(r.created_at, 'YYYY-MM')") + .getRawMany(); + + return results.map((row) => ({ + month: row.month, + refunds: parseInt(row.refunds, 10), + })); + } +} diff --git a/backend/services/clearing-service/src/infrastructure/persistence/report.repository.ts b/backend/services/clearing-service/src/infrastructure/persistence/report.repository.ts new file mode 100644 index 0000000..18f4be9 --- /dev/null +++ b/backend/services/clearing-service/src/infrastructure/persistence/report.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Report, ReportType } from '../../domain/entities/report.entity'; +import { IReportRepository } from '../../domain/repositories/report.repository.interface'; + +@Injectable() +export class ReportRepository implements IReportRepository { + constructor( + @InjectRepository(Report) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findAndCount(options: { + type?: ReportType; + page: number; + limit: number; + }): Promise<[Report[], number]> { + const qb = this.repo.createQueryBuilder('r'); + + if (options.type) { + qb.where('r.type = :type', { type: options.type }); + } + + qb.orderBy('r.created_at', 'DESC') + .skip((options.page - 1) * options.limit) + .take(options.limit); + + return qb.getManyAndCount(); + } + + create(data: Partial): Report { + return this.repo.create(data); + } + + async save(report: Report): Promise { + return this.repo.save(report); + } +} diff --git a/backend/services/clearing-service/src/infrastructure/persistence/settlement.repository.ts b/backend/services/clearing-service/src/infrastructure/persistence/settlement.repository.ts new file mode 100644 index 0000000..d55e96f --- /dev/null +++ b/backend/services/clearing-service/src/infrastructure/persistence/settlement.repository.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Settlement, SettlementStatus } from '../../domain/entities/settlement.entity'; +import { JournalEntry } from '../../domain/entities/journal-entry.entity'; +import { ISettlementRepository } from '../../domain/repositories/settlement.repository.interface'; + +@Injectable() +export class SettlementRepository implements ISettlementRepository { + constructor( + @InjectRepository(Settlement) + private readonly repo: Repository, + private readonly dataSource: DataSource, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findByTradeId(tradeId: string): Promise { + return this.repo.findOne({ where: { tradeId } }); + } + + async findAndCount(options: { + status?: SettlementStatus; + page: number; + limit: number; + }): Promise<[Settlement[], number]> { + const qb = this.repo.createQueryBuilder('s'); + + if (options.status) { + qb.where('s.status = :status', { status: options.status }); + } + + qb.orderBy('s.created_at', 'DESC') + .skip((options.page - 1) * options.limit) + .take(options.limit); + + return qb.getManyAndCount(); + } + + async save(settlement: Settlement): Promise { + return this.repo.save(settlement); + } + + async createSettlementWithJournalEntries( + settlementData: Partial, + journalEntriesData: Partial[], + ): Promise { + return this.dataSource.transaction(async (manager) => { + const settlement = manager.create(Settlement, settlementData); + const saved = await manager.save(settlement); + + const entries = journalEntriesData.map((entry) => + manager.create(JournalEntry, entry), + ); + // Set referenceId from the saved settlement + for (const entry of entries) { + if (!entry.referenceId) { + entry.referenceId = saved.id; + } + } + await manager.save(entries); + + return saved; + }); + } + + async getStatsByStatus( + status: SettlementStatus, + ): Promise<{ count: number; total: string }> { + const result = await this.repo + .createQueryBuilder('s') + .select('COUNT(s.id)', 'count') + .addSelect('COALESCE(SUM(s.amount::numeric), 0)', 'total') + .where('s.status = :status', { status }) + .getRawOne(); + + return { + count: parseInt(result?.count || '0', 10), + total: result?.total || '0', + }; + } + + async getMonthlySettlementCounts(): Promise< + Array<{ month: string; settlements: number }> + > { + const results = await this.repo + .createQueryBuilder('s') + .select("TO_CHAR(s.created_at, 'YYYY-MM')", 'month') + .addSelect('COUNT(s.id)', 'settlements') + .where("s.created_at >= NOW() - INTERVAL '12 months'") + .groupBy("TO_CHAR(s.created_at, 'YYYY-MM')") + .getRawMany(); + + return results.map((row) => ({ + month: row.month, + settlements: parseInt(row.settlements, 10), + })); + } +} diff --git a/backend/services/clearing-service/src/interface/http/controllers/admin-finance.controller.ts b/backend/services/clearing-service/src/interface/http/controllers/admin-finance.controller.ts index a6def44..e244eca 100644 --- a/backend/services/clearing-service/src/interface/http/controllers/admin-finance.controller.ts +++ b/backend/services/clearing-service/src/interface/http/controllers/admin-finance.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get, Post, Param, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, RolesGuard, Roles, UserRole } from '@genex/common'; import { AdminFinanceService } from '../../../application/services/admin-finance.service'; +import { ListSettlementsQueryDto } from '../dto/settlement.dto'; +import { ListRefundsQueryDto } from '../dto/refund.dto'; @ApiTags('Admin - Finance') @Controller('admin/finance') @@ -19,15 +21,15 @@ export class AdminFinanceController { @Get('settlements') @ApiOperation({ summary: 'Settlement queue (paginated, filter by status)' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'status', required: false, enum: ['pending', 'processing', 'completed', 'failed'] }) - async getSettlements( - @Query('page') page = '1', - @Query('limit') limit = '20', - @Query('status') status?: string, - ) { - return { code: 0, data: await this.adminFinanceService.getSettlements(+page, +limit, status) }; + async getSettlements(@Query() query: ListSettlementsQueryDto) { + return { + code: 0, + data: await this.adminFinanceService.getSettlements( + +(query.page || '1'), + +(query.limit || '20'), + query.status, + ), + }; } @Get('revenue-trend') @@ -50,14 +52,14 @@ export class AdminFinanceController { @Get('consumer-refunds') @ApiOperation({ summary: 'Consumer refund tracking (paginated)' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'status', required: false, enum: ['pending', 'approved', 'completed', 'rejected'] }) - async getConsumerRefunds( - @Query('page') page = '1', - @Query('limit') limit = '20', - @Query('status') status?: string, - ) { - return { code: 0, data: await this.adminFinanceService.getConsumerRefunds(+page, +limit, status) }; + async getConsumerRefunds(@Query() query: ListRefundsQueryDto) { + return { + code: 0, + data: await this.adminFinanceService.getConsumerRefunds( + +(query.page || '1'), + +(query.limit || '20'), + query.status, + ), + }; } } diff --git a/backend/services/clearing-service/src/interface/http/controllers/admin-reports.controller.ts b/backend/services/clearing-service/src/interface/http/controllers/admin-reports.controller.ts index 006a6f8..47b2764 100644 --- a/backend/services/clearing-service/src/interface/http/controllers/admin-reports.controller.ts +++ b/backend/services/clearing-service/src/interface/http/controllers/admin-reports.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Post, Param, Query, Body, UseGuards, Req, NotFoundException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, RolesGuard, Roles, UserRole } from '@genex/common'; -import { AdminReportsService, GenerateReportDto } from '../../../application/services/admin-reports.service'; +import { AdminReportsService } from '../../../application/services/admin-reports.service'; import { ReportStatus } from '../../../domain/entities/report.entity'; +import { GenerateReportDto, ListReportsQueryDto } from '../dto/report.dto'; @ApiTags('Admin - Reports') @Controller('admin/reports') @@ -14,15 +15,15 @@ export class AdminReportsController { @Get() @ApiOperation({ summary: 'List all generated reports' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'type', required: false, enum: ['daily', 'monthly', 'quarterly', 'annual'] }) - async listReports( - @Query('page') page = '1', - @Query('limit') limit = '20', - @Query('type') type?: string, - ) { - return { code: 0, data: await this.adminReportsService.listReports(+page, +limit, type) }; + async listReports(@Query() query: ListReportsQueryDto) { + return { + code: 0, + data: await this.adminReportsService.listReports( + +(query.page || '1'), + +(query.limit || '20'), + query.type, + ), + }; } @Post('generate') diff --git a/backend/services/clearing-service/src/interface/http/controllers/clearing.controller.ts b/backend/services/clearing-service/src/interface/http/controllers/clearing.controller.ts index ebdbd92..705d061 100644 --- a/backend/services/clearing-service/src/interface/http/controllers/clearing.controller.ts +++ b/backend/services/clearing-service/src/interface/http/controllers/clearing.controller.ts @@ -4,6 +4,8 @@ import { AuthGuard } from '@nestjs/passport'; import { SettlementService } from '../../../application/services/settlement.service'; import { RefundService } from '../../../application/services/refund.service'; import { BreakageService } from '../../../application/services/breakage.service'; +import { ListSettlementsQueryDto } from '../dto/settlement.dto'; +import { CreateRefundDto, ApproveRefundDto, ListRefundsQueryDto } from '../dto/refund.dto'; @ApiTags('Clearing') @Controller('payments') @@ -18,30 +20,46 @@ export class ClearingController { @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'List settlements' }) - async listSettlements(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status?: string) { - return { code: 0, data: await this.settlementService.list(+page, +limit, status) }; + async listSettlements(@Query() query: ListSettlementsQueryDto) { + return { + code: 0, + data: await this.settlementService.list( + +(query.page || '1'), + +(query.limit || '20'), + query.status, + ), + }; } @Post('refunds') @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Request a refund' }) - async createRefund(@Body() body: { orderId: string; userId: string; amount: string; reason: string }) { + async createRefund(@Body() body: CreateRefundDto) { return { code: 0, data: await this.refundService.createRefund(body) }; } @Put('refunds/:id/approve') @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() - async approveRefund(@Param('id') id: string, @Body('processedBy') processedBy: string) { - await this.refundService.approveRefund(id, processedBy); + @ApiOperation({ summary: 'Approve a refund' }) + async approveRefund(@Param('id') id: string, @Body() body: ApproveRefundDto) { + await this.refundService.approveRefund(id, body.processedBy); return { code: 0, data: null }; } @Get('refunds') @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() - async listRefunds(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status?: string) { - return { code: 0, data: await this.refundService.listRefunds(+page, +limit, status) }; + @ApiOperation({ summary: 'List refunds' }) + async listRefunds(@Query() query: ListRefundsQueryDto) { + return { + code: 0, + data: await this.refundService.listRefunds( + +(query.page || '1'), + +(query.limit || '20'), + query.status, + ), + }; } } diff --git a/backend/services/clearing-service/src/interface/http/dto/index.ts b/backend/services/clearing-service/src/interface/http/dto/index.ts new file mode 100644 index 0000000..203bdeb --- /dev/null +++ b/backend/services/clearing-service/src/interface/http/dto/index.ts @@ -0,0 +1,4 @@ +export * from './pagination.dto'; +export * from './settlement.dto'; +export * from './refund.dto'; +export * from './report.dto'; diff --git a/backend/services/clearing-service/src/interface/http/dto/pagination.dto.ts b/backend/services/clearing-service/src/interface/http/dto/pagination.dto.ts new file mode 100644 index 0000000..9690fc4 --- /dev/null +++ b/backend/services/clearing-service/src/interface/http/dto/pagination.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsNumberString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ example: '1', description: 'Page number (1-based)' }) + @IsOptional() + @IsNumberString() + page?: string = '1'; + + @ApiPropertyOptional({ example: '20', description: 'Items per page' }) + @IsOptional() + @IsNumberString() + limit?: string = '20'; +} diff --git a/backend/services/clearing-service/src/interface/http/dto/refund.dto.ts b/backend/services/clearing-service/src/interface/http/dto/refund.dto.ts new file mode 100644 index 0000000..3afab12 --- /dev/null +++ b/backend/services/clearing-service/src/interface/http/dto/refund.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsUUID, IsNumberString, IsOptional, IsEnum, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { RefundStatus } from '../../../domain/entities/refund.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +export class CreateRefundDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: 'Order ID' }) + @IsUUID() + orderId: string; + + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001', description: 'User ID' }) + @IsUUID() + userId: string; + + @ApiProperty({ example: '50.00', description: 'Refund amount' }) + @IsNumberString() + amount: string; + + @ApiProperty({ example: 'Product defective', description: 'Reason for refund' }) + @IsString() + @MaxLength(200) + reason: string; +} + +export class ApproveRefundDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440003', description: 'Admin user ID who approves the refund' }) + @IsUUID() + processedBy: string; +} + +export class ListRefundsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + enum: RefundStatus, + description: 'Filter by refund status', + }) + @IsOptional() + @IsEnum(RefundStatus) + status?: RefundStatus; +} diff --git a/backend/services/clearing-service/src/interface/http/dto/report.dto.ts b/backend/services/clearing-service/src/interface/http/dto/report.dto.ts new file mode 100644 index 0000000..271ea68 --- /dev/null +++ b/backend/services/clearing-service/src/interface/http/dto/report.dto.ts @@ -0,0 +1,33 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ReportType } from '../../../domain/entities/report.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +export class GenerateReportDto { + @ApiProperty({ + enum: ReportType, + example: ReportType.MONTHLY, + description: 'Report type (daily, monthly, quarterly, annual)', + }) + @IsEnum(ReportType) + type: ReportType; + + @ApiPropertyOptional({ + example: '2025-01', + description: 'Report period (auto-generated if omitted)', + }) + @IsOptional() + @IsString() + @MaxLength(50) + period?: string; +} + +export class ListReportsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + enum: ReportType, + description: 'Filter by report type', + }) + @IsOptional() + @IsEnum(ReportType) + type?: ReportType; +} diff --git a/backend/services/clearing-service/src/interface/http/dto/settlement.dto.ts b/backend/services/clearing-service/src/interface/http/dto/settlement.dto.ts new file mode 100644 index 0000000..8546ea0 --- /dev/null +++ b/backend/services/clearing-service/src/interface/http/dto/settlement.dto.ts @@ -0,0 +1,40 @@ +import { IsString, IsUUID, IsNumberString, IsOptional, IsEnum } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SettlementStatus } from '../../../domain/entities/settlement.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +export class CreateSettlementDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: 'Trade ID' }) + @IsUUID() + tradeId: string; + + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001', description: 'Buyer user ID' }) + @IsUUID() + buyerId: string; + + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440002', description: 'Seller user ID' }) + @IsUUID() + sellerId: string; + + @ApiProperty({ example: '100.00', description: 'Settlement amount' }) + @IsNumberString() + amount: string; + + @ApiProperty({ example: '1.50', description: 'Buyer fee amount' }) + @IsNumberString() + buyerFee: string; + + @ApiProperty({ example: '1.50', description: 'Seller fee amount' }) + @IsNumberString() + sellerFee: string; +} + +export class ListSettlementsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + enum: SettlementStatus, + description: 'Filter by settlement status', + }) + @IsOptional() + @IsEnum(SettlementStatus) + status?: SettlementStatus; +} diff --git a/backend/services/compliance-service/src/application/services/admin-compliance.service.ts b/backend/services/compliance-service/src/application/services/admin-compliance.service.ts index e8f7c1e..a787ed6 100644 --- a/backend/services/compliance-service/src/application/services/admin-compliance.service.ts +++ b/backend/services/compliance-service/src/application/services/admin-compliance.service.ts @@ -1,37 +1,47 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { SarReport } from '../../domain/entities/sar-report.entity'; -import { AuditLog } from '../../domain/entities/audit-log.entity'; -import { AmlAlert } from '../../domain/entities/aml-alert.entity'; -import { TravelRuleRecord } from '../../domain/entities/travel-rule-record.entity'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + SAR_REPORT_REPOSITORY, + ISarReportRepository, +} from '../../domain/repositories/sar-report.repository.interface'; +import { + AUDIT_LOG_REPOSITORY, + IAuditLogRepository, +} from '../../domain/repositories/audit-log.repository.interface'; +import { + AML_ALERT_REPOSITORY, + IAmlAlertRepository, +} from '../../domain/repositories/aml-alert.repository.interface'; +import { + TRAVEL_RULE_REPOSITORY, + ITravelRuleRepository, +} from '../../domain/repositories/travel-rule.repository.interface'; +import { + AUDIT_LOGGER_SERVICE, + IAuditLoggerService, +} from '../../domain/ports/audit-logger.interface'; @Injectable() export class AdminComplianceService { private readonly logger = new Logger('AdminComplianceService'); constructor( - @InjectRepository(SarReport) private readonly sarRepo: Repository, - @InjectRepository(AuditLog) private readonly auditRepo: Repository, - @InjectRepository(AmlAlert) private readonly alertRepo: Repository, - @InjectRepository(TravelRuleRecord) private readonly travelRepo: Repository, + @Inject(SAR_REPORT_REPOSITORY) + private readonly sarRepo: ISarReportRepository, + @Inject(AUDIT_LOG_REPOSITORY) + private readonly auditRepo: IAuditLogRepository, + @Inject(AML_ALERT_REPOSITORY) + private readonly alertRepo: IAmlAlertRepository, + @Inject(TRAVEL_RULE_REPOSITORY) + private readonly travelRepo: ITravelRuleRepository, + @Inject(AUDIT_LOGGER_SERVICE) + private readonly auditLogger: IAuditLoggerService, ) {} // ───────────── SAR Management ───────────── /** List SAR reports (paginated, with optional status filter) */ async listSarReports(page: number, limit: number, status?: string) { - const qb = this.sarRepo.createQueryBuilder('sar'); - - if (status) { - qb.andWhere('sar.filing_status = :status', { status }); - } - - qb.orderBy('sar.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.sarRepo.findPaginated(page, limit, { status }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } @@ -63,29 +73,13 @@ export class AdminComplianceService { startDate?: string, endDate?: string, ) { - const qb = this.auditRepo.createQueryBuilder('log'); - - if (action) { - qb.andWhere('log.action = :action', { action }); - } - if (adminId) { - qb.andWhere('log.admin_id = :adminId', { adminId }); - } - if (resource) { - qb.andWhere('log.resource = :resource', { resource }); - } - if (startDate) { - qb.andWhere('log.created_at >= :startDate', { startDate }); - } - if (endDate) { - qb.andWhere('log.created_at <= :endDate', { endDate }); - } - - qb.orderBy('log.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.auditRepo.findPaginated(page, limit, { + action, + adminId, + resource, + startDate, + endDate, + }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } @@ -140,10 +134,7 @@ export class AdminComplianceService { case 'aml': const [alertCount, highRisk] = await Promise.all([ this.alertRepo.count(), - this.alertRepo - .createQueryBuilder('a') - .where('a.risk_score >= :score', { score: 70 }) - .getCount(), + this.alertRepo.countHighRisk(70), ]); reportData = { alertCount, highRiskCount: highRisk, generatedAt: timestamp }; break; @@ -160,18 +151,16 @@ export class AdminComplianceService { reportData = { type: reportType, status: 'unsupported', generatedAt: timestamp }; } - // Audit log the report generation - const log = this.auditRepo.create({ + // Audit log the report generation via shared service + await this.auditLogger.log({ adminId, adminName, action: 'generate_report', resource: 'compliance_report', resourceId: null, - ipAddress: ipAddress || null, - result: 'success', + ipAddress, details: { reportType, ...reportData }, }); - await this.auditRepo.save(log); this.logger.log(`Report generated: type=${reportType} by admin=${adminId}`); return { reportType, ...reportData }; diff --git a/backend/services/compliance-service/src/application/services/admin-dispute.service.ts b/backend/services/compliance-service/src/application/services/admin-dispute.service.ts index a50b448..8b016f3 100644 --- a/backend/services/compliance-service/src/application/services/admin-dispute.service.ts +++ b/backend/services/compliance-service/src/application/services/admin-dispute.service.ts @@ -1,40 +1,34 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Dispute, DisputeStatus } from '../../domain/entities/dispute.entity'; -import { AuditLog } from '../../domain/entities/audit-log.entity'; +import { + DISPUTE_REPOSITORY, + IDisputeRepository, +} from '../../domain/repositories/dispute.repository.interface'; +import { + AUDIT_LOGGER_SERVICE, + IAuditLoggerService, +} from '../../domain/ports/audit-logger.interface'; @Injectable() export class AdminDisputeService { private readonly logger = new Logger('AdminDisputeService'); constructor( - @InjectRepository(Dispute) private readonly disputeRepo: Repository, - @InjectRepository(AuditLog) private readonly auditRepo: Repository, + @Inject(DISPUTE_REPOSITORY) + private readonly disputeRepo: IDisputeRepository, + @Inject(AUDIT_LOGGER_SERVICE) + private readonly auditLogger: IAuditLoggerService, ) {} /** List disputes (paginated, filterable by status and type) */ async listDisputes(page: number, limit: number, status?: string, type?: string) { - const qb = this.disputeRepo.createQueryBuilder('dispute'); - - if (status) { - qb.andWhere('dispute.status = :status', { status }); - } - if (type) { - qb.andWhere('dispute.type = :type', { type }); - } - - qb.orderBy('dispute.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.disputeRepo.findPaginated(page, limit, { status, type }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } /** Get dispute detail by ID */ async getDisputeDetail(id: string) { - const dispute = await this.disputeRepo.findOne({ where: { id } }); + const dispute = await this.disputeRepo.findById(id); if (!dispute) throw new NotFoundException('Dispute not found'); return dispute; } @@ -47,7 +41,7 @@ export class AdminDisputeService { adminName: string, ipAddress?: string, ) { - const dispute = await this.disputeRepo.findOne({ where: { id } }); + const dispute = await this.disputeRepo.findById(id); if (!dispute) throw new NotFoundException('Dispute not found'); const previousStatus = dispute.status; @@ -57,11 +51,19 @@ export class AdminDisputeService { const saved = await this.disputeRepo.save(dispute); - // Audit log - await this.logAction(adminId, adminName, 'resolve_dispute', 'dispute', id, ipAddress, { - previousStatus, - newStatus: dispute.status, - resolution: data.resolution, + // Audit log via shared service + await this.auditLogger.log({ + adminId, + adminName, + action: 'resolve_dispute', + resource: 'dispute', + resourceId: id, + ipAddress, + details: { + previousStatus, + newStatus: dispute.status, + resolution: data.resolution, + }, }); this.logger.log(`Dispute resolved: id=${id}, status=${dispute.status}, by admin=${adminId}`); @@ -76,7 +78,7 @@ export class AdminDisputeService { adminName: string, ipAddress?: string, ) { - const dispute = await this.disputeRepo.findOne({ where: { id } }); + const dispute = await this.disputeRepo.findById(id); if (!dispute) throw new NotFoundException('Dispute not found'); const previousStatus = dispute.status; @@ -87,37 +89,22 @@ export class AdminDisputeService { const saved = await this.disputeRepo.save(dispute); - // Audit log - await this.logAction(adminId, adminName, 'arbitrate_dispute', 'dispute', id, ipAddress, { - previousStatus, - decision: data.decision, - notes: data.notes, + // Audit log via shared service + await this.auditLogger.log({ + adminId, + adminName, + action: 'arbitrate_dispute', + resource: 'dispute', + resourceId: id, + ipAddress, + details: { + previousStatus, + decision: data.decision, + notes: data.notes, + }, }); this.logger.log(`Dispute arbitrated: id=${id}, by admin=${adminId}`); return saved; } - - /** Write an entry to the audit log */ - private async logAction( - adminId: string, - adminName: string, - action: string, - resource: string, - resourceId: string, - ipAddress?: string, - details?: any, - ) { - const log = this.auditRepo.create({ - adminId, - adminName, - action, - resource, - resourceId, - ipAddress: ipAddress || null, - result: 'success', - details: details || null, - }); - await this.auditRepo.save(log); - } } diff --git a/backend/services/compliance-service/src/application/services/admin-insurance.service.ts b/backend/services/compliance-service/src/application/services/admin-insurance.service.ts index 577e34a..ae65121 100644 --- a/backend/services/compliance-service/src/application/services/admin-insurance.service.ts +++ b/backend/services/compliance-service/src/application/services/admin-insurance.service.ts @@ -1,16 +1,23 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InsuranceClaim, ClaimStatus } from '../../domain/entities/insurance-claim.entity'; -import { AuditLog } from '../../domain/entities/audit-log.entity'; +import { + INSURANCE_CLAIM_REPOSITORY, + IInsuranceClaimRepository, +} from '../../domain/repositories/insurance-claim.repository.interface'; +import { + AUDIT_LOGGER_SERVICE, + IAuditLoggerService, +} from '../../domain/ports/audit-logger.interface'; @Injectable() export class AdminInsuranceService { private readonly logger = new Logger('AdminInsuranceService'); constructor( - @InjectRepository(InsuranceClaim) private readonly claimRepo: Repository, - @InjectRepository(AuditLog) private readonly auditRepo: Repository, + @Inject(INSURANCE_CLAIM_REPOSITORY) + private readonly claimRepo: IInsuranceClaimRepository, + @Inject(AUDIT_LOGGER_SERVICE) + private readonly auditLogger: IAuditLoggerService, ) {} /** Protection fund statistics */ @@ -22,17 +29,10 @@ export class AdminInsuranceService { this.claimRepo.count({ where: { status: ClaimStatus.REJECTED } }), ]); - const paidAmountResult = await this.claimRepo - .createQueryBuilder('claim') - .select('COALESCE(SUM(claim.amount), 0)', 'totalPaid') - .where('claim.status = :status', { status: ClaimStatus.PAID }) - .getRawOne(); - - const pendingAmountResult = await this.claimRepo - .createQueryBuilder('claim') - .select('COALESCE(SUM(claim.amount), 0)', 'totalPending') - .where('claim.status = :status', { status: ClaimStatus.PENDING }) - .getRawOne(); + const [totalPaidAmount, totalPendingAmount] = await Promise.all([ + this.claimRepo.sumAmountByStatus(ClaimStatus.PAID), + this.claimRepo.sumAmountByStatus(ClaimStatus.PENDING), + ]); return { totalClaims, @@ -40,25 +40,15 @@ export class AdminInsuranceService { paidClaims, rejectedClaims, processingClaims: totalClaims - pendingClaims - paidClaims - rejectedClaims, - totalPaidAmount: paidAmountResult?.totalPaid || '0', - totalPendingAmount: pendingAmountResult?.totalPending || '0', + totalPaidAmount, + totalPendingAmount, fundBalance: '1000000.00', // Mock: protection fund balance }; } /** List insurance claims (paginated, filterable by status) */ async listClaims(page: number, limit: number, status?: string) { - const qb = this.claimRepo.createQueryBuilder('claim'); - - if (status) { - qb.andWhere('claim.status = :status', { status }); - } - - qb.orderBy('claim.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.claimRepo.findPaginated(page, limit, { status }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } @@ -69,7 +59,7 @@ export class AdminInsuranceService { adminName: string, ipAddress?: string, ) { - const claim = await this.claimRepo.findOne({ where: { id } }); + const claim = await this.claimRepo.findById(id); if (!claim) throw new NotFoundException('Insurance claim not found'); const previousStatus = claim.status; @@ -78,11 +68,19 @@ export class AdminInsuranceService { const saved = await this.claimRepo.save(claim); - // Audit log - await this.logAction(adminId, adminName, 'approve_claim', 'insurance_claim', id, ipAddress, { - previousStatus, - amount: claim.amount, - userId: claim.userId, + // Audit log via shared service + await this.auditLogger.log({ + adminId, + adminName, + action: 'approve_claim', + resource: 'insurance_claim', + resourceId: id, + ipAddress, + details: { + previousStatus, + amount: claim.amount, + userId: claim.userId, + }, }); this.logger.log(`Insurance claim approved: id=${id}, amount=${claim.amount}, by admin=${adminId}`); @@ -97,7 +95,7 @@ export class AdminInsuranceService { adminName: string, ipAddress?: string, ) { - const claim = await this.claimRepo.findOne({ where: { id } }); + const claim = await this.claimRepo.findById(id); if (!claim) throw new NotFoundException('Insurance claim not found'); const previousStatus = claim.status; @@ -109,38 +107,23 @@ export class AdminInsuranceService { const saved = await this.claimRepo.save(claim); - // Audit log - await this.logAction(adminId, adminName, 'reject_claim', 'insurance_claim', id, ipAddress, { - previousStatus, - amount: claim.amount, - userId: claim.userId, - rejectionReason: data.reason, + // Audit log via shared service + await this.auditLogger.log({ + adminId, + adminName, + action: 'reject_claim', + resource: 'insurance_claim', + resourceId: id, + ipAddress, + details: { + previousStatus, + amount: claim.amount, + userId: claim.userId, + rejectionReason: data.reason, + }, }); this.logger.log(`Insurance claim rejected: id=${id}, by admin=${adminId}`); return saved; } - - /** Write an entry to the audit log */ - private async logAction( - adminId: string, - adminName: string, - action: string, - resource: string, - resourceId: string, - ipAddress?: string, - details?: any, - ) { - const log = this.auditRepo.create({ - adminId, - adminName, - action, - resource, - resourceId, - ipAddress: ipAddress || null, - result: 'success', - details: details || null, - }); - await this.auditRepo.save(log); - } } diff --git a/backend/services/compliance-service/src/application/services/admin-risk.service.ts b/backend/services/compliance-service/src/application/services/admin-risk.service.ts index 57a984d..5404fe0 100644 --- a/backend/services/compliance-service/src/application/services/admin-risk.service.ts +++ b/backend/services/compliance-service/src/application/services/admin-risk.service.ts @@ -1,20 +1,35 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AmlAlert, AlertStatus } from '../../domain/entities/aml-alert.entity'; -import { OfacScreening } from '../../domain/entities/ofac-screening.entity'; -import { SarReport } from '../../domain/entities/sar-report.entity'; -import { AuditLog } from '../../domain/entities/audit-log.entity'; +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { AlertStatus } from '../../domain/entities/aml-alert.entity'; +import { + AML_ALERT_REPOSITORY, + IAmlAlertRepository, +} from '../../domain/repositories/aml-alert.repository.interface'; +import { + OFAC_SCREENING_REPOSITORY, + IOfacScreeningRepository, +} from '../../domain/repositories/ofac-screening.repository.interface'; +import { + SAR_REPORT_REPOSITORY, + ISarReportRepository, +} from '../../domain/repositories/sar-report.repository.interface'; +import { + AUDIT_LOGGER_SERVICE, + IAuditLoggerService, +} from '../../domain/ports/audit-logger.interface'; @Injectable() export class AdminRiskService { private readonly logger = new Logger('AdminRiskService'); constructor( - @InjectRepository(AmlAlert) private readonly alertRepo: Repository, - @InjectRepository(OfacScreening) private readonly ofacRepo: Repository, - @InjectRepository(SarReport) private readonly sarRepo: Repository, - @InjectRepository(AuditLog) private readonly auditRepo: Repository, + @Inject(AML_ALERT_REPOSITORY) + private readonly alertRepo: IAmlAlertRepository, + @Inject(OFAC_SCREENING_REPOSITORY) + private readonly ofacRepo: IOfacScreeningRepository, + @Inject(SAR_REPORT_REPOSITORY) + private readonly sarRepo: ISarReportRepository, + @Inject(AUDIT_LOGGER_SERVICE) + private readonly auditLogger: IAuditLoggerService, ) {} /** Risk dashboard: aggregate stats across alerts, screenings, SARs */ @@ -27,12 +42,7 @@ export class AdminRiskService { { status: AlertStatus.ESCALATED }, ], }), - this.alertRepo - .createQueryBuilder('a') - .where('a.risk_score >= :threshold', { threshold: 70 }) - .andWhere('a.status != :resolved', { resolved: AlertStatus.RESOLVED }) - .andWhere('a.status != :dismissed', { dismissed: AlertStatus.DISMISSED }) - .getCount(), + this.alertRepo.countHighRiskActive(70), this.ofacRepo.count({ where: { isMatch: true } }), ]); @@ -51,45 +61,19 @@ export class AdminRiskService { /** List active risk/AML alerts (paginated) */ async listAlerts(page: number, limit: number, status?: string, pattern?: string) { - const qb = this.alertRepo.createQueryBuilder('alert'); - - if (status) { - qb.andWhere('alert.status = :status', { status }); - } - if (pattern) { - qb.andWhere('alert.pattern = :pattern', { pattern }); - } - - qb.orderBy('alert.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.alertRepo.findPaginated(page, limit, { status, pattern }); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } /** List flagged suspicious transactions (high risk score) */ async listSuspiciousTrades(page: number, limit: number) { - const qb = this.alertRepo - .createQueryBuilder('alert') - .where('alert.risk_score >= :threshold', { threshold: 70 }) - .orderBy('alert.risk_score', 'DESC') - .addOrderBy('alert.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.alertRepo.findSuspicious(page, limit, 70); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } /** List blacklisted users from OFAC screening */ async listBlacklist(page: number, limit: number) { - const [items, total] = await this.ofacRepo.findAndCount({ - where: { isMatch: true }, - order: { screenedAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); + const [items, total] = await this.ofacRepo.findMatchesPaginated(page, limit); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } @@ -100,17 +84,26 @@ export class AdminRiskService { adminName: string, ipAddress?: string, ) { - const alert = await this.alertRepo.findOne({ where: { id: alertId } }); + const alert = await this.alertRepo.findById(alertId); if (!alert) throw new NotFoundException('Alert not found'); + const previousStatus = alert.status; alert.status = AlertStatus.ESCALATED; alert.resolution = `Account frozen by admin ${adminName}`; await this.alertRepo.save(alert); - // Log the admin action - await this.logAction(adminId, adminName, 'freeze_account', 'aml_alert', alertId, ipAddress, { - userId: alert.userId, - previousStatus: alert.status, + // Log the admin action via shared service + await this.auditLogger.log({ + adminId, + adminName, + action: 'freeze_account', + resource: 'aml_alert', + resourceId: alertId, + ipAddress, + details: { + userId: alert.userId, + previousStatus, + }, }); this.logger.warn(`Account frozen: alert=${alertId}, user=${alert.userId}, by admin=${adminId}`); @@ -124,7 +117,7 @@ export class AdminRiskService { adminName: string, ipAddress?: string, ) { - const alert = await this.alertRepo.findOne({ where: { id: alertId } }); + const alert = await this.alertRepo.findById(alertId); if (!alert) throw new NotFoundException('Alert not found'); const sar = this.sarRepo.create({ @@ -140,36 +133,21 @@ export class AdminRiskService { alert.status = AlertStatus.ESCALATED; await this.alertRepo.save(alert); - // Log the admin action - await this.logAction(adminId, adminName, 'generate_sar', 'sar_report', saved.id, ipAddress, { - alertId, - userId: alert.userId, + // Log the admin action via shared service + await this.auditLogger.log({ + adminId, + adminName, + action: 'generate_sar', + resource: 'sar_report', + resourceId: saved.id, + ipAddress, + details: { + alertId, + userId: alert.userId, + }, }); this.logger.log(`SAR generated: sar=${saved.id} from alert=${alertId}, by admin=${adminId}`); return saved; } - - /** Write an entry to the audit log */ - private async logAction( - adminId: string, - adminName: string, - action: string, - resource: string, - resourceId: string, - ipAddress?: string, - details?: any, - ) { - const log = this.auditRepo.create({ - adminId, - adminName, - action, - resource, - resourceId, - ipAddress: ipAddress || null, - result: 'success', - details: details || null, - }); - await this.auditRepo.save(log); - } } diff --git a/backend/services/compliance-service/src/application/services/aml.service.ts b/backend/services/compliance-service/src/application/services/aml.service.ts index 5746440..3183e9c 100644 --- a/backend/services/compliance-service/src/application/services/aml.service.ts +++ b/backend/services/compliance-service/src/application/services/aml.service.ts @@ -1,12 +1,20 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { AmlAlert, AmlPattern, AlertStatus } from '../../domain/entities/aml-alert.entity'; +import { + AML_ALERT_REPOSITORY, + IAmlAlertRepository, +} from '../../domain/repositories/aml-alert.repository.interface'; +import { RiskScore } from '../../domain/value-objects/risk-score.vo'; +import { AlertSeverity } from '../../domain/value-objects/alert-severity.vo'; @Injectable() export class AmlService { private readonly logger = new Logger('AMLService'); - constructor(@InjectRepository(AmlAlert) private readonly repo: Repository) {} + + constructor( + @Inject(AML_ALERT_REPOSITORY) + private readonly repo: IAmlAlertRepository, + ) {} /** * Analyze a transaction for AML patterns. @@ -70,15 +78,20 @@ export class AmlService { description: string, evidence: any, ): Promise { + const score = RiskScore.create(riskScore); + const severity = AlertSeverity.fromRiskScore(riskScore); + const alert = this.repo.create({ userId, pattern, - riskScore: String(riskScore), + riskScore: score.toString(), description, evidence, status: AlertStatus.OPEN, }); - this.logger.warn(`AML Alert: ${pattern} for user ${userId}, risk=${riskScore}`); + this.logger.warn( + `AML Alert: ${pattern} for user ${userId}, risk=${score.value}, severity=${severity.value}`, + ); return this.repo.save(alert); } diff --git a/backend/services/compliance-service/src/application/services/ofac.service.ts b/backend/services/compliance-service/src/application/services/ofac.service.ts index 9fd18d3..5a3167d 100644 --- a/backend/services/compliance-service/src/application/services/ofac.service.ts +++ b/backend/services/compliance-service/src/application/services/ofac.service.ts @@ -1,11 +1,16 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; import { OfacScreening } from '../../domain/entities/ofac-screening.entity'; +import { + OFAC_SCREENING_REPOSITORY, + IOfacScreeningRepository, +} from '../../domain/repositories/ofac-screening.repository.interface'; @Injectable() export class OfacService { - constructor(@InjectRepository(OfacScreening) private readonly repo: Repository) {} + constructor( + @Inject(OFAC_SCREENING_REPOSITORY) + private readonly repo: IOfacScreeningRepository, + ) {} /** * Screen a name against OFAC SDN list (mock implementation). @@ -25,6 +30,6 @@ export class OfacService { } async getScreeningsByUserId(userId: string) { - return this.repo.find({ where: { userId }, order: { screenedAt: 'DESC' } }); + return this.repo.findByUserId(userId); } } diff --git a/backend/services/compliance-service/src/application/services/sar.service.ts b/backend/services/compliance-service/src/application/services/sar.service.ts index e3437e0..b25e236 100644 --- a/backend/services/compliance-service/src/application/services/sar.service.ts +++ b/backend/services/compliance-service/src/application/services/sar.service.ts @@ -1,11 +1,15 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { SarReport } from '../../domain/entities/sar-report.entity'; +import { Inject, Injectable } from '@nestjs/common'; +import { + SAR_REPORT_REPOSITORY, + ISarReportRepository, +} from '../../domain/repositories/sar-report.repository.interface'; @Injectable() export class SarService { - constructor(@InjectRepository(SarReport) private readonly repo: Repository) {} + constructor( + @Inject(SAR_REPORT_REPOSITORY) + private readonly repo: ISarReportRepository, + ) {} async createReport(data: { alertId: string; @@ -26,11 +30,7 @@ export class SarService { } async listReports(page: number, limit: number) { - const [items, total] = await this.repo.findAndCount({ - skip: (page - 1) * limit, - take: limit, - order: { createdAt: 'DESC' }, - }); + const [items, total] = await this.repo.findPaginated(page, limit); return { items, total, page, limit }; } } diff --git a/backend/services/compliance-service/src/application/services/travel-rule.service.ts b/backend/services/compliance-service/src/application/services/travel-rule.service.ts index 51d577e..4e9faac 100644 --- a/backend/services/compliance-service/src/application/services/travel-rule.service.ts +++ b/backend/services/compliance-service/src/application/services/travel-rule.service.ts @@ -1,11 +1,16 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { TravelRuleRecord } from '../../domain/entities/travel-rule-record.entity'; +import { + TRAVEL_RULE_REPOSITORY, + ITravelRuleRepository, +} from '../../domain/repositories/travel-rule.repository.interface'; @Injectable() export class TravelRuleService { - constructor(@InjectRepository(TravelRuleRecord) private readonly repo: Repository) {} + constructor( + @Inject(TRAVEL_RULE_REPOSITORY) + private readonly repo: ITravelRuleRepository, + ) {} /** * Check if a transfer requires Travel Rule compliance (>= $3,000). @@ -44,6 +49,6 @@ export class TravelRuleService { } async getByTransactionId(transactionId: string) { - return this.repo.findOne({ where: { transactionId } }); + return this.repo.findByTransactionId(transactionId); } } diff --git a/backend/services/compliance-service/src/compliance.module.ts b/backend/services/compliance-service/src/compliance.module.ts index 3a5d0d3..4596205 100644 --- a/backend/services/compliance-service/src/compliance.module.ts +++ b/backend/services/compliance-service/src/compliance.module.ts @@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; -// Domain Entities +// ─── Domain Entities ─── import { AmlAlert } from './domain/entities/aml-alert.entity'; import { OfacScreening } from './domain/entities/ofac-screening.entity'; import { TravelRuleRecord } from './domain/entities/travel-rule-record.entity'; @@ -12,19 +12,41 @@ import { Dispute } from './domain/entities/dispute.entity'; import { AuditLog } from './domain/entities/audit-log.entity'; import { InsuranceClaim } from './domain/entities/insurance-claim.entity'; -// Core Services +// ─── Domain Repository Interfaces (Symbols) ─── +import { AML_ALERT_REPOSITORY } from './domain/repositories/aml-alert.repository.interface'; +import { OFAC_SCREENING_REPOSITORY } from './domain/repositories/ofac-screening.repository.interface'; +import { SAR_REPORT_REPOSITORY } from './domain/repositories/sar-report.repository.interface'; +import { TRAVEL_RULE_REPOSITORY } from './domain/repositories/travel-rule.repository.interface'; +import { DISPUTE_REPOSITORY } from './domain/repositories/dispute.repository.interface'; +import { INSURANCE_CLAIM_REPOSITORY } from './domain/repositories/insurance-claim.repository.interface'; +import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository.interface'; + +// ─── Infrastructure Persistence (Concrete Implementations) ─── +import { AmlAlertRepository } from './infrastructure/persistence/aml-alert.repository'; +import { OfacScreeningRepository } from './infrastructure/persistence/ofac-screening.repository'; +import { SarReportRepository } from './infrastructure/persistence/sar-report.repository'; +import { TravelRuleRepository } from './infrastructure/persistence/travel-rule.repository'; +import { DisputeRepository } from './infrastructure/persistence/dispute.repository'; +import { InsuranceClaimRepository } from './infrastructure/persistence/insurance-claim.repository'; +import { AuditLogRepository } from './infrastructure/persistence/audit-log.repository'; + +// ─── Domain Ports ─── +import { AUDIT_LOGGER_SERVICE } from './domain/ports/audit-logger.interface'; + +// ─── Infrastructure Services ─── +import { AuditLoggerService } from './infrastructure/services/audit-logger.service'; + +// ─── Application Services ─── import { AmlService } from './application/services/aml.service'; import { OfacService } from './application/services/ofac.service'; import { TravelRuleService } from './application/services/travel-rule.service'; import { SarService } from './application/services/sar.service'; - -// Admin Services import { AdminRiskService } from './application/services/admin-risk.service'; import { AdminComplianceService } from './application/services/admin-compliance.service'; import { AdminDisputeService } from './application/services/admin-dispute.service'; import { AdminInsuranceService } from './application/services/admin-insurance.service'; -// Controllers +// ─── Interface Controllers ─── import { ComplianceController } from './interface/http/controllers/compliance.controller'; import { AdminRiskController } from './interface/http/controllers/admin-risk.controller'; import { AdminComplianceController } from './interface/http/controllers/admin-compliance.controller'; @@ -53,18 +75,30 @@ import { AdminInsuranceController } from './interface/http/controllers/admin-ins AdminInsuranceController, ], providers: [ - // Core services + // ─── Repository DI bindings (interface → implementation) ─── + { provide: AML_ALERT_REPOSITORY, useClass: AmlAlertRepository }, + { provide: OFAC_SCREENING_REPOSITORY, useClass: OfacScreeningRepository }, + { provide: SAR_REPORT_REPOSITORY, useClass: SarReportRepository }, + { provide: TRAVEL_RULE_REPOSITORY, useClass: TravelRuleRepository }, + { provide: DISPUTE_REPOSITORY, useClass: DisputeRepository }, + { provide: INSURANCE_CLAIM_REPOSITORY, useClass: InsuranceClaimRepository }, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + + // ─── Infrastructure services ─── + { provide: AUDIT_LOGGER_SERVICE, useClass: AuditLoggerService }, + + // ─── Application services ─── AmlService, OfacService, TravelRuleService, SarService, - // Admin services AdminRiskService, AdminComplianceService, AdminDisputeService, AdminInsuranceService, ], exports: [ + // Export application services for potential use by other modules AmlService, OfacService, TravelRuleService, @@ -73,6 +107,9 @@ import { AdminInsuranceController } from './interface/http/controllers/admin-ins AdminComplianceService, AdminDisputeService, AdminInsuranceService, + + // Export infrastructure service symbols for DI in other modules + AUDIT_LOGGER_SERVICE, ], }) export class ComplianceModule {} diff --git a/backend/services/compliance-service/src/domain/events/compliance.events.ts b/backend/services/compliance-service/src/domain/events/compliance.events.ts new file mode 100644 index 0000000..9a232ff --- /dev/null +++ b/backend/services/compliance-service/src/domain/events/compliance.events.ts @@ -0,0 +1,65 @@ +/** + * Domain Events for the Compliance bounded context. + * + * These events represent significant domain occurrences that other + * parts of the system (or external services via Kafka) may react to. + */ + +export interface AmlAlertCreatedEvent { + alertId: string; + userId: string; + pattern: string; + riskScore: number; + description: string; + timestamp: string; +} + +export interface OfacScreeningCompletedEvent { + screeningId: string; + userId: string; + screenedName: string; + isMatch: boolean; + matchScore: string; + result: string; + timestamp: string; +} + +export interface SarReportFiledEvent { + reportId: string; + alertId: string; + userId: string; + reportType: string; + fincenReference: string | null; + timestamp: string; +} + +export interface DisputeOpenedEvent { + disputeId: string; + orderId: string; + plaintiffId: string; + defendantId: string | null; + type: string; + amount: string; + timestamp: string; +} + +export interface DisputeResolvedEvent { + disputeId: string; + orderId: string; + plaintiffId: string; + status: string; + resolution: string; + resolvedAt: string; + timestamp: string; +} + +/** + * Compliance event topic constants for Kafka publishing. + */ +export const COMPLIANCE_EVENT_TOPICS = { + AML_ALERT_CREATED: 'compliance.aml-alert.created', + OFAC_SCREENING_COMPLETED: 'compliance.ofac-screening.completed', + SAR_REPORT_FILED: 'compliance.sar-report.filed', + DISPUTE_OPENED: 'compliance.dispute.opened', + DISPUTE_RESOLVED: 'compliance.dispute.resolved', +} as const; diff --git a/backend/services/compliance-service/src/domain/ports/audit-logger.interface.ts b/backend/services/compliance-service/src/domain/ports/audit-logger.interface.ts new file mode 100644 index 0000000..3c1330b --- /dev/null +++ b/backend/services/compliance-service/src/domain/ports/audit-logger.interface.ts @@ -0,0 +1,23 @@ +/** + * Audit Logger domain port. + * + * Application services depend on this interface for recording audit trails. + * The concrete implementation lives in infrastructure/services/. + */ + +export const AUDIT_LOGGER_SERVICE = Symbol('IAuditLoggerService'); + +export interface AuditLogParams { + adminId: string; + adminName: string; + action: string; + resource: string; + resourceId: string | null; + ipAddress?: string; + result?: string; + details?: Record | null; +} + +export interface IAuditLoggerService { + log(params: AuditLogParams): Promise; +} diff --git a/backend/services/compliance-service/src/domain/repositories/aml-alert.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/aml-alert.repository.interface.ts new file mode 100644 index 0000000..90535e8 --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/aml-alert.repository.interface.ts @@ -0,0 +1,38 @@ +import { AmlAlert, AlertStatus, AmlPattern } from '../entities/aml-alert.entity'; + +export const AML_ALERT_REPOSITORY = Symbol('IAmlAlertRepository'); + +export interface IAmlAlertRepository { + create(data: Partial): AmlAlert; + save(alert: AmlAlert): Promise; + findById(id: string): Promise; + findAndCount(options: { + where?: Record; + skip?: number; + take?: number; + order?: Record; + }): Promise<[AmlAlert[], number]>; + find(options: { + where?: Record | Record[]; + order?: Record; + take?: number; + }): Promise; + count(options?: { where?: Record | Record[] }): Promise; + update(id: string, data: Partial): Promise; + + /** Count alerts with risk_score >= threshold and not in resolved/dismissed status */ + countHighRiskActive(threshold: number): Promise; + + /** Paginated query with optional status and pattern filters */ + findPaginated( + page: number, + limit: number, + filters?: { status?: string; pattern?: string }, + ): Promise<[AmlAlert[], number]>; + + /** Find alerts with risk_score >= threshold, ordered by score desc */ + findSuspicious(page: number, limit: number, threshold: number): Promise<[AmlAlert[], number]>; + + /** Count high-risk alerts (risk_score >= threshold) */ + countHighRisk(threshold: number): Promise; +} diff --git a/backend/services/compliance-service/src/domain/repositories/audit-log.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/audit-log.repository.interface.ts new file mode 100644 index 0000000..c5fc991 --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/audit-log.repository.interface.ts @@ -0,0 +1,21 @@ +import { AuditLog } from '../entities/audit-log.entity'; + +export const AUDIT_LOG_REPOSITORY = Symbol('IAuditLogRepository'); + +export interface IAuditLogRepository { + create(data: Partial): AuditLog; + save(log: AuditLog): Promise; + + /** Paginated list with optional filters */ + findPaginated( + page: number, + limit: number, + filters?: { + action?: string; + adminId?: string; + resource?: string; + startDate?: string; + endDate?: string; + }, + ): Promise<[AuditLog[], number]>; +} diff --git a/backend/services/compliance-service/src/domain/repositories/dispute.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/dispute.repository.interface.ts new file mode 100644 index 0000000..dc194a1 --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/dispute.repository.interface.ts @@ -0,0 +1,16 @@ +import { Dispute } from '../entities/dispute.entity'; + +export const DISPUTE_REPOSITORY = Symbol('IDisputeRepository'); + +export interface IDisputeRepository { + create(data: Partial): Dispute; + save(dispute: Dispute): Promise; + findById(id: string): Promise; + + /** Paginated list with optional status and type filters */ + findPaginated( + page: number, + limit: number, + filters?: { status?: string; type?: string }, + ): Promise<[Dispute[], number]>; +} diff --git a/backend/services/compliance-service/src/domain/repositories/insurance-claim.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/insurance-claim.repository.interface.ts new file mode 100644 index 0000000..b300c70 --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/insurance-claim.repository.interface.ts @@ -0,0 +1,20 @@ +import { InsuranceClaim, ClaimStatus } from '../entities/insurance-claim.entity'; + +export const INSURANCE_CLAIM_REPOSITORY = Symbol('IInsuranceClaimRepository'); + +export interface IInsuranceClaimRepository { + create(data: Partial): InsuranceClaim; + save(claim: InsuranceClaim): Promise; + findById(id: string): Promise; + count(options?: { where?: Record }): Promise; + + /** Paginated list with optional status filter */ + findPaginated( + page: number, + limit: number, + filters?: { status?: string }, + ): Promise<[InsuranceClaim[], number]>; + + /** Sum amount for a given claim status */ + sumAmountByStatus(status: ClaimStatus): Promise; +} diff --git a/backend/services/compliance-service/src/domain/repositories/ofac-screening.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/ofac-screening.repository.interface.ts new file mode 100644 index 0000000..44a18ce --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/ofac-screening.repository.interface.ts @@ -0,0 +1,13 @@ +import { OfacScreening } from '../entities/ofac-screening.entity'; + +export const OFAC_SCREENING_REPOSITORY = Symbol('IOfacScreeningRepository'); + +export interface IOfacScreeningRepository { + create(data: Partial): OfacScreening; + save(screening: OfacScreening): Promise; + findByUserId(userId: string): Promise; + count(options?: { where?: Record }): Promise; + + /** Find OFAC matches (blacklisted), paginated */ + findMatchesPaginated(page: number, limit: number): Promise<[OfacScreening[], number]>; +} diff --git a/backend/services/compliance-service/src/domain/repositories/sar-report.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/sar-report.repository.interface.ts new file mode 100644 index 0000000..f2fae0a --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/sar-report.repository.interface.ts @@ -0,0 +1,18 @@ +import { SarReport } from '../entities/sar-report.entity'; + +export const SAR_REPORT_REPOSITORY = Symbol('ISarReportRepository'); + +export interface ISarReportRepository { + create(data: Partial): SarReport; + save(report: SarReport): Promise; + findById(id: string): Promise; + update(id: string, data: Partial): Promise; + count(options?: { where?: Record }): Promise; + + /** Paginated list with optional status filter */ + findPaginated( + page: number, + limit: number, + filters?: { status?: string }, + ): Promise<[SarReport[], number]>; +} diff --git a/backend/services/compliance-service/src/domain/repositories/travel-rule.repository.interface.ts b/backend/services/compliance-service/src/domain/repositories/travel-rule.repository.interface.ts new file mode 100644 index 0000000..8f15b5b --- /dev/null +++ b/backend/services/compliance-service/src/domain/repositories/travel-rule.repository.interface.ts @@ -0,0 +1,10 @@ +import { TravelRuleRecord } from '../entities/travel-rule-record.entity'; + +export const TRAVEL_RULE_REPOSITORY = Symbol('ITravelRuleRepository'); + +export interface ITravelRuleRepository { + create(data: Partial): TravelRuleRecord; + save(record: TravelRuleRecord): Promise; + findByTransactionId(transactionId: string): Promise; + count(): Promise; +} diff --git a/backend/services/compliance-service/src/domain/value-objects/alert-severity.vo.ts b/backend/services/compliance-service/src/domain/value-objects/alert-severity.vo.ts new file mode 100644 index 0000000..3234a66 --- /dev/null +++ b/backend/services/compliance-service/src/domain/value-objects/alert-severity.vo.ts @@ -0,0 +1,100 @@ +/** + * Value Object: AlertSeverity + * + * Encapsulates the severity level of a compliance alert. + * Immutable after creation via static factory methods. + */ +export enum SeverityLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +export class AlertSeverity { + private static readonly VALID_LEVELS = Object.values(SeverityLevel); + + private constructor(private readonly level: SeverityLevel) {} + + /** + * Create an AlertSeverity from a string level. + * @throws Error if level is not a valid severity level + */ + static create(level: string): AlertSeverity { + const normalized = level.toLowerCase() as SeverityLevel; + if (!AlertSeverity.VALID_LEVELS.includes(normalized)) { + throw new Error( + `Invalid severity level: "${level}". Must be one of: ${AlertSeverity.VALID_LEVELS.join(', ')}`, + ); + } + return new AlertSeverity(normalized); + } + + /** Create a LOW severity */ + static low(): AlertSeverity { + return new AlertSeverity(SeverityLevel.LOW); + } + + /** Create a MEDIUM severity */ + static medium(): AlertSeverity { + return new AlertSeverity(SeverityLevel.MEDIUM); + } + + /** Create a HIGH severity */ + static high(): AlertSeverity { + return new AlertSeverity(SeverityLevel.HIGH); + } + + /** Create a CRITICAL severity */ + static critical(): AlertSeverity { + return new AlertSeverity(SeverityLevel.CRITICAL); + } + + /** + * Derive severity from a risk score. + * - 0-39: LOW + * - 40-69: MEDIUM + * - 70-89: HIGH + * - 90-100: CRITICAL + */ + static fromRiskScore(score: number): AlertSeverity { + if (score >= 90) return AlertSeverity.critical(); + if (score >= 70) return AlertSeverity.high(); + if (score >= 40) return AlertSeverity.medium(); + return AlertSeverity.low(); + } + + get value(): SeverityLevel { + return this.level; + } + + toString(): string { + return this.level; + } + + /** Numeric priority (higher = more severe) for sorting/comparison */ + get priority(): number { + switch (this.level) { + case SeverityLevel.CRITICAL: + return 4; + case SeverityLevel.HIGH: + return 3; + case SeverityLevel.MEDIUM: + return 2; + case SeverityLevel.LOW: + return 1; + } + } + + isHigherThan(other: AlertSeverity): boolean { + return this.priority > other.priority; + } + + isAtLeast(level: SeverityLevel): boolean { + return this.priority >= AlertSeverity.create(level).priority; + } + + equals(other: AlertSeverity): boolean { + return this.level === other.level; + } +} diff --git a/backend/services/compliance-service/src/domain/value-objects/risk-score.vo.ts b/backend/services/compliance-service/src/domain/value-objects/risk-score.vo.ts new file mode 100644 index 0000000..7899bec --- /dev/null +++ b/backend/services/compliance-service/src/domain/value-objects/risk-score.vo.ts @@ -0,0 +1,77 @@ +/** + * Value Object: RiskScore + * + * Encapsulates a risk score value (0-100) with domain validation. + * Immutable after creation via static factory methods. + */ +export class RiskScore { + private static readonly MIN = 0; + private static readonly MAX = 100; + private static readonly HIGH_THRESHOLD = 70; + private static readonly MEDIUM_THRESHOLD = 40; + + private constructor(private readonly score: number) {} + + /** + * Create a RiskScore from a numeric value. + * @throws Error if score is out of range [0, 100] + */ + static create(score: number): RiskScore { + if (score < RiskScore.MIN || score > RiskScore.MAX) { + throw new Error( + `Risk score must be between ${RiskScore.MIN} and ${RiskScore.MAX}, got ${score}`, + ); + } + if (!Number.isFinite(score)) { + throw new Error('Risk score must be a finite number'); + } + return new RiskScore(score); + } + + /** + * Reconstitute a RiskScore from a stored string value (e.g. from DB numeric column). + */ + static fromString(value: string): RiskScore { + const parsed = parseFloat(value); + if (isNaN(parsed)) { + throw new Error(`Cannot parse risk score from string: "${value}"`); + } + return RiskScore.create(parsed); + } + + /** Get the numeric value */ + get value(): number { + return this.score; + } + + /** Get the string representation (for DB storage) */ + toString(): string { + return String(this.score); + } + + /** Whether this risk score is considered high risk */ + isHighRisk(): boolean { + return this.score >= RiskScore.HIGH_THRESHOLD; + } + + /** Whether this risk score is considered medium risk */ + isMediumRisk(): boolean { + return this.score >= RiskScore.MEDIUM_THRESHOLD && this.score < RiskScore.HIGH_THRESHOLD; + } + + /** Whether this risk score is considered low risk */ + isLowRisk(): boolean { + return this.score < RiskScore.MEDIUM_THRESHOLD; + } + + /** Get the risk level string */ + get level(): 'high' | 'medium' | 'low' { + if (this.isHighRisk()) return 'high'; + if (this.isMediumRisk()) return 'medium'; + return 'low'; + } + + equals(other: RiskScore): boolean { + return this.score === other.score; + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/aml-alert.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/aml-alert.repository.ts new file mode 100644 index 0000000..515bc1a --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/aml-alert.repository.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AmlAlert, AlertStatus } from '../../domain/entities/aml-alert.entity'; +import { IAmlAlertRepository } from '../../domain/repositories/aml-alert.repository.interface'; + +@Injectable() +export class AmlAlertRepository implements IAmlAlertRepository { + constructor( + @InjectRepository(AmlAlert) + private readonly repo: Repository, + ) {} + + create(data: Partial): AmlAlert { + return this.repo.create(data); + } + + async save(alert: AmlAlert): Promise { + return this.repo.save(alert); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findAndCount(options: { + where?: Record; + skip?: number; + take?: number; + order?: Record; + }): Promise<[AmlAlert[], number]> { + return this.repo.findAndCount(options); + } + + async find(options: { + where?: Record | Record[]; + order?: Record; + take?: number; + }): Promise { + return this.repo.find(options); + } + + async count(options?: { where?: Record | Record[] }): Promise { + return this.repo.count(options); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + } + + async countHighRiskActive(threshold: number): Promise { + return this.repo + .createQueryBuilder('a') + .where('a.risk_score >= :threshold', { threshold }) + .andWhere('a.status != :resolved', { resolved: AlertStatus.RESOLVED }) + .andWhere('a.status != :dismissed', { dismissed: AlertStatus.DISMISSED }) + .getCount(); + } + + async findPaginated( + page: number, + limit: number, + filters?: { status?: string; pattern?: string }, + ): Promise<[AmlAlert[], number]> { + const qb = this.repo.createQueryBuilder('alert'); + + if (filters?.status) { + qb.andWhere('alert.status = :status', { status: filters.status }); + } + if (filters?.pattern) { + qb.andWhere('alert.pattern = :pattern', { pattern: filters.pattern }); + } + + qb.orderBy('alert.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async findSuspicious( + page: number, + limit: number, + threshold: number, + ): Promise<[AmlAlert[], number]> { + const qb = this.repo + .createQueryBuilder('alert') + .where('alert.risk_score >= :threshold', { threshold }) + .orderBy('alert.risk_score', 'DESC') + .addOrderBy('alert.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async countHighRisk(threshold: number): Promise { + return this.repo + .createQueryBuilder('a') + .where('a.risk_score >= :score', { score: threshold }) + .getCount(); + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/audit-log.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/audit-log.repository.ts new file mode 100644 index 0000000..3af2d64 --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/audit-log.repository.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from '../../domain/entities/audit-log.entity'; +import { IAuditLogRepository } from '../../domain/repositories/audit-log.repository.interface'; + +@Injectable() +export class AuditLogRepository implements IAuditLogRepository { + constructor( + @InjectRepository(AuditLog) + private readonly repo: Repository, + ) {} + + create(data: Partial): AuditLog { + return this.repo.create(data); + } + + async save(log: AuditLog): Promise { + return this.repo.save(log); + } + + async findPaginated( + page: number, + limit: number, + filters?: { + action?: string; + adminId?: string; + resource?: string; + startDate?: string; + endDate?: string; + }, + ): Promise<[AuditLog[], number]> { + const qb = this.repo.createQueryBuilder('log'); + + if (filters?.action) { + qb.andWhere('log.action = :action', { action: filters.action }); + } + if (filters?.adminId) { + qb.andWhere('log.admin_id = :adminId', { adminId: filters.adminId }); + } + if (filters?.resource) { + qb.andWhere('log.resource = :resource', { resource: filters.resource }); + } + if (filters?.startDate) { + qb.andWhere('log.created_at >= :startDate', { startDate: filters.startDate }); + } + if (filters?.endDate) { + qb.andWhere('log.created_at <= :endDate', { endDate: filters.endDate }); + } + + qb.orderBy('log.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/dispute.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/dispute.repository.ts new file mode 100644 index 0000000..3598cfc --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/dispute.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Dispute } from '../../domain/entities/dispute.entity'; +import { IDisputeRepository } from '../../domain/repositories/dispute.repository.interface'; + +@Injectable() +export class DisputeRepository implements IDisputeRepository { + constructor( + @InjectRepository(Dispute) + private readonly repo: Repository, + ) {} + + create(data: Partial): Dispute { + return this.repo.create(data); + } + + async save(dispute: Dispute): Promise { + return this.repo.save(dispute); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findPaginated( + page: number, + limit: number, + filters?: { status?: string; type?: string }, + ): Promise<[Dispute[], number]> { + const qb = this.repo.createQueryBuilder('dispute'); + + if (filters?.status) { + qb.andWhere('dispute.status = :status', { status: filters.status }); + } + if (filters?.type) { + qb.andWhere('dispute.type = :type', { type: filters.type }); + } + + qb.orderBy('dispute.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/insurance-claim.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/insurance-claim.repository.ts new file mode 100644 index 0000000..64d6a7f --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/insurance-claim.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { InsuranceClaim, ClaimStatus } from '../../domain/entities/insurance-claim.entity'; +import { IInsuranceClaimRepository } from '../../domain/repositories/insurance-claim.repository.interface'; + +@Injectable() +export class InsuranceClaimRepository implements IInsuranceClaimRepository { + constructor( + @InjectRepository(InsuranceClaim) + private readonly repo: Repository, + ) {} + + create(data: Partial): InsuranceClaim { + return this.repo.create(data); + } + + async save(claim: InsuranceClaim): Promise { + return this.repo.save(claim); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async count(options?: { where?: Record }): Promise { + return this.repo.count(options); + } + + async findPaginated( + page: number, + limit: number, + filters?: { status?: string }, + ): Promise<[InsuranceClaim[], number]> { + const qb = this.repo.createQueryBuilder('claim'); + + if (filters?.status) { + qb.andWhere('claim.status = :status', { status: filters.status }); + } + + qb.orderBy('claim.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async sumAmountByStatus(status: ClaimStatus): Promise { + const result = await this.repo + .createQueryBuilder('claim') + .select('COALESCE(SUM(claim.amount), 0)', 'total') + .where('claim.status = :status', { status }) + .getRawOne(); + return result?.total || '0'; + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/ofac-screening.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/ofac-screening.repository.ts new file mode 100644 index 0000000..ae2768b --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/ofac-screening.repository.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OfacScreening } from '../../domain/entities/ofac-screening.entity'; +import { IOfacScreeningRepository } from '../../domain/repositories/ofac-screening.repository.interface'; + +@Injectable() +export class OfacScreeningRepository implements IOfacScreeningRepository { + constructor( + @InjectRepository(OfacScreening) + private readonly repo: Repository, + ) {} + + create(data: Partial): OfacScreening { + return this.repo.create(data); + } + + async save(screening: OfacScreening): Promise { + return this.repo.save(screening); + } + + async findByUserId(userId: string): Promise { + return this.repo.find({ where: { userId }, order: { screenedAt: 'DESC' } }); + } + + async count(options?: { where?: Record }): Promise { + return this.repo.count(options); + } + + async findMatchesPaginated(page: number, limit: number): Promise<[OfacScreening[], number]> { + return this.repo.findAndCount({ + where: { isMatch: true }, + order: { screenedAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/sar-report.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/sar-report.repository.ts new file mode 100644 index 0000000..9e9eae0 --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/sar-report.repository.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SarReport } from '../../domain/entities/sar-report.entity'; +import { ISarReportRepository } from '../../domain/repositories/sar-report.repository.interface'; + +@Injectable() +export class SarReportRepository implements ISarReportRepository { + constructor( + @InjectRepository(SarReport) + private readonly repo: Repository, + ) {} + + create(data: Partial): SarReport { + return this.repo.create(data); + } + + async save(report: SarReport): Promise { + return this.repo.save(report); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + } + + async count(options?: { where?: Record }): Promise { + return this.repo.count(options); + } + + async findPaginated( + page: number, + limit: number, + filters?: { status?: string }, + ): Promise<[SarReport[], number]> { + const qb = this.repo.createQueryBuilder('sar'); + + if (filters?.status) { + qb.andWhere('sar.filing_status = :status', { status: filters.status }); + } + + qb.orderBy('sar.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } +} diff --git a/backend/services/compliance-service/src/infrastructure/persistence/travel-rule.repository.ts b/backend/services/compliance-service/src/infrastructure/persistence/travel-rule.repository.ts new file mode 100644 index 0000000..960fa2b --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/persistence/travel-rule.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TravelRuleRecord } from '../../domain/entities/travel-rule-record.entity'; +import { ITravelRuleRepository } from '../../domain/repositories/travel-rule.repository.interface'; + +@Injectable() +export class TravelRuleRepository implements ITravelRuleRepository { + constructor( + @InjectRepository(TravelRuleRecord) + private readonly repo: Repository, + ) {} + + create(data: Partial): TravelRuleRecord { + return this.repo.create(data); + } + + async save(record: TravelRuleRecord): Promise { + return this.repo.save(record); + } + + async findByTransactionId(transactionId: string): Promise { + return this.repo.findOne({ where: { transactionId } }); + } + + async count(): Promise { + return this.repo.count(); + } +} diff --git a/backend/services/compliance-service/src/infrastructure/services/audit-logger.service.ts b/backend/services/compliance-service/src/infrastructure/services/audit-logger.service.ts new file mode 100644 index 0000000..49e22e2 --- /dev/null +++ b/backend/services/compliance-service/src/infrastructure/services/audit-logger.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + AUDIT_LOG_REPOSITORY, + IAuditLogRepository, +} from '../../domain/repositories/audit-log.repository.interface'; +import { IAuditLoggerService, AuditLogParams } from '../../domain/ports/audit-logger.interface'; + +@Injectable() +export class AuditLoggerService implements IAuditLoggerService { + private readonly logger = new Logger('AuditLoggerService'); + + constructor( + @Inject(AUDIT_LOG_REPOSITORY) + private readonly auditLogRepo: IAuditLogRepository, + ) {} + + async log(params: AuditLogParams): Promise { + const entry = this.auditLogRepo.create({ + adminId: params.adminId, + adminName: params.adminName, + action: params.action, + resource: params.resource, + resourceId: params.resourceId, + ipAddress: params.ipAddress || null, + result: params.result || 'success', + details: params.details || null, + }); + await this.auditLogRepo.save(entry); + this.logger.debug( + `Audit: action=${params.action} resource=${params.resource} resourceId=${params.resourceId} admin=${params.adminId}`, + ); + } +} diff --git a/backend/services/compliance-service/src/interface/http/controllers/admin-compliance.controller.ts b/backend/services/compliance-service/src/interface/http/controllers/admin-compliance.controller.ts index 02e33e0..59113fc 100644 --- a/backend/services/compliance-service/src/interface/http/controllers/admin-compliance.controller.ts +++ b/backend/services/compliance-service/src/interface/http/controllers/admin-compliance.controller.ts @@ -5,6 +5,12 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AdminComplianceService } from '../../../application/services/admin-compliance.service'; import { Request } from 'express'; +import { + ListSarQueryDto, + AdminCreateSarReportDto, + ListAuditLogsQueryDto, + GenerateReportDto, +} from '../dto/admin-compliance.dto'; @ApiTags('Admin - Compliance & Audit') @Controller('admin/compliance') @@ -18,20 +24,18 @@ export class AdminComplianceController { @Get('sar') @ApiOperation({ summary: 'List SAR reports (paginated)' }) - async listSarReports( - @Query('page') page = 1, - @Query('limit') limit = 20, - @Query('status') status?: string, - ) { - const data = await this.adminComplianceService.listSarReports(+page, +limit, status); + async listSarReports(@Query() query: ListSarQueryDto) { + const data = await this.adminComplianceService.listSarReports( + query.page, + query.limit, + query.status, + ); return { code: 0, data }; } @Post('sar') @ApiOperation({ summary: 'Create a new SAR report' }) - async createSarReport( - @Body() body: { alertId: string; userId: string; reportType: string; narrative: string }, - ) { + async createSarReport(@Body() body: AdminCreateSarReportDto) { const data = await this.adminComplianceService.createSarReport(body); return { code: 0, data, message: 'SAR report created' }; } @@ -40,23 +44,15 @@ export class AdminComplianceController { @Get('audit-logs') @ApiOperation({ summary: 'List admin action audit logs (paginated)' }) - async listAuditLogs( - @Query('page') page = 1, - @Query('limit') limit = 20, - @Query('action') action?: string, - @Query('adminId') adminId?: string, - @Query('resource') resource?: string, - @Query('startDate') startDate?: string, - @Query('endDate') endDate?: string, - ) { + async listAuditLogs(@Query() query: ListAuditLogsQueryDto) { const data = await this.adminComplianceService.listAuditLogs( - +page, - +limit, - action, - adminId, - resource, - startDate, - endDate, + query.page, + query.limit, + query.action, + query.adminId, + query.resource, + query.startDate, + query.endDate, ); return { code: 0, data }; } @@ -73,7 +69,7 @@ export class AdminComplianceController { @Post('reports/generate') @ApiOperation({ summary: 'Generate a compliance report' }) async generateReport( - @Body() body: { reportType: string }, + @Body() body: GenerateReportDto, @Req() req: Request, ) { const user = req.user as any; diff --git a/backend/services/compliance-service/src/interface/http/controllers/admin-dispute.controller.ts b/backend/services/compliance-service/src/interface/http/controllers/admin-dispute.controller.ts index 15c3d33..fa3a8f9 100644 --- a/backend/services/compliance-service/src/interface/http/controllers/admin-dispute.controller.ts +++ b/backend/services/compliance-service/src/interface/http/controllers/admin-dispute.controller.ts @@ -1,10 +1,15 @@ import { - Controller, Get, Post, Param, Query, Body, Req, UseGuards, + Controller, Get, Post, Param, Query, Body, Req, UseGuards, ParseUUIDPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AdminDisputeService } from '../../../application/services/admin-dispute.service'; import { Request } from 'express'; +import { + ListDisputesQueryDto, + ResolveDisputeDto, + ArbitrateDisputeDto, +} from '../dto/admin-dispute.dto'; @ApiTags('Admin - Dispute Management') @Controller('admin/disputes') @@ -16,19 +21,19 @@ export class AdminDisputeController { @Get() @ApiOperation({ summary: 'List disputes (paginated, filter by status/type)' }) - async listDisputes( - @Query('page') page = 1, - @Query('limit') limit = 20, - @Query('status') status?: string, - @Query('type') type?: string, - ) { - const data = await this.adminDisputeService.listDisputes(+page, +limit, status, type); + async listDisputes(@Query() query: ListDisputesQueryDto) { + const data = await this.adminDisputeService.listDisputes( + query.page, + query.limit, + query.status, + query.type, + ); return { code: 0, data }; } @Get(':id') @ApiOperation({ summary: 'Get dispute detail' }) - async getDisputeDetail(@Param('id') id: string) { + async getDisputeDetail(@Param('id', ParseUUIDPipe) id: string) { const data = await this.adminDisputeService.getDisputeDetail(id); return { code: 0, data }; } @@ -36,8 +41,8 @@ export class AdminDisputeController { @Post(':id/resolve') @ApiOperation({ summary: 'Resolve dispute with decision' }) async resolveDispute( - @Param('id') id: string, - @Body() body: { resolution: string; status: 'resolved' | 'rejected' }, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: ResolveDisputeDto, @Req() req: Request, ) { const user = req.user as any; @@ -55,8 +60,8 @@ export class AdminDisputeController { @Post(':id/arbitrate') @ApiOperation({ summary: 'Arbitration action on dispute' }) async arbitrate( - @Param('id') id: string, - @Body() body: { decision: string; notes?: string }, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: ArbitrateDisputeDto, @Req() req: Request, ) { const user = req.user as any; diff --git a/backend/services/compliance-service/src/interface/http/controllers/admin-insurance.controller.ts b/backend/services/compliance-service/src/interface/http/controllers/admin-insurance.controller.ts index 2d0c89b..98fdfc4 100644 --- a/backend/services/compliance-service/src/interface/http/controllers/admin-insurance.controller.ts +++ b/backend/services/compliance-service/src/interface/http/controllers/admin-insurance.controller.ts @@ -1,10 +1,11 @@ import { - Controller, Get, Post, Param, Query, Body, Req, UseGuards, + Controller, Get, Post, Param, Query, Body, Req, UseGuards, ParseUUIDPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AdminInsuranceService } from '../../../application/services/admin-insurance.service'; import { Request } from 'express'; +import { ListClaimsQueryDto, RejectClaimDto } from '../dto/admin-insurance.dto'; @ApiTags('Admin - Insurance & Consumer Protection') @Controller('admin/insurance') @@ -23,18 +24,21 @@ export class AdminInsuranceController { @Get('claims') @ApiOperation({ summary: 'List insurance claims (paginated)' }) - async listClaims( - @Query('page') page = 1, - @Query('limit') limit = 20, - @Query('status') status?: string, - ) { - const data = await this.adminInsuranceService.listClaims(+page, +limit, status); + async listClaims(@Query() query: ListClaimsQueryDto) { + const data = await this.adminInsuranceService.listClaims( + query.page, + query.limit, + query.status, + ); return { code: 0, data }; } @Post('claims/:id/approve') @ApiOperation({ summary: 'Approve an insurance claim' }) - async approveClaim(@Param('id') id: string, @Req() req: Request) { + async approveClaim( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { const user = req.user as any; const ip = req.ip || req.headers['x-forwarded-for'] as string; const data = await this.adminInsuranceService.approveClaim( @@ -49,8 +53,8 @@ export class AdminInsuranceController { @Post('claims/:id/reject') @ApiOperation({ summary: 'Reject an insurance claim' }) async rejectClaim( - @Param('id') id: string, - @Body() body: { reason?: string }, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: RejectClaimDto, @Req() req: Request, ) { const user = req.user as any; diff --git a/backend/services/compliance-service/src/interface/http/controllers/admin-risk.controller.ts b/backend/services/compliance-service/src/interface/http/controllers/admin-risk.controller.ts index c9adc7a..300ac11 100644 --- a/backend/services/compliance-service/src/interface/http/controllers/admin-risk.controller.ts +++ b/backend/services/compliance-service/src/interface/http/controllers/admin-risk.controller.ts @@ -1,10 +1,12 @@ import { - Controller, Get, Post, Param, Query, Req, UseGuards, + Controller, Get, Post, Param, Query, Req, UseGuards, ParseUUIDPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AdminRiskService } from '../../../application/services/admin-risk.service'; import { Request } from 'express'; +import { ListRiskAlertsQueryDto } from '../dto/admin-risk.dto'; +import { PaginationQueryDto } from '../dto/pagination.dto'; @ApiTags('Admin - Risk Center') @Controller('admin/risk') @@ -23,39 +25,36 @@ export class AdminRiskController { @Get('alerts') @ApiOperation({ summary: 'List active risk/AML alerts (paginated)' }) - async listAlerts( - @Query('page') page = 1, - @Query('limit') limit = 20, - @Query('status') status?: string, - @Query('pattern') pattern?: string, - ) { - const data = await this.adminRiskService.listAlerts(+page, +limit, status, pattern); + async listAlerts(@Query() query: ListRiskAlertsQueryDto) { + const data = await this.adminRiskService.listAlerts( + query.page, + query.limit, + query.status, + query.pattern, + ); return { code: 0, data }; } @Get('suspicious-trades') @ApiOperation({ summary: 'List flagged suspicious transactions' }) - async listSuspiciousTrades( - @Query('page') page = 1, - @Query('limit') limit = 20, - ) { - const data = await this.adminRiskService.listSuspiciousTrades(+page, +limit); + async listSuspiciousTrades(@Query() query: PaginationQueryDto) { + const data = await this.adminRiskService.listSuspiciousTrades(query.page, query.limit); return { code: 0, data }; } @Get('blacklist') @ApiOperation({ summary: 'List blacklisted users (OFAC matches)' }) - async listBlacklist( - @Query('page') page = 1, - @Query('limit') limit = 20, - ) { - const data = await this.adminRiskService.listBlacklist(+page, +limit); + async listBlacklist(@Query() query: PaginationQueryDto) { + const data = await this.adminRiskService.listBlacklist(query.page, query.limit); return { code: 0, data }; } @Post('suspicious/:id/freeze') @ApiOperation({ summary: 'Freeze account associated with suspicious alert' }) - async freezeAccount(@Param('id') id: string, @Req() req: Request) { + async freezeAccount( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { const user = req.user as any; const ip = req.ip || req.headers['x-forwarded-for'] as string; const data = await this.adminRiskService.freezeAccount( @@ -69,7 +68,10 @@ export class AdminRiskController { @Post('suspicious/:id/sar') @ApiOperation({ summary: 'Generate SAR from suspicious alert' }) - async generateSar(@Param('id') id: string, @Req() req: Request) { + async generateSar( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { const user = req.user as any; const ip = req.ip || req.headers['x-forwarded-for'] as string; const data = await this.adminRiskService.generateSar( diff --git a/backend/services/compliance-service/src/interface/http/controllers/compliance.controller.ts b/backend/services/compliance-service/src/interface/http/controllers/compliance.controller.ts index 0c1c512..349778f 100644 --- a/backend/services/compliance-service/src/interface/http/controllers/compliance.controller.ts +++ b/backend/services/compliance-service/src/interface/http/controllers/compliance.controller.ts @@ -1,10 +1,17 @@ -import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Put, Body, Param, Query, UseGuards, ParseUUIDPipe } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { AmlService } from '../../../application/services/aml.service'; import { OfacService } from '../../../application/services/ofac.service'; import { TravelRuleService } from '../../../application/services/travel-rule.service'; import { SarService } from '../../../application/services/sar.service'; +import { + ListAmlAlertsQueryDto, + UpdateAlertStatusDto, + ScreenOfacDto, + CreateSarReportDto, +} from '../dto/compliance.dto'; +import { PaginationQueryDto } from '../dto/pagination.dto'; @ApiTags('Compliance') @Controller('compliance') @@ -20,19 +27,15 @@ export class ComplianceController { @Get('aml/alerts') @ApiOperation({ summary: 'List AML alerts' }) - async listAlerts( - @Query('page') page = '1', - @Query('limit') limit = '20', - @Query('status') status?: string, - ) { - return { code: 0, data: await this.amlService.listAlerts(+page, +limit, status) }; + async listAlerts(@Query() query: ListAmlAlertsQueryDto) { + return { code: 0, data: await this.amlService.listAlerts(query.page, query.limit, query.status) }; } @Put('aml/alerts/:id/status') @ApiOperation({ summary: 'Update AML alert status' }) async updateAlert( - @Param('id') id: string, - @Body() body: { status: string; resolution?: string }, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: UpdateAlertStatusDto, ) { await this.amlService.updateAlertStatus(id, body.status as any, body.resolution); return { code: 0, data: null }; @@ -40,30 +43,25 @@ export class ComplianceController { @Post('ofac/screen') @ApiOperation({ summary: 'Screen name against OFAC' }) - async screenOfac(@Body() body: { userId: string; name: string }) { + async screenOfac(@Body() body: ScreenOfacDto) { return { code: 0, data: await this.ofacService.screenName(body.userId, body.name) }; } @Get('sar/reports') @ApiOperation({ summary: 'List SAR reports' }) - async listSarReports( - @Query('page') page = '1', - @Query('limit') limit = '20', - ) { - return { code: 0, data: await this.sarService.listReports(+page, +limit) }; + async listSarReports(@Query() query: PaginationQueryDto) { + return { code: 0, data: await this.sarService.listReports(query.page, query.limit) }; } @Post('sar/reports') @ApiOperation({ summary: 'Create SAR report' }) - async createSarReport( - @Body() body: { alertId: string; userId: string; reportType: string; narrative: string }, - ) { + async createSarReport(@Body() body: CreateSarReportDto) { return { code: 0, data: await this.sarService.createReport(body) }; } @Put('sar/reports/:id/file') @ApiOperation({ summary: 'File SAR report with FinCEN' }) - async fileSar(@Param('id') id: string) { + async fileSar(@Param('id', ParseUUIDPipe) id: string) { await this.sarService.fileReport(id); return { code: 0, data: null, message: 'Report filed' }; } diff --git a/backend/services/compliance-service/src/interface/http/dto/admin-compliance.dto.ts b/backend/services/compliance-service/src/interface/http/dto/admin-compliance.dto.ts new file mode 100644 index 0000000..c3ef9db --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/admin-compliance.dto.ts @@ -0,0 +1,74 @@ +import { IsString, IsOptional, IsUUID, IsNotEmpty, IsDateString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginationQueryDto } from './pagination.dto'; + +// ───────────── Admin SAR DTOs ───────────── + +export class ListSarQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by filing status (draft, filed, etc.)' }) + @IsOptional() + @IsString() + status?: string; +} + +export class AdminCreateSarReportDto { + @ApiProperty({ description: 'Related AML alert ID' }) + @IsUUID() + alertId: string; + + @ApiProperty({ description: 'User ID associated with the report' }) + @IsUUID() + userId: string; + + @ApiProperty({ description: 'Type of SAR report' }) + @IsString() + @IsNotEmpty() + reportType: string; + + @ApiProperty({ description: 'Narrative description of suspicious activity' }) + @IsString() + @IsNotEmpty() + narrative: string; +} + +// ───────────── Audit Log DTOs ───────────── + +export class ListAuditLogsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by action type' }) + @IsOptional() + @IsString() + action?: string; + + @ApiPropertyOptional({ description: 'Filter by admin ID' }) + @IsOptional() + @IsUUID() + adminId?: string; + + @ApiPropertyOptional({ description: 'Filter by resource type' }) + @IsOptional() + @IsString() + resource?: string; + + @ApiPropertyOptional({ description: 'Filter by start date (ISO 8601)' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: 'Filter by end date (ISO 8601)' }) + @IsOptional() + @IsDateString() + endDate?: string; +} + +// ───────────── Report Generation DTOs ───────────── + +export class GenerateReportDto { + @ApiProperty({ + description: 'Report type to generate', + enum: ['aml', 'sar', 'travel_rule'], + example: 'aml', + }) + @IsString() + @IsNotEmpty() + reportType: string; +} diff --git a/backend/services/compliance-service/src/interface/http/dto/admin-dispute.dto.ts b/backend/services/compliance-service/src/interface/http/dto/admin-dispute.dto.ts new file mode 100644 index 0000000..18aa800 --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/admin-dispute.dto.ts @@ -0,0 +1,50 @@ +import { IsString, IsOptional, IsEnum, IsNotEmpty } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DisputeType, DisputeStatus } from '../../../domain/entities/dispute.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +// ───────────── List Disputes ───────────── + +export class ListDisputesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by dispute status', enum: DisputeStatus }) + @IsOptional() + @IsEnum(DisputeStatus) + status?: string; + + @ApiPropertyOptional({ description: 'Filter by dispute type', enum: DisputeType }) + @IsOptional() + @IsEnum(DisputeType) + type?: string; +} + +// ───────────── Resolve Dispute ───────────── + +export class ResolveDisputeDto { + @ApiProperty({ description: 'Resolution description' }) + @IsString() + @IsNotEmpty() + resolution: string; + + @ApiProperty({ + description: 'Resolution status', + enum: ['resolved', 'rejected'], + }) + @IsEnum(['resolved', 'rejected'] as const, { + message: 'status must be either "resolved" or "rejected"', + }) + status: 'resolved' | 'rejected'; +} + +// ───────────── Arbitrate Dispute ───────────── + +export class ArbitrateDisputeDto { + @ApiProperty({ description: 'Arbitration decision' }) + @IsString() + @IsNotEmpty() + decision: string; + + @ApiPropertyOptional({ description: 'Additional notes' }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/services/compliance-service/src/interface/http/dto/admin-insurance.dto.ts b/backend/services/compliance-service/src/interface/http/dto/admin-insurance.dto.ts new file mode 100644 index 0000000..e1e216a --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/admin-insurance.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsOptional, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ClaimStatus } from '../../../domain/entities/insurance-claim.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +// ───────────── List Claims ───────────── + +export class ListClaimsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by claim status', enum: ClaimStatus }) + @IsOptional() + @IsEnum(ClaimStatus) + status?: string; +} + +// ───────────── Reject Claim ───────────── + +export class RejectClaimDto { + @ApiPropertyOptional({ description: 'Reason for rejection' }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/backend/services/compliance-service/src/interface/http/dto/admin-risk.dto.ts b/backend/services/compliance-service/src/interface/http/dto/admin-risk.dto.ts new file mode 100644 index 0000000..2dd2d67 --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/admin-risk.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { AlertStatus, AmlPattern } from '../../../domain/entities/aml-alert.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +// ───────────── List Alerts ───────────── + +export class ListRiskAlertsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by alert status', enum: AlertStatus }) + @IsOptional() + @IsEnum(AlertStatus) + status?: string; + + @ApiPropertyOptional({ description: 'Filter by AML pattern', enum: AmlPattern }) + @IsOptional() + @IsEnum(AmlPattern) + pattern?: string; +} diff --git a/backend/services/compliance-service/src/interface/http/dto/compliance.dto.ts b/backend/services/compliance-service/src/interface/http/dto/compliance.dto.ts new file mode 100644 index 0000000..01eee25 --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/compliance.dto.ts @@ -0,0 +1,66 @@ +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsNotEmpty, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AlertStatus } from '../../../domain/entities/aml-alert.entity'; +import { PaginationQueryDto } from './pagination.dto'; + +// ───────────── AML Alert DTOs ───────────── + +export class ListAmlAlertsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by alert status', enum: AlertStatus }) + @IsOptional() + @IsEnum(AlertStatus) + status?: string; +} + +export class UpdateAlertStatusDto { + @ApiProperty({ description: 'New alert status', enum: AlertStatus }) + @IsNotEmpty() + @IsEnum(AlertStatus) + status: string; + + @ApiPropertyOptional({ description: 'Resolution notes' }) + @IsOptional() + @IsString() + resolution?: string; +} + +// ───────────── OFAC Screening DTOs ───────────── + +export class ScreenOfacDto { + @ApiProperty({ description: 'User ID to screen' }) + @IsUUID() + userId: string; + + @ApiProperty({ description: 'Name to screen against OFAC SDN list' }) + @IsString() + @IsNotEmpty() + name: string; +} + +// ───────────── SAR Report DTOs ───────────── + +export class CreateSarReportDto { + @ApiProperty({ description: 'Related AML alert ID' }) + @IsUUID() + alertId: string; + + @ApiProperty({ description: 'User ID associated with the report' }) + @IsUUID() + userId: string; + + @ApiProperty({ description: 'Type of SAR report' }) + @IsString() + @IsNotEmpty() + reportType: string; + + @ApiProperty({ description: 'Narrative description of suspicious activity' }) + @IsString() + @IsNotEmpty() + narrative: string; +} diff --git a/backend/services/compliance-service/src/interface/http/dto/index.ts b/backend/services/compliance-service/src/interface/http/dto/index.ts new file mode 100644 index 0000000..a38f353 --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/index.ts @@ -0,0 +1,6 @@ +export * from './pagination.dto'; +export * from './compliance.dto'; +export * from './admin-compliance.dto'; +export * from './admin-dispute.dto'; +export * from './admin-insurance.dto'; +export * from './admin-risk.dto'; diff --git a/backend/services/compliance-service/src/interface/http/dto/pagination.dto.ts b/backend/services/compliance-service/src/interface/http/dto/pagination.dto.ts new file mode 100644 index 0000000..3addfa2 --- /dev/null +++ b/backend/services/compliance-service/src/interface/http/dto/pagination.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ description: 'Page number (1-based)', default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts b/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts index 044324e..e26a651 100644 --- a/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts +++ b/backend/services/issuer-service/src/application/services/admin-coupon-analytics.service.ts @@ -1,8 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; -import { Issuer } from '../../domain/entities/issuer.entity'; +import { Injectable, Inject } from '@nestjs/common'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; +import { CouponStatus } from '../../domain/entities/coupon.entity'; export interface CouponStats { totalCoupons: number; @@ -37,7 +36,7 @@ export interface LifecyclePipeline { soldOut: number; expired: number; totalSold: number; - totalRedeemed: number; // estimated from sold - remaining patterns + totalRedeemed: number; } export interface RedemptionRateTrend { @@ -56,48 +55,33 @@ export interface DiscountDistribution { @Injectable() export class AdminCouponAnalyticsService { constructor( - @InjectRepository(Coupon) private readonly couponRepo: Repository, - @InjectRepository(Issuer) private readonly issuerRepo: Repository, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, ) {} /** * Aggregate coupon supply, sold, remaining, and circulation metrics. */ async getCouponStats(): Promise { - const result = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'COUNT(c.id) as "totalCoupons"', - 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - 'COALESCE(SUM(c.remaining_supply), 0) as "totalRemaining"', - ]) - .getRawOne(); - - const totalSupply = Number(result.totalSupply) || 0; - const totalSold = Number(result.totalSold) || 0; - const circulationRate = totalSupply > 0 - ? Math.round((totalSold / totalSupply) * 10000) / 100 - : 0; - - // Count by status - const statusCounts = await this.couponRepo - .createQueryBuilder('c') - .select('c.status', 'status') - .addSelect('COUNT(c.id)', 'count') - .groupBy('c.status') - .getRawMany(); + const aggregate = await this.couponRepo.getAggregateStats(); + const circulationRate = + aggregate.totalSupply > 0 + ? Math.round((aggregate.totalSold / aggregate.totalSupply) * 10000) / 100 + : 0; + const statusCounts = await this.couponRepo.getStatusCounts(); const byStatus: Record = {}; - statusCounts.forEach(row => { - byStatus[row.status] = Number(row.count); + statusCounts.forEach((row) => { + byStatus[row.status] = row.count; }); return { - totalCoupons: Number(result.totalCoupons) || 0, - totalSupply, - totalSold, - totalRemaining: Number(result.totalRemaining) || 0, + totalCoupons: aggregate.totalCoupons, + totalSupply: aggregate.totalSupply, + totalSold: aggregate.totalSold, + totalRemaining: aggregate.totalRemaining, circulationRate, byStatus, }; @@ -107,36 +91,18 @@ export class AdminCouponAnalyticsService { * Distribution of coupons grouped by issuer. */ async getCouponsByIssuer(): Promise { - const raw = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'c.issuer_id as "issuerId"', - 'COUNT(c.id) as "couponCount"', - 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - 'COALESCE(SUM(CAST(c.face_value AS numeric) * c.total_supply), 0) as "totalFaceValue"', - ]) - .groupBy('c.issuer_id') - .orderBy('"couponCount"', 'DESC') - .getRawMany(); + const raw = await this.couponRepo.getCouponsByIssuer(); + const issuerIds = raw.map((r) => r.issuerId); + const issuers = await this.issuerRepo.findByIds(issuerIds); + const issuerMap = new Map(issuers.map((i) => [i.id, i.companyName])); - // Enrich with issuer names - const issuerIds = raw.map(r => r.issuerId); - const issuers = issuerIds.length > 0 - ? await this.issuerRepo - .createQueryBuilder('i') - .where('i.id IN (:...ids)', { ids: issuerIds }) - .getMany() - : []; - const issuerMap = new Map(issuers.map(i => [i.id, i.companyName])); - - return raw.map(row => ({ + return raw.map((row) => ({ issuerId: row.issuerId, companyName: issuerMap.get(row.issuerId) || 'Unknown', - couponCount: Number(row.couponCount), - totalSupply: Number(row.totalSupply), - totalSold: Number(row.totalSold), - totalFaceValue: Number(row.totalFaceValue), + couponCount: row.couponCount, + totalSupply: row.totalSupply, + totalSold: row.totalSold, + totalFaceValue: row.totalFaceValue, })); } @@ -144,61 +110,24 @@ export class AdminCouponAnalyticsService { * Distribution of coupons grouped by category. */ async getCouponsByCategory(): Promise { - const raw = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'c.category as "category"', - 'COUNT(c.id) as "couponCount"', - 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - 'COALESCE(AVG(CAST(c.price AS numeric)), 0) as "avgPrice"', - ]) - .groupBy('c.category') - .orderBy('"couponCount"', 'DESC') - .getRawMany(); - - return raw.map(row => ({ - category: row.category, - couponCount: Number(row.couponCount), - totalSupply: Number(row.totalSupply), - totalSold: Number(row.totalSold), - avgPrice: Math.round(Number(row.avgPrice) * 100) / 100, - })); + return this.couponRepo.getCouponsByCategory(); } /** * Lifecycle pipeline: counts at each status stage. */ async getLifecycle(): Promise { - const statusCounts = await this.couponRepo - .createQueryBuilder('c') - .select('c.status', 'status') - .addSelect('COUNT(c.id)', 'count') - .groupBy('c.status') - .getRawMany(); - + const statusCounts = await this.couponRepo.getStatusCounts(); const countMap: Record = {}; - statusCounts.forEach(row => { - countMap[row.status] = Number(row.count); + statusCounts.forEach((row) => { + countMap[row.status] = row.count; }); - // Aggregate sold = totalSupply - remainingSupply across all coupons - const soldResult = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - ]) - .getRawOne(); - - // For redeemed, we estimate based on expired + sold_out coupons' sold amounts - // In a real system, this would come from a redemption/transaction service - const redeemedResult = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalRedeemed"', - ]) - .where('c.status IN (:...statuses)', { statuses: [CouponStatus.EXPIRED, CouponStatus.SOLD_OUT] }) - .getRawOne(); + const totalSold = await this.couponRepo.getTotalSold(); + const totalRedeemed = await this.couponRepo.getTotalSoldByStatuses([ + CouponStatus.EXPIRED, + CouponStatus.SOLD_OUT, + ]); return { draft: countMap[CouponStatus.DRAFT] || 0, @@ -206,75 +135,31 @@ export class AdminCouponAnalyticsService { paused: countMap[CouponStatus.PAUSED] || 0, soldOut: countMap[CouponStatus.SOLD_OUT] || 0, expired: countMap[CouponStatus.EXPIRED] || 0, - totalSold: Number(soldResult.totalSold) || 0, - totalRedeemed: Number(redeemedResult.totalRedeemed) || 0, + totalSold, + totalRedeemed, }; } /** * Monthly redemption rate trend. - * Groups coupons by their creation month and calculates sold/supply ratio. */ async getRedemptionRate(): Promise { - const raw = await this.couponRepo - .createQueryBuilder('c') - .select([ - "TO_CHAR(c.created_at, 'YYYY-MM') as \"month\"", - 'COALESCE(SUM(c.total_supply), 0) as "totalIssued"', - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - ]) - .groupBy("TO_CHAR(c.created_at, 'YYYY-MM')") - .orderBy('"month"', 'ASC') - .getRawMany(); - - return raw.map(row => { - const totalIssued = Number(row.totalIssued) || 0; - const totalSold = Number(row.totalSold) || 0; - return { - month: row.month, - totalIssued, - totalSold, - redemptionRate: totalIssued > 0 - ? Math.round((totalSold / totalIssued) * 10000) / 100 + const raw = await this.couponRepo.getRedemptionRateTrend(); + return raw.map((row) => ({ + month: row.month, + totalIssued: row.totalIssued, + totalSold: row.totalSold, + redemptionRate: + row.totalIssued > 0 + ? Math.round((row.totalSold / row.totalIssued) * 10000) / 100 : 0, - }; - }); + })); } /** - * Distribution of secondary market discounts (price vs face value). - * Groups coupons into discount ranges. + * Secondary market discount distribution (price vs face value). */ async getDiscountDistribution(): Promise { - // Calculate discount percentage = (1 - price/faceValue) * 100 - const raw = await this.couponRepo - .createQueryBuilder('c') - .select([ - `CASE - WHEN CAST(c.face_value AS numeric) = 0 THEN 'N/A' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%' - WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%' - ELSE '50%+' - END as "range"`, - 'COUNT(c.id) as "count"', - `COALESCE(AVG( - CASE WHEN CAST(c.face_value AS numeric) > 0 - THEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 - ELSE 0 - END - ), 0) as "avgDiscount"`, - ]) - .groupBy('"range"') - .orderBy('"range"', 'ASC') - .getRawMany(); - - return raw.map(row => ({ - range: row.range, - count: Number(row.count), - avgDiscount: Math.round(Number(row.avgDiscount) * 100) / 100, - })); + return this.couponRepo.getDiscountDistribution(); } } diff --git a/backend/services/issuer-service/src/application/services/admin-coupon.service.ts b/backend/services/issuer-service/src/application/services/admin-coupon.service.ts index 303822b..b53fdc5 100644 --- a/backend/services/issuer-service/src/application/services/admin-coupon.service.ts +++ b/backend/services/issuer-service/src/application/services/admin-coupon.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { COUPON_RULE_REPOSITORY, ICouponRuleRepository } from '../../domain/repositories/coupon-rule.repository.interface'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; import { CouponRule } from '../../domain/entities/coupon-rule.entity'; import { Issuer } from '../../domain/entities/issuer.entity'; @@ -30,9 +31,12 @@ export interface CouponDetailResult { @Injectable() export class AdminCouponService { constructor( - @InjectRepository(Coupon) private readonly couponRepo: Repository, - @InjectRepository(CouponRule) private readonly ruleRepo: Repository, - @InjectRepository(Issuer) private readonly issuerRepo: Repository, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(COUPON_RULE_REPOSITORY) + private readonly ruleRepo: ICouponRuleRepository, + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, ) {} /** @@ -42,44 +46,21 @@ export class AdminCouponService { const page = filters.page || 1; const limit = filters.limit || 20; - const qb = this.couponRepo.createQueryBuilder('c'); - - // Left join to get issuer company name - qb.leftJoinAndMapOne('c.issuer', Issuer, 'i', 'i.id = c.issuer_id'); - - if (filters.status) { - qb.andWhere('c.status = :status', { status: filters.status }); - } - if (filters.issuerId) { - qb.andWhere('c.issuer_id = :issuerId', { issuerId: filters.issuerId }); - } - if (filters.category) { - qb.andWhere('c.category = :category', { category: filters.category }); - } - if (filters.search) { - qb.andWhere( - '(c.name ILIKE :search OR c.description ILIKE :search)', - { search: `%${filters.search}%` }, - ); - } - - qb.orderBy('c.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.couponRepo.findAndCountWithIssuerJoin({ + status: filters.status, + issuerId: filters.issuerId, + category: filters.category, + search: filters.search, + page, + limit, + }); // Enrich each coupon with issuer name - const issuerIds = [...new Set(items.map(c => c.issuerId))]; - const issuers = issuerIds.length > 0 - ? await this.issuerRepo - .createQueryBuilder('i') - .where('i.id IN (:...ids)', { ids: issuerIds }) - .getMany() - : []; - const issuerMap = new Map(issuers.map(i => [i.id, i])); + const issuerIds = [...new Set(items.map((c) => c.issuerId))]; + const issuers = await this.issuerRepo.findByIds(issuerIds); + const issuerMap = new Map(issuers.map((i) => [i.id, i])); - const enrichedItems = items.map(coupon => ({ + const enrichedItems = items.map((coupon) => ({ ...coupon, issuerName: issuerMap.get(coupon.issuerId)?.companyName || null, })); @@ -97,16 +78,17 @@ export class AdminCouponService { * Get full coupon detail with rules, issuer info, and metrics. */ async getCouponDetail(id: string): Promise { - const coupon = await this.couponRepo.findOne({ where: { id } }); + const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); - const rules = await this.ruleRepo.find({ where: { couponId: id } }); - const issuer = await this.issuerRepo.findOne({ where: { id: coupon.issuerId } }); + const rules = await this.ruleRepo.findByCouponId(id); + const issuer = await this.issuerRepo.findById(coupon.issuerId); const soldCount = coupon.totalSupply - coupon.remainingSupply; - const soldPercentage = coupon.totalSupply > 0 - ? Math.round((soldCount / coupon.totalSupply) * 10000) / 100 - : 0; + const soldPercentage = + coupon.totalSupply > 0 + ? Math.round((soldCount / coupon.totalSupply) * 10000) / 100 + : 0; return { coupon, @@ -126,7 +108,7 @@ export class AdminCouponService { * Approve a coupon: set status from DRAFT to ACTIVE. */ async approveCoupon(id: string): Promise { - const coupon = await this.couponRepo.findOne({ where: { id } }); + const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); if (coupon.status !== CouponStatus.DRAFT) { @@ -140,12 +122,10 @@ export class AdminCouponService { } /** - * Reject a coupon: set status back to DRAFT with notation. - * Since the entity doesn't have a REJECTED status, we set it to EXPIRED - * and add rejection reason to the terms JSON. + * Reject a coupon: set status to EXPIRED and store rejection reason. */ async rejectCoupon(id: string, reason: string): Promise { - const coupon = await this.couponRepo.findOne({ where: { id } }); + const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); if (coupon.status !== CouponStatus.DRAFT) { @@ -154,7 +134,6 @@ export class AdminCouponService { ); } - // Store rejection info in the terms JSONB field coupon.terms = { ...(coupon.terms || {}), _rejection: { @@ -170,7 +149,7 @@ export class AdminCouponService { * Suspend an active coupon: set status to PAUSED. */ async suspendCoupon(id: string): Promise { - const coupon = await this.couponRepo.findOne({ where: { id } }); + const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); if (coupon.status !== CouponStatus.ACTIVE) { diff --git a/backend/services/issuer-service/src/application/services/admin-issuer.service.ts b/backend/services/issuer-service/src/application/services/admin-issuer.service.ts index 1ea0add..bd76b47 100644 --- a/backend/services/issuer-service/src/application/services/admin-issuer.service.ts +++ b/backend/services/issuer-service/src/application/services/admin-issuer.service.ts @@ -1,9 +1,10 @@ -import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, ILike } from 'typeorm'; +import { Injectable, Inject, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; +import { STORE_REPOSITORY, IStoreRepository } from '../../domain/repositories/store.repository.interface'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { CREDIT_METRIC_REPOSITORY, ICreditMetricRepository } from '../../domain/repositories/credit-metric.repository.interface'; +import { AI_SERVICE_CLIENT, IAiServiceClient } from '../../domain/ports/ai-service.client.interface'; import { Issuer, IssuerStatus } from '../../domain/entities/issuer.entity'; -import { Store } from '../../domain/entities/store.entity'; -import { Coupon } from '../../domain/entities/coupon.entity'; import { CreditMetric } from '../../domain/entities/credit-metric.entity'; export interface ListIssuersFilters { @@ -30,16 +31,19 @@ export interface AiPreReviewResult { @Injectable() export class AdminIssuerService { private readonly logger = new Logger('AdminIssuerService'); - private readonly aiServiceUrl: string; constructor( - @InjectRepository(Issuer) private readonly issuerRepo: Repository, - @InjectRepository(Store) private readonly storeRepo: Repository, - @InjectRepository(Coupon) private readonly couponRepo: Repository, - @InjectRepository(CreditMetric) private readonly creditMetricRepo: Repository, - ) { - this.aiServiceUrl = process.env.AI_SERVICE_URL || 'http://localhost:3006'; - } + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, + @Inject(STORE_REPOSITORY) + private readonly storeRepo: IStoreRepository, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(CREDIT_METRIC_REPOSITORY) + private readonly creditMetricRepo: ICreditMetricRepository, + @Inject(AI_SERVICE_CLIENT) + private readonly aiServiceClient: IAiServiceClient, + ) {} /** * List issuers with pagination, ILIKE search, and status filter. @@ -48,24 +52,12 @@ export class AdminIssuerService { const page = filters.page || 1; const limit = filters.limit || 20; - const qb = this.issuerRepo.createQueryBuilder('i'); - - if (filters.status) { - qb.andWhere('i.status = :status', { status: filters.status }); - } - - if (filters.search) { - qb.andWhere( - '(i.company_name ILIKE :search OR i.contact_name ILIKE :search OR i.contact_email ILIKE :search)', - { search: `%${filters.search}%` }, - ); - } - - qb.orderBy('i.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const [items, total] = await this.issuerRepo.findAndCount({ + status: filters.status, + search: filters.search, + page, + limit, + }); return { items, @@ -80,16 +72,12 @@ export class AdminIssuerService { * Get issuer detail including credit metrics, store count, and coupon count. */ async getIssuerDetail(id: string): Promise { - const issuer = await this.issuerRepo.findOne({ where: { id } }); + const issuer = await this.issuerRepo.findById(id); if (!issuer) throw new NotFoundException('Issuer not found'); - const creditMetric = await this.creditMetricRepo.findOne({ - where: { issuerId: id }, - order: { calculatedAt: 'DESC' }, - }); - - const storesCount = await this.storeRepo.count({ where: { issuerId: id } }); - const couponsCount = await this.couponRepo.count({ where: { issuerId: id } }); + const creditMetric = await this.creditMetricRepo.findLatestByIssuerId(id); + const storesCount = await this.storeRepo.count({ issuerId: id }); + const couponsCount = await this.couponRepo.count({ issuerId: id }); return { issuer, creditMetric, storesCount, couponsCount }; } @@ -98,7 +86,7 @@ export class AdminIssuerService { * Approve an issuer: set status from PENDING to ACTIVE. */ async approveIssuer(id: string): Promise { - const issuer = await this.issuerRepo.findOne({ where: { id } }); + const issuer = await this.issuerRepo.findById(id); if (!issuer) throw new NotFoundException('Issuer not found'); if (issuer.status !== IssuerStatus.PENDING) { @@ -115,7 +103,7 @@ export class AdminIssuerService { * Reject an issuer: set status from PENDING to REJECTED with reason. */ async rejectIssuer(id: string, reason: string): Promise { - const issuer = await this.issuerRepo.findOne({ where: { id } }); + const issuer = await this.issuerRepo.findById(id); if (!issuer) throw new NotFoundException('Issuer not found'); if (issuer.status !== IssuerStatus.PENDING) { @@ -125,8 +113,6 @@ export class AdminIssuerService { } issuer.status = IssuerStatus.REJECTED; - // Note: reason is stored in description field or could be logged/event-sourced - // Update the description with the rejection reason issuer.description = issuer.description ? `${issuer.description}\n[REJECTED] ${reason}` : `[REJECTED] ${reason}`; @@ -139,68 +125,42 @@ export class AdminIssuerService { * Falls back to a simple rule-based recommendation if AI service is unavailable. */ async getAiPreReview(): Promise { - const pendingIssuers = await this.issuerRepo.find({ - where: { status: IssuerStatus.PENDING }, - order: { createdAt: 'ASC' }, - take: 50, - }); + const pendingIssuers = await this.issuerRepo.findPending(50); if (pendingIssuers.length === 0) { return []; } - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); + // Delegate to infrastructure AI service client + const aiReviews = await this.aiServiceClient.reviewIssuers( + pendingIssuers.map((i) => ({ + id: i.id, + companyName: i.companyName, + businessLicense: i.businessLicense, + contactName: i.contactName, + contactPhone: i.contactPhone, + contactEmail: i.contactEmail, + description: i.description, + creditScore: i.creditScore, + createdAt: i.createdAt, + })), + ); - const response = await fetch(`${this.aiServiceUrl}/api/v1/admin/issuer-review`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - issuers: pendingIssuers.map(i => ({ - id: i.id, - companyName: i.companyName, - businessLicense: i.businessLicense, - contactName: i.contactName, - contactPhone: i.contactPhone, - contactEmail: i.contactEmail, - description: i.description, - creditScore: i.creditScore, - createdAt: i.createdAt, - })), - }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (response.ok) { - const aiResults = await response.json() as { - reviews: Array<{ - issuerId: string; - recommendation: string; - riskLevel: string; - reasons: string[]; - }>; + if (aiReviews) { + return pendingIssuers.map((issuer) => { + const aiReview = aiReviews.find((r) => r.issuerId === issuer.id); + return { + issuer, + recommendation: aiReview?.recommendation || 'manual_review', + riskLevel: aiReview?.riskLevel || 'unknown', + reasons: aiReview?.reasons || ['AI review data not available'], }; - return pendingIssuers.map(issuer => { - const aiReview = aiResults.reviews?.find(r => r.issuerId === issuer.id); - return { - issuer, - recommendation: aiReview?.recommendation || 'manual_review', - riskLevel: aiReview?.riskLevel || 'unknown', - reasons: aiReview?.reasons || ['AI review data not available'], - }; - }); - } - - this.logger.warn(`AI service returned status ${response.status}, falling back to rule-based review`); - } catch (error) { - this.logger.warn(`AI service unavailable: ${error.message}. Falling back to rule-based review.`); + }); } // Fallback: simple rule-based pre-review - return pendingIssuers.map(issuer => { + this.logger.warn('AI service unavailable, falling back to rule-based review'); + return pendingIssuers.map((issuer) => { const reasons: string[] = []; let riskLevel = 'low'; @@ -221,7 +181,11 @@ export class AdminIssuerService { } const recommendation = - riskLevel === 'high' ? 'reject' : riskLevel === 'medium' ? 'manual_review' : 'approve'; + riskLevel === 'high' + ? 'reject' + : riskLevel === 'medium' + ? 'manual_review' + : 'approve'; if (reasons.length === 0) { reasons.push('All basic checks passed'); diff --git a/backend/services/issuer-service/src/application/services/admin-merchant.service.ts b/backend/services/issuer-service/src/application/services/admin-merchant.service.ts index 0ffa904..48c68b2 100644 --- a/backend/services/issuer-service/src/application/services/admin-merchant.service.ts +++ b/backend/services/issuer-service/src/application/services/admin-merchant.service.ts @@ -1,9 +1,8 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common'; +import { STORE_REPOSITORY, IStoreRepository } from '../../domain/repositories/store.repository.interface'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; import { Store } from '../../domain/entities/store.entity'; -import { Coupon } from '../../domain/entities/coupon.entity'; -import { Issuer } from '../../domain/entities/issuer.entity'; export interface MerchantRedemptionStats { totalStores: number; @@ -21,7 +20,6 @@ export interface StoreRanking { issuerId: string; address: string | null; status: string; - /** Estimated redemption count - based on coupons sold by the issuer owning this store */ estimatedRedemptions: number; } @@ -40,9 +38,12 @@ export class AdminMerchantService { private readonly logger = new Logger('AdminMerchantService'); constructor( - @InjectRepository(Store) private readonly storeRepo: Repository, - @InjectRepository(Coupon) private readonly couponRepo: Repository, - @InjectRepository(Issuer) private readonly issuerRepo: Repository, + @Inject(STORE_REPOSITORY) + private readonly storeRepo: IStoreRepository, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, ) {} /** @@ -50,19 +51,12 @@ export class AdminMerchantService { */ async getRedemptionStats(): Promise { const totalStores = await this.storeRepo.count(); - const activeStores = await this.storeRepo.count({ where: { status: 'active' } }); - const flaggedStores = await this.storeRepo.count({ where: { status: 'flagged' } }); + const activeStores = await this.storeRepo.count({ status: 'active' }); + const flaggedStores = await this.storeRepo.count({ status: 'flagged' }); - const couponAgg = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'COALESCE(SUM(c.total_supply), 0) as "totalCouponsIssued"', - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalCouponsSold"', - ]) - .getRawOne(); - - const totalIssued = Number(couponAgg.totalCouponsIssued) || 0; - const totalSold = Number(couponAgg.totalCouponsSold) || 0; + const couponAgg = await this.couponRepo.getCouponSupplyAggregate(); + const totalIssued = couponAgg.totalCouponsIssued; + const totalSold = couponAgg.totalCouponsSold; return { totalStores, @@ -70,50 +64,32 @@ export class AdminMerchantService { flaggedStores, totalCouponsIssued: totalIssued, totalCouponsSold: totalSold, - overallRedemptionRate: totalIssued > 0 - ? Math.round((totalSold / totalIssued) * 10000) / 100 - : 0, + overallRedemptionRate: + totalIssued > 0 + ? Math.round((totalSold / totalIssued) * 10000) / 100 + : 0, }; } /** * Rank stores by estimated redemption volume. - * Since we don't have a direct redemption table, we estimate based on - * the coupons sold by the issuer that owns each store. */ async getStoreRanking(limit: number = 20): Promise { - // Get all stores with their issuers - const stores = await this.storeRepo - .createQueryBuilder('s') - .orderBy('s.created_at', 'DESC') - .take(limit * 2) // fetch extra to filter - .getMany(); - + const stores = await this.storeRepo.findTopStores(limit); if (stores.length === 0) return []; - const issuerIds = [...new Set(stores.map(s => s.issuerId))]; + const issuerIds = [...new Set(stores.map((s) => s.issuerId))]; // Get issuer names - const issuers = await this.issuerRepo - .createQueryBuilder('i') - .where('i.id IN (:...ids)', { ids: issuerIds }) - .getMany(); - const issuerMap = new Map(issuers.map(i => [i.id, i.companyName])); + const issuers = await this.issuerRepo.findByIds(issuerIds); + const issuerMap = new Map(issuers.map((i) => [i.id, i.companyName])); // Get total sold per issuer - const soldPerIssuer = await this.couponRepo - .createQueryBuilder('c') - .select([ - 'c.issuer_id as "issuerId"', - 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', - ]) - .where('c.issuer_id IN (:...ids)', { ids: issuerIds }) - .groupBy('c.issuer_id') - .getRawMany(); - const soldMap = new Map(soldPerIssuer.map(r => [r.issuerId, Number(r.totalSold)])); + const soldPerIssuer = await this.couponRepo.getSoldPerIssuer(issuerIds); + const soldMap = new Map(soldPerIssuer.map((r) => [r.issuerId, r.totalSold])); // Build ranking - const ranked = stores.map(store => ({ + const ranked = stores.map((store) => ({ storeId: store.id, storeName: store.name, issuerName: issuerMap.get(store.issuerId) || 'Unknown', @@ -131,46 +107,27 @@ export class AdminMerchantService { /** * Simulated real-time feed of recent redemption activity. - * In a real system, this would come from a redemption/transaction event stream. - * Constructs a feed from recently sold coupons matched to stores. */ async getRealtimeFeed(limit: number = 50): Promise { - // Get recently updated coupons that have been sold (totalSupply > remainingSupply) - const recentCoupons = await this.couponRepo - .createQueryBuilder('c') - .where('c.total_supply > c.remaining_supply') - .orderBy('c.updated_at', 'DESC') - .take(limit) - .getMany(); - + const recentCoupons = await this.couponRepo.getRecentlySoldCoupons(limit); if (recentCoupons.length === 0) return []; - const issuerIds = [...new Set(recentCoupons.map(c => c.issuerId))]; + const issuerIds = [...new Set(recentCoupons.map((c) => c.issuerId))]; // Get issuers and their stores - const issuers = issuerIds.length > 0 - ? await this.issuerRepo - .createQueryBuilder('i') - .where('i.id IN (:...ids)', { ids: issuerIds }) - .getMany() - : []; - const issuerMap = new Map(issuers.map(i => [i.id, i.companyName])); + const issuers = await this.issuerRepo.findByIds(issuerIds); + const issuerMap = new Map(issuers.map((i) => [i.id, i.companyName])); - const stores = issuerIds.length > 0 - ? await this.storeRepo - .createQueryBuilder('s') - .where('s.issuer_id IN (:...ids)', { ids: issuerIds }) - .getMany() - : []; + const stores = await this.storeRepo.findByIssuerIds(issuerIds); // Map issuer -> first store (for feed display) const storeByIssuer = new Map(); - stores.forEach(s => { + stores.forEach((s) => { if (!storeByIssuer.has(s.issuerId)) { storeByIssuer.set(s.issuerId, s); } }); - return recentCoupons.map(coupon => { + return recentCoupons.map((coupon) => { const store = storeByIssuer.get(coupon.issuerId); return { storeId: store?.id || 'N/A', @@ -188,12 +145,10 @@ export class AdminMerchantService { * Flag a store as abnormal for investigation. */ async flagStore(storeId: string, reason: string): Promise { - const store = await this.storeRepo.findOne({ where: { id: storeId } }); + const store = await this.storeRepo.findById(storeId); if (!store) throw new NotFoundException('Store not found'); store.status = 'flagged'; - // Store the flag reason in the address-adjacent field or log - // Add to system note. TODO: migrate to separate audit table this.logger.warn(`Store ${storeId} (${store.name}) flagged: ${reason}`); return this.storeRepo.save(store); diff --git a/backend/services/issuer-service/src/application/services/coupon.service.ts b/backend/services/issuer-service/src/application/services/coupon.service.ts index b2c363b..b3e3b89 100644 --- a/backend/services/issuer-service/src/application/services/coupon.service.ts +++ b/backend/services/issuer-service/src/application/services/coupon.service.ts @@ -1,62 +1,70 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, ILike, DataSource } from 'typeorm'; +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { COUPON_REPOSITORY, ICouponRepository } from '../../domain/repositories/coupon.repository.interface'; +import { COUPON_RULE_REPOSITORY, ICouponRuleRepository } from '../../domain/repositories/coupon-rule.repository.interface'; import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; -import { CouponRule } from '../../domain/entities/coupon-rule.entity'; @Injectable() export class CouponService { constructor( - @InjectRepository(Coupon) private readonly couponRepo: Repository, - @InjectRepository(CouponRule) private readonly ruleRepo: Repository, - private readonly dataSource: DataSource, + @Inject(COUPON_REPOSITORY) + private readonly couponRepo: ICouponRepository, + @Inject(COUPON_RULE_REPOSITORY) + private readonly ruleRepo: ICouponRuleRepository, ) {} async create(issuerId: string, data: Partial & { rules?: any[] }) { - const coupon = this.couponRepo.create({ ...data, issuerId, status: CouponStatus.DRAFT, remainingSupply: data.totalSupply || 0 }); - const saved = await this.couponRepo.save(coupon); + const saved = await this.couponRepo.create({ + ...data, + issuerId, + status: CouponStatus.DRAFT, + remainingSupply: data.totalSupply || 0, + }); + if (data.rules?.length) { - const rules = data.rules.map(r => this.ruleRepo.create({ couponId: saved.id, ...r })); - await this.ruleRepo.save(rules); + const rules = data.rules.map((r) => ({ + couponId: saved.id, + ...r, + })); + await this.ruleRepo.createMany(rules); } + return saved; } async findById(id: string) { - const coupon = await this.couponRepo.findOne({ where: { id } }); + const coupon = await this.couponRepo.findById(id); if (!coupon) throw new NotFoundException('Coupon not found'); - const rules = await this.ruleRepo.find({ where: { couponId: id } }); + + const rules = await this.ruleRepo.findByCouponId(id); return { ...coupon, rules }; } - async list(page: number, limit: number, filters?: { category?: string; status?: string; search?: string; issuerId?: string }) { - const qb = this.couponRepo.createQueryBuilder('c'); - if (filters?.category) qb.andWhere('c.category = :category', { category: filters.category }); - if (filters?.status) qb.andWhere('c.status = :status', { status: filters.status }); - if (filters?.issuerId) qb.andWhere('c.issuer_id = :issuerId', { issuerId: filters.issuerId }); - if (filters?.search) qb.andWhere('(c.name ILIKE :search OR c.description ILIKE :search)', { search: `%${filters.search}%` }); - qb.orderBy('c.created_at', 'DESC').skip((page - 1) * limit).take(limit); - const [items, total] = await qb.getManyAndCount(); + async list( + page: number, + limit: number, + filters?: { + category?: string; + status?: string; + search?: string; + issuerId?: string; + }, + ) { + const [items, total] = await this.couponRepo.findAndCount({ + category: filters?.category, + status: filters?.status, + search: filters?.search, + issuerId: filters?.issuerId, + page, + limit, + }); return { items, total, page, limit }; } async updateStatus(id: string, status: CouponStatus) { - const coupon = await this.couponRepo.findOne({ where: { id } }); - if (!coupon) throw new NotFoundException('Coupon not found'); - coupon.status = status; - return this.couponRepo.save(coupon); + return this.couponRepo.updateStatus(id, status); } async purchase(couponId: string, quantity: number = 1) { - return this.dataSource.transaction(async (manager) => { - const coupon = await manager.findOne(Coupon, { where: { id: couponId }, lock: { mode: 'pessimistic_write' } }); - if (!coupon) throw new NotFoundException('Coupon not found'); - if (coupon.status !== CouponStatus.ACTIVE) throw new BadRequestException('Coupon is not available'); - if (coupon.remainingSupply < quantity) throw new BadRequestException('Insufficient supply'); - coupon.remainingSupply -= quantity; - if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD_OUT; - await manager.save(coupon); - return coupon; - }); + return this.couponRepo.purchaseWithLock(couponId, quantity); } } diff --git a/backend/services/issuer-service/src/application/services/credit-scoring.service.ts b/backend/services/issuer-service/src/application/services/credit-scoring.service.ts index d418edd..ab6aec8 100644 --- a/backend/services/issuer-service/src/application/services/credit-scoring.service.ts +++ b/backend/services/issuer-service/src/application/services/credit-scoring.service.ts @@ -1,17 +1,23 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CreditMetric } from '../../domain/entities/credit-metric.entity'; +import { Injectable, Inject } from '@nestjs/common'; +import { CREDIT_METRIC_REPOSITORY, ICreditMetricRepository } from '../../domain/repositories/credit-metric.repository.interface'; @Injectable() export class CreditScoringService { - constructor(@InjectRepository(CreditMetric) private readonly repo: Repository) {} + constructor( + @Inject(CREDIT_METRIC_REPOSITORY) + private readonly creditMetricRepo: ICreditMetricRepository, + ) {} /** * 4-factor credit scoring: 35% redemption + 25% breakage + 20% tenure + 20% satisfaction * Score: 0-100, Levels: A(80+), B(60-79), C(40-59), D(20-39), F(<20) */ - calculateScore(params: { redemptionRate: number; breakageRate: number; tenureDays: number; satisfactionScore: number }): { score: number; level: string } { + calculateScore(params: { + redemptionRate: number; + breakageRate: number; + tenureDays: number; + satisfactionScore: number; + }): { score: number; level: string } { const redemptionScore = Math.min(100, params.redemptionRate * 100) * 0.35; const breakageScore = Math.min(100, (1 - params.breakageRate) * 100) * 0.25; const tenureScore = Math.min(100, (params.tenureDays / 365) * 100) * 0.20; @@ -21,17 +27,28 @@ export class CreditScoringService { return { score, level }; } - async saveMetric(issuerId: string, params: { redemptionRate: number; breakageRate: number; tenureDays: number; satisfactionScore: number }) { + async saveMetric( + issuerId: string, + params: { + redemptionRate: number; + breakageRate: number; + tenureDays: number; + satisfactionScore: number; + }, + ) { const { score, level } = this.calculateScore(params); - const metric = this.repo.create({ - issuerId, redemptionRate: String(params.redemptionRate), breakageRate: String(params.breakageRate), - tenureDays: params.tenureDays, satisfactionScore: String(params.satisfactionScore), - compositeScore: String(score), scoreLevel: level, + return this.creditMetricRepo.create({ + issuerId, + redemptionRate: String(params.redemptionRate), + breakageRate: String(params.breakageRate), + tenureDays: params.tenureDays, + satisfactionScore: String(params.satisfactionScore), + compositeScore: String(score), + scoreLevel: level, }); - return this.repo.save(metric); } async getLatestMetric(issuerId: string) { - return this.repo.findOne({ where: { issuerId }, order: { calculatedAt: 'DESC' } }); + return this.creditMetricRepo.findLatestByIssuerId(issuerId); } } diff --git a/backend/services/issuer-service/src/application/services/issuer.service.ts b/backend/services/issuer-service/src/application/services/issuer.service.ts index 31bcc82..b0217e0 100644 --- a/backend/services/issuer-service/src/application/services/issuer.service.ts +++ b/backend/services/issuer-service/src/application/services/issuer.service.ts @@ -1,40 +1,60 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Issuer, IssuerStatus } from '../../domain/entities/issuer.entity'; +import { Injectable, Inject, NotFoundException, ConflictException } from '@nestjs/common'; +import { ISSUER_REPOSITORY, IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; +import { IssuerStatus } from '../../domain/entities/issuer.entity'; @Injectable() export class IssuerService { - constructor(@InjectRepository(Issuer) private readonly repo: Repository) {} + constructor( + @Inject(ISSUER_REPOSITORY) + private readonly issuerRepo: IIssuerRepository, + ) {} - async register(userId: string, data: { companyName: string; contactName: string; contactPhone: string; contactEmail?: string; businessLicense?: string; description?: string; logoUrl?: string }) { - const existing = await this.repo.findOne({ where: { userId } }); + async register( + userId: string, + data: { + companyName: string; + contactName: string; + contactPhone: string; + contactEmail?: string; + businessLicense?: string; + description?: string; + logoUrl?: string; + }, + ) { + const existing = await this.issuerRepo.findByUserId(userId); if (existing) throw new ConflictException('User already registered as issuer'); - const issuer = this.repo.create({ userId, ...data, status: IssuerStatus.PENDING }); - return this.repo.save(issuer); + + return this.issuerRepo.create({ + userId, + ...data, + status: IssuerStatus.PENDING, + }); } async findById(id: string) { - const issuer = await this.repo.findOne({ where: { id } }); + const issuer = await this.issuerRepo.findById(id); if (!issuer) throw new NotFoundException('Issuer not found'); return issuer; } async findByUserId(userId: string) { - return this.repo.findOne({ where: { userId } }); + return this.issuerRepo.findByUserId(userId); } async approve(id: string) { - await this.repo.update(id, { status: IssuerStatus.ACTIVE }); + await this.issuerRepo.updateStatus(id, IssuerStatus.ACTIVE); } async reject(id: string) { - await this.repo.update(id, { status: IssuerStatus.REJECTED }); + await this.issuerRepo.updateStatus(id, IssuerStatus.REJECTED); } async listAll(page: number, limit: number, status?: string) { - const where = status ? { status: status as any } : {}; - const [items, total] = await this.repo.findAndCount({ where, skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' } }); + const [items, total] = await this.issuerRepo.findAndCount({ + status, + page, + limit, + }); return { items, total, page, limit }; } } diff --git a/backend/services/issuer-service/src/domain/events/coupon.events.ts b/backend/services/issuer-service/src/domain/events/coupon.events.ts new file mode 100644 index 0000000..ce5312b --- /dev/null +++ b/backend/services/issuer-service/src/domain/events/coupon.events.ts @@ -0,0 +1,59 @@ +export interface CouponCreatedEvent { + couponId: string; + issuerId: string; + name: string; + type: string; + category: string; + faceValue: string; + price: string; + totalSupply: number; + timestamp: string; +} + +export interface CouponApprovedEvent { + couponId: string; + issuerId: string; + name: string; + approvedBy?: string; + timestamp: string; +} + +export interface CouponRejectedEvent { + couponId: string; + issuerId: string; + name: string; + reason: string; + rejectedBy?: string; + timestamp: string; +} + +export interface CouponSoldEvent { + couponId: string; + issuerId: string; + quantity: number; + remainingSupply: number; + timestamp: string; +} + +export interface CouponRedeemedEvent { + couponId: string; + issuerId: string; + userId: string; + storeId?: string; + timestamp: string; +} + +export interface CouponSuspendedEvent { + couponId: string; + issuerId: string; + name: string; + suspendedBy?: string; + timestamp: string; +} + +export interface CouponExpiredEvent { + couponId: string; + issuerId: string; + name: string; + timestamp: string; +} diff --git a/backend/services/issuer-service/src/domain/events/issuer.events.ts b/backend/services/issuer-service/src/domain/events/issuer.events.ts new file mode 100644 index 0000000..d11f3c3 --- /dev/null +++ b/backend/services/issuer-service/src/domain/events/issuer.events.ts @@ -0,0 +1,33 @@ +export interface IssuerRegisteredEvent { + issuerId: string; + userId: string; + companyName: string; + contactName: string; + contactPhone: string; + timestamp: string; +} + +export interface IssuerApprovedEvent { + issuerId: string; + userId: string; + companyName: string; + approvedBy?: string; + timestamp: string; +} + +export interface IssuerRejectedEvent { + issuerId: string; + userId: string; + companyName: string; + reason: string; + rejectedBy?: string; + timestamp: string; +} + +export interface IssuerSuspendedEvent { + issuerId: string; + userId: string; + companyName: string; + reason?: string; + timestamp: string; +} diff --git a/backend/services/issuer-service/src/domain/ports/ai-service.client.interface.ts b/backend/services/issuer-service/src/domain/ports/ai-service.client.interface.ts new file mode 100644 index 0000000..c397593 --- /dev/null +++ b/backend/services/issuer-service/src/domain/ports/ai-service.client.interface.ts @@ -0,0 +1,31 @@ +/** + * AI Service Client domain port. + * + * Application services depend on this interface for AI-powered issuer review. + * The concrete HTTP implementation lives in infrastructure/external/. + */ + +export const AI_SERVICE_CLIENT = Symbol('IAiServiceClient'); + +export interface IssuerReviewRequest { + id: string; + companyName: string; + businessLicense: string | null; + contactName: string; + contactPhone: string; + contactEmail: string | null; + description: string | null; + creditScore: string; + createdAt: Date; +} + +export interface IssuerReviewResult { + issuerId: string; + recommendation: string; + riskLevel: string; + reasons: string[]; +} + +export interface IAiServiceClient { + reviewIssuers(issuers: IssuerReviewRequest[]): Promise; +} diff --git a/backend/services/issuer-service/src/domain/repositories/coupon-rule.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/coupon-rule.repository.interface.ts new file mode 100644 index 0000000..1c93ebf --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/coupon-rule.repository.interface.ts @@ -0,0 +1,11 @@ +import { CouponRule } from '../entities/coupon-rule.entity'; + +export const COUPON_RULE_REPOSITORY = Symbol('ICouponRuleRepository'); + +export interface ICouponRuleRepository { + findByCouponId(couponId: string): Promise; + createMany(rules: Partial[]): Promise; + create(data: Partial): Promise; + save(rule: CouponRule): Promise; + deletesByCouponId(couponId: string): Promise; +} diff --git a/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts new file mode 100644 index 0000000..0b80a69 --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/coupon.repository.interface.ts @@ -0,0 +1,83 @@ +import { Coupon, CouponStatus } from '../entities/coupon.entity'; + +export const COUPON_REPOSITORY = Symbol('ICouponRepository'); + +export interface CouponListFilters { + category?: string; + status?: string; + search?: string; + issuerId?: string; + page: number; + limit: number; +} + +export interface CouponAggregateResult { + totalCoupons: number; + totalSupply: number; + totalSold: number; + totalRemaining: number; +} + +export interface CouponStatusCount { + status: string; + count: number; +} + +export interface CouponsByIssuerRow { + issuerId: string; + couponCount: number; + totalSupply: number; + totalSold: number; + totalFaceValue: number; +} + +export interface CouponsByCategoryRow { + category: string; + couponCount: number; + totalSupply: number; + totalSold: number; + avgPrice: number; +} + +export interface CouponSoldAggregateRow { + issuerId: string; + totalSold: number; +} + +export interface DiscountDistributionRow { + range: string; + count: number; + avgDiscount: number; +} + +export interface RedemptionRateRow { + month: string; + totalIssued: number; + totalSold: number; +} + +export interface ICouponRepository { + findById(id: string): Promise; + create(data: Partial): Promise; + save(coupon: Coupon): Promise; + findAndCount(filters: CouponListFilters): Promise<[Coupon[], number]>; + findAndCountWithIssuerJoin(filters: CouponListFilters): Promise<[Coupon[], number]>; + updateStatus(id: string, status: CouponStatus): Promise; + purchaseWithLock(couponId: string, quantity: number): Promise; + count(where?: Partial>): Promise; + + // Analytics aggregates + getAggregateStats(): Promise; + getStatusCounts(): Promise; + getCouponsByIssuer(): Promise; + getCouponsByCategory(): Promise; + getTotalSold(): Promise; + getTotalSoldByStatuses(statuses: CouponStatus[]): Promise; + getRedemptionRateTrend(): Promise; + getDiscountDistribution(): Promise; + + // Merchant analytics + getCouponSupplyAggregate(): Promise<{ totalCouponsIssued: number; totalCouponsSold: number }>; + getSoldPerIssuer(issuerIds: string[]): Promise; + getRecentlySoldCoupons(limit: number): Promise; +} diff --git a/backend/services/issuer-service/src/domain/repositories/credit-metric.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/credit-metric.repository.interface.ts new file mode 100644 index 0000000..49d45e8 --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/credit-metric.repository.interface.ts @@ -0,0 +1,9 @@ +import { CreditMetric } from '../entities/credit-metric.entity'; + +export const CREDIT_METRIC_REPOSITORY = Symbol('ICreditMetricRepository'); + +export interface ICreditMetricRepository { + findLatestByIssuerId(issuerId: string): Promise; + create(data: Partial): Promise; + save(metric: CreditMetric): Promise; +} diff --git a/backend/services/issuer-service/src/domain/repositories/issuer.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/issuer.repository.interface.ts new file mode 100644 index 0000000..d4212e8 --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/issuer.repository.interface.ts @@ -0,0 +1,20 @@ +import { Issuer, IssuerStatus } from '../entities/issuer.entity'; + +export const ISSUER_REPOSITORY = Symbol('IIssuerRepository'); + +export interface IIssuerRepository { + findById(id: string): Promise; + findByUserId(userId: string): Promise; + create(data: Partial): Promise; + save(issuer: Issuer): Promise; + updateStatus(id: string, status: IssuerStatus): Promise; + findAndCount(options: { + status?: string; + search?: string; + page: number; + limit: number; + }): Promise<[Issuer[], number]>; + findPending(take: number): Promise; + findByIds(ids: string[]): Promise; + count(where?: Partial>): Promise; +} diff --git a/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts b/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts new file mode 100644 index 0000000..17fe69b --- /dev/null +++ b/backend/services/issuer-service/src/domain/repositories/store.repository.interface.ts @@ -0,0 +1,12 @@ +import { Store } from '../entities/store.entity'; + +export const STORE_REPOSITORY = Symbol('IStoreRepository'); + +export interface IStoreRepository { + findById(id: string): Promise; + save(store: Store): Promise; + count(where?: Partial>): Promise; + findByIssuerId(issuerId: string): Promise; + findByIssuerIds(issuerIds: string[]): Promise; + findTopStores(limit: number): Promise; +} diff --git a/backend/services/issuer-service/src/domain/value-objects/coupon-quantity.vo.ts b/backend/services/issuer-service/src/domain/value-objects/coupon-quantity.vo.ts new file mode 100644 index 0000000..5ddc9fa --- /dev/null +++ b/backend/services/issuer-service/src/domain/value-objects/coupon-quantity.vo.ts @@ -0,0 +1,68 @@ +/** + * Value Object representing a coupon quantity (positive integer). + * Immutable with private constructor and static factory. + */ +export class CouponQuantity { + private constructor(private readonly _value: number) {} + + /** + * Create a CouponQuantity from a numeric value. + * @param value - Must be a positive integer (>= 1) + */ + static create(value: number): CouponQuantity { + if (!Number.isInteger(value)) { + throw new Error('Coupon quantity must be an integer'); + } + if (value < 1) { + throw new Error('Coupon quantity must be at least 1'); + } + return new CouponQuantity(value); + } + + /** + * Create a CouponQuantity representing a supply amount (allows 0). + * Used for remaining supply which can reach 0. + * @param value - Must be a non-negative integer (>= 0) + */ + static createSupply(value: number): CouponQuantity { + if (!Number.isInteger(value)) { + throw new Error('Supply quantity must be an integer'); + } + if (value < 0) { + throw new Error('Supply quantity cannot be negative'); + } + return new CouponQuantity(value); + } + + get value(): number { + return this._value; + } + + subtract(amount: CouponQuantity): CouponQuantity { + const result = this._value - amount._value; + if (result < 0) { + throw new Error('Insufficient quantity: cannot subtract more than available'); + } + return new CouponQuantity(result); + } + + add(amount: CouponQuantity): CouponQuantity { + return new CouponQuantity(this._value + amount._value); + } + + isZero(): boolean { + return this._value === 0; + } + + isGreaterThanOrEqual(amount: CouponQuantity): boolean { + return this._value >= amount._value; + } + + equals(other: CouponQuantity): boolean { + return this._value === other._value; + } + + toString(): string { + return String(this._value); + } +} diff --git a/backend/services/issuer-service/src/domain/value-objects/credit-score.vo.ts b/backend/services/issuer-service/src/domain/value-objects/credit-score.vo.ts new file mode 100644 index 0000000..cdd2aaa --- /dev/null +++ b/backend/services/issuer-service/src/domain/value-objects/credit-score.vo.ts @@ -0,0 +1,77 @@ +/** + * Value Object representing a credit score with range validation (0-1000). + * Immutable with private constructor and static factory. + */ +export class CreditScore { + private static readonly MIN = 0; + private static readonly MAX = 1000; + + private constructor(private readonly _value: number) {} + + /** + * Create a CreditScore from a numeric value. + * @param value - Must be between 0 and 1000 inclusive + */ + static create(value: number): CreditScore { + if (!Number.isFinite(value)) { + throw new Error('Credit score must be a finite number'); + } + if (value < CreditScore.MIN || value > CreditScore.MAX) { + throw new Error(`Credit score must be between ${CreditScore.MIN} and ${CreditScore.MAX}`); + } + const normalized = Math.round(value * 100) / 100; + return new CreditScore(normalized); + } + + /** + * Reconstruct from a stored string value (e.g. from database numeric column). + */ + static fromString(value: string): CreditScore { + const parsed = parseFloat(value); + if (isNaN(parsed)) { + throw new Error('Invalid credit score string value'); + } + return CreditScore.create(parsed); + } + + get value(): number { + return this._value; + } + + /** + * Returns the letter grade for the credit score. + * A: 80-100, B: 60-79, C: 40-59, D: 20-39, F: <20 + * (Mapped to 0-100 scale from 0-1000) + */ + get level(): string { + const scaled = this._value / 10; // Convert 0-1000 to 0-100 + if (scaled >= 80) return 'A'; + if (scaled >= 60) return 'B'; + if (scaled >= 40) return 'C'; + if (scaled >= 20) return 'D'; + return 'F'; + } + + /** + * Returns the string representation suitable for database storage. + */ + toStorageString(): string { + return this._value.toFixed(2); + } + + toString(): string { + return `${this._value.toFixed(2)} (${this.level})`; + } + + equals(other: CreditScore): boolean { + return this._value === other._value; + } + + isAbove(threshold: number): boolean { + return this._value > threshold; + } + + isBelow(threshold: number): boolean { + return this._value < threshold; + } +} diff --git a/backend/services/issuer-service/src/domain/value-objects/money.vo.ts b/backend/services/issuer-service/src/domain/value-objects/money.vo.ts new file mode 100644 index 0000000..94650c8 --- /dev/null +++ b/backend/services/issuer-service/src/domain/value-objects/money.vo.ts @@ -0,0 +1,96 @@ +/** + * Value Object representing a monetary amount with currency. + * Immutable, validated, with private constructor and static factory. + */ +export class Money { + private constructor( + private readonly _amount: number, + private readonly _currency: string, + ) {} + + /** + * Create a Money value object from a numeric amount and optional currency. + * @param amount - Must be >= 0 + * @param currency - ISO 4217 currency code, defaults to 'USD' + */ + static create(amount: number, currency: string = 'USD'): Money { + if (amount < 0) { + throw new Error('Money amount cannot be negative'); + } + if (!Number.isFinite(amount)) { + throw new Error('Money amount must be a finite number'); + } + const normalized = Math.round(amount * 100) / 100; + const normalizedCurrency = currency.toUpperCase().trim(); + if (normalizedCurrency.length < 2 || normalizedCurrency.length > 10) { + throw new Error('Invalid currency code'); + } + return new Money(normalized, normalizedCurrency); + } + + /** + * Reconstruct from a stored string value (e.g. from database numeric column). + */ + static fromString(value: string, currency: string = 'USD'): Money { + const amount = parseFloat(value); + if (isNaN(amount)) { + throw new Error('Invalid money string value'); + } + return Money.create(amount, currency); + } + + get amount(): number { + return this._amount; + } + + get currency(): string { + return this._currency; + } + + /** + * Returns the string representation suitable for database storage. + */ + toStorageString(): string { + return this._amount.toFixed(2); + } + + /** + * Returns display string, e.g. "100.00 USD". + */ + toString(): string { + return `${this._amount.toFixed(2)} ${this._currency}`; + } + + equals(other: Money): boolean { + return this._amount === other._amount && this._currency === other._currency; + } + + add(other: Money): Money { + if (this._currency !== other._currency) { + throw new Error('Cannot add Money with different currencies'); + } + return Money.create(this._amount + other._amount, this._currency); + } + + subtract(other: Money): Money { + if (this._currency !== other._currency) { + throw new Error('Cannot subtract Money with different currencies'); + } + return Money.create(this._amount - other._amount, this._currency); + } + + multiply(factor: number): Money { + return Money.create(this._amount * factor, this._currency); + } + + isGreaterThan(other: Money): boolean { + if (this._currency !== other._currency) { + throw new Error('Cannot compare Money with different currencies'); + } + return this._amount > other._amount; + } + + isZero(): boolean { + return this._amount === 0; + } +} diff --git a/backend/services/issuer-service/src/infrastructure/external/ai-service.client.ts b/backend/services/issuer-service/src/infrastructure/external/ai-service.client.ts new file mode 100644 index 0000000..d7a077c --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/external/ai-service.client.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + IAiServiceClient, + IssuerReviewRequest, + IssuerReviewResult, +} from '../../domain/ports/ai-service.client.interface'; + +@Injectable() +export class AiServiceClient implements IAiServiceClient { + private readonly logger = new Logger('AiServiceClient'); + private readonly aiServiceUrl: string; + + constructor() { + this.aiServiceUrl = process.env.AI_SERVICE_URL || 'http://localhost:3006'; + } + + /** + * Call external AI service for issuer pre-review. + * Returns null if the service is unavailable (caller handles fallback). + */ + async reviewIssuers(issuers: IssuerReviewRequest[]): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(`${this.aiServiceUrl}/api/v1/admin/issuer-review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issuers: issuers.map((i) => ({ + id: i.id, + companyName: i.companyName, + businessLicense: i.businessLicense, + contactName: i.contactName, + contactPhone: i.contactPhone, + contactEmail: i.contactEmail, + description: i.description, + creditScore: i.creditScore, + createdAt: i.createdAt, + })), + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const aiResults = (await response.json()) as { + reviews: IssuerReviewResult[]; + }; + return aiResults.reviews || null; + } + + this.logger.warn(`AI service returned status ${response.status}`); + return null; + } catch (error) { + this.logger.warn(`AI service unavailable: ${error.message}`); + return null; + } + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/coupon-rule.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/coupon-rule.repository.ts new file mode 100644 index 0000000..56443d9 --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/coupon-rule.repository.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CouponRule } from '../../domain/entities/coupon-rule.entity'; +import { ICouponRuleRepository } from '../../domain/repositories/coupon-rule.repository.interface'; + +@Injectable() +export class CouponRuleRepository implements ICouponRuleRepository { + constructor( + @InjectRepository(CouponRule) + private readonly repo: Repository, + ) {} + + async findByCouponId(couponId: string): Promise { + return this.repo.find({ where: { couponId } }); + } + + async createMany(rules: Partial[]): Promise { + const entities = rules.map((r) => this.repo.create(r)); + return this.repo.save(entities); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async save(rule: CouponRule): Promise { + return this.repo.save(rule); + } + + async deletesByCouponId(couponId: string): Promise { + await this.repo.delete({ couponId }); + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts new file mode 100644 index 0000000..d01fcde --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/coupon.repository.ts @@ -0,0 +1,305 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Coupon, CouponStatus } from '../../domain/entities/coupon.entity'; +import { Issuer } from '../../domain/entities/issuer.entity'; +import { + ICouponRepository, + CouponListFilters, + CouponAggregateResult, + CouponStatusCount, + CouponsByIssuerRow, + CouponsByCategoryRow, + CouponSoldAggregateRow, + DiscountDistributionRow, + RedemptionRateRow, +} from '../../domain/repositories/coupon.repository.interface'; + +@Injectable() +export class CouponRepository implements ICouponRepository { + constructor( + @InjectRepository(Coupon) + private readonly repo: Repository, + private readonly dataSource: DataSource, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async save(coupon: Coupon): Promise { + return this.repo.save(coupon); + } + + async findAndCount(filters: CouponListFilters): Promise<[Coupon[], number]> { + const { category, status, search, issuerId, page, limit } = filters; + const qb = this.repo.createQueryBuilder('c'); + + if (category) qb.andWhere('c.category = :category', { category }); + if (status) qb.andWhere('c.status = :status', { status }); + if (issuerId) qb.andWhere('c.issuer_id = :issuerId', { issuerId }); + if (search) { + qb.andWhere('(c.name ILIKE :search OR c.description ILIKE :search)', { + search: `%${search}%`, + }); + } + + qb.orderBy('c.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async findAndCountWithIssuerJoin(filters: CouponListFilters): Promise<[Coupon[], number]> { + const { category, status, search, issuerId, page, limit } = filters; + const qb = this.repo.createQueryBuilder('c'); + + qb.leftJoinAndMapOne('c.issuer', Issuer, 'i', 'i.id = c.issuer_id'); + + if (status) qb.andWhere('c.status = :status', { status }); + if (issuerId) qb.andWhere('c.issuer_id = :issuerId', { issuerId }); + if (category) qb.andWhere('c.category = :category', { category }); + if (search) { + qb.andWhere('(c.name ILIKE :search OR c.description ILIKE :search)', { + search: `%${search}%`, + }); + } + + qb.orderBy('c.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async updateStatus(id: string, status: CouponStatus): Promise { + const coupon = await this.repo.findOne({ where: { id } }); + if (!coupon) throw new NotFoundException('Coupon not found'); + coupon.status = status; + return this.repo.save(coupon); + } + + async purchaseWithLock(couponId: string, quantity: number): Promise { + return this.dataSource.transaction(async (manager) => { + const coupon = await manager.findOne(Coupon, { + where: { id: couponId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!coupon) throw new NotFoundException('Coupon not found'); + if (coupon.status !== CouponStatus.ACTIVE) { + throw new BadRequestException('Coupon is not available'); + } + if (coupon.remainingSupply < quantity) { + throw new BadRequestException('Insufficient supply'); + } + coupon.remainingSupply -= quantity; + if (coupon.remainingSupply === 0) coupon.status = CouponStatus.SOLD_OUT; + await manager.save(coupon); + return coupon; + }); + } + + async count(where?: Partial>): Promise { + return this.repo.count({ where: where as any }); + } + + // ---- Analytics aggregates ---- + + async getAggregateStats(): Promise { + const result = await this.repo + .createQueryBuilder('c') + .select([ + 'COUNT(c.id) as "totalCoupons"', + 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', + 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', + 'COALESCE(SUM(c.remaining_supply), 0) as "totalRemaining"', + ]) + .getRawOne(); + + return { + totalCoupons: Number(result.totalCoupons) || 0, + totalSupply: Number(result.totalSupply) || 0, + totalSold: Number(result.totalSold) || 0, + totalRemaining: Number(result.totalRemaining) || 0, + }; + } + + async getStatusCounts(): Promise { + const raw = await this.repo + .createQueryBuilder('c') + .select('c.status', 'status') + .addSelect('COUNT(c.id)', 'count') + .groupBy('c.status') + .getRawMany(); + + return raw.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getCouponsByIssuer(): Promise { + const raw = await this.repo + .createQueryBuilder('c') + .select([ + 'c.issuer_id as "issuerId"', + 'COUNT(c.id) as "couponCount"', + 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', + 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', + 'COALESCE(SUM(CAST(c.face_value AS numeric) * c.total_supply), 0) as "totalFaceValue"', + ]) + .groupBy('c.issuer_id') + .orderBy('"couponCount"', 'DESC') + .getRawMany(); + + return raw.map((row) => ({ + issuerId: row.issuerId, + couponCount: Number(row.couponCount), + totalSupply: Number(row.totalSupply), + totalSold: Number(row.totalSold), + totalFaceValue: Number(row.totalFaceValue), + })); + } + + async getCouponsByCategory(): Promise { + const raw = await this.repo + .createQueryBuilder('c') + .select([ + 'c.category as "category"', + 'COUNT(c.id) as "couponCount"', + 'COALESCE(SUM(c.total_supply), 0) as "totalSupply"', + 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', + 'COALESCE(AVG(CAST(c.price AS numeric)), 0) as "avgPrice"', + ]) + .groupBy('c.category') + .orderBy('"couponCount"', 'DESC') + .getRawMany(); + + return raw.map((row) => ({ + category: row.category, + couponCount: Number(row.couponCount), + totalSupply: Number(row.totalSupply), + totalSold: Number(row.totalSold), + avgPrice: Math.round(Number(row.avgPrice) * 100) / 100, + })); + } + + async getTotalSold(): Promise { + const result = await this.repo + .createQueryBuilder('c') + .select('COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"') + .getRawOne(); + return Number(result.totalSold) || 0; + } + + async getTotalSoldByStatuses(statuses: CouponStatus[]): Promise { + const result = await this.repo + .createQueryBuilder('c') + .select('COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalRedeemed"') + .where('c.status IN (:...statuses)', { statuses }) + .getRawOne(); + return Number(result.totalRedeemed) || 0; + } + + async getRedemptionRateTrend(): Promise { + const raw = await this.repo + .createQueryBuilder('c') + .select([ + "TO_CHAR(c.created_at, 'YYYY-MM') as \"month\"", + 'COALESCE(SUM(c.total_supply), 0) as "totalIssued"', + 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', + ]) + .groupBy("TO_CHAR(c.created_at, 'YYYY-MM')") + .orderBy('"month"', 'ASC') + .getRawMany(); + + return raw.map((row) => ({ + month: row.month, + totalIssued: Number(row.totalIssued) || 0, + totalSold: Number(row.totalSold) || 0, + })); + } + + async getDiscountDistribution(): Promise { + const raw = await this.repo + .createQueryBuilder('c') + .select([ + `CASE + WHEN CAST(c.face_value AS numeric) = 0 THEN 'N/A' + WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 0 THEN 'premium' + WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 10 THEN '0-10%' + WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 20 THEN '10-20%' + WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 30 THEN '20-30%' + WHEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 < 50 THEN '30-50%' + ELSE '50%+' + END as "range"`, + 'COUNT(c.id) as "count"', + `COALESCE(AVG( + CASE WHEN CAST(c.face_value AS numeric) > 0 + THEN (1 - CAST(c.price AS numeric) / CAST(c.face_value AS numeric)) * 100 + ELSE 0 + END + ), 0) as "avgDiscount"`, + ]) + .groupBy('"range"') + .orderBy('"range"', 'ASC') + .getRawMany(); + + return raw.map((row) => ({ + range: row.range, + count: Number(row.count), + avgDiscount: Math.round(Number(row.avgDiscount) * 100) / 100, + })); + } + + // ---- Merchant analytics ---- + + async getCouponSupplyAggregate(): Promise<{ totalCouponsIssued: number; totalCouponsSold: number }> { + const result = await this.repo + .createQueryBuilder('c') + .select([ + 'COALESCE(SUM(c.total_supply), 0) as "totalCouponsIssued"', + 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalCouponsSold"', + ]) + .getRawOne(); + + return { + totalCouponsIssued: Number(result.totalCouponsIssued) || 0, + totalCouponsSold: Number(result.totalCouponsSold) || 0, + }; + } + + async getSoldPerIssuer(issuerIds: string[]): Promise { + if (issuerIds.length === 0) return []; + + const raw = await this.repo + .createQueryBuilder('c') + .select([ + 'c.issuer_id as "issuerId"', + 'COALESCE(SUM(c.total_supply - c.remaining_supply), 0) as "totalSold"', + ]) + .where('c.issuer_id IN (:...ids)', { ids: issuerIds }) + .groupBy('c.issuer_id') + .getRawMany(); + + return raw.map((r) => ({ + issuerId: r.issuerId, + totalSold: Number(r.totalSold), + })); + } + + async getRecentlySoldCoupons(limit: number): Promise { + return this.repo + .createQueryBuilder('c') + .where('c.total_supply > c.remaining_supply') + .orderBy('c.updated_at', 'DESC') + .take(limit) + .getMany(); + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/credit-metric.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/credit-metric.repository.ts new file mode 100644 index 0000000..2515df1 --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/credit-metric.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreditMetric } from '../../domain/entities/credit-metric.entity'; +import { ICreditMetricRepository } from '../../domain/repositories/credit-metric.repository.interface'; + +@Injectable() +export class CreditMetricRepository implements ICreditMetricRepository { + constructor( + @InjectRepository(CreditMetric) + private readonly repo: Repository, + ) {} + + async findLatestByIssuerId(issuerId: string): Promise { + return this.repo.findOne({ + where: { issuerId }, + order: { calculatedAt: 'DESC' }, + }); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async save(metric: CreditMetric): Promise { + return this.repo.save(metric); + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/issuer.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/issuer.repository.ts new file mode 100644 index 0000000..dd508bc --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/issuer.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Issuer, IssuerStatus } from '../../domain/entities/issuer.entity'; +import { IIssuerRepository } from '../../domain/repositories/issuer.repository.interface'; + +@Injectable() +export class IssuerRepository implements IIssuerRepository { + constructor( + @InjectRepository(Issuer) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findByUserId(userId: string): Promise { + return this.repo.findOne({ where: { userId } }); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async save(issuer: Issuer): Promise { + return this.repo.save(issuer); + } + + async updateStatus(id: string, status: IssuerStatus): Promise { + await this.repo.update(id, { status }); + } + + async findAndCount(options: { + status?: string; + search?: string; + page: number; + limit: number; + }): Promise<[Issuer[], number]> { + const { status, search, page, limit } = options; + const qb = this.repo.createQueryBuilder('i'); + + if (status) { + qb.andWhere('i.status = :status', { status }); + } + + if (search) { + qb.andWhere( + '(i.company_name ILIKE :search OR i.contact_name ILIKE :search OR i.contact_email ILIKE :search)', + { search: `%${search}%` }, + ); + } + + qb.orderBy('i.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async findPending(take: number): Promise { + return this.repo.find({ + where: { status: IssuerStatus.PENDING }, + order: { createdAt: 'ASC' }, + take, + }); + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + return this.repo + .createQueryBuilder('i') + .where('i.id IN (:...ids)', { ids }) + .getMany(); + } + + async count(where?: Partial>): Promise { + return this.repo.count({ where: where as any }); + } +} diff --git a/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts b/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts new file mode 100644 index 0000000..cd7742b --- /dev/null +++ b/backend/services/issuer-service/src/infrastructure/persistence/store.repository.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Store } from '../../domain/entities/store.entity'; +import { IStoreRepository } from '../../domain/repositories/store.repository.interface'; + +@Injectable() +export class StoreRepository implements IStoreRepository { + constructor( + @InjectRepository(Store) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async save(store: Store): Promise { + return this.repo.save(store); + } + + async count(where?: Partial>): Promise { + return this.repo.count({ where: where as any }); + } + + async findByIssuerId(issuerId: string): Promise { + return this.repo.find({ where: { issuerId } }); + } + + async findByIssuerIds(issuerIds: string[]): Promise { + if (issuerIds.length === 0) return []; + return this.repo + .createQueryBuilder('s') + .where('s.issuer_id IN (:...ids)', { ids: issuerIds }) + .getMany(); + } + + async findTopStores(limit: number): Promise { + return this.repo + .createQueryBuilder('s') + .orderBy('s.created_at', 'DESC') + .take(limit * 2) + .getMany(); + } +} diff --git a/backend/services/issuer-service/src/interface/http/controllers/admin-analytics.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/admin-analytics.controller.ts index ec9f4cd..a6dcaf0 100644 --- a/backend/services/issuer-service/src/interface/http/controllers/admin-analytics.controller.ts +++ b/backend/services/issuer-service/src/interface/http/controllers/admin-analytics.controller.ts @@ -6,31 +6,7 @@ import { import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { AdminCouponAnalyticsService } from '../../../application/services/admin-coupon-analytics.service'; - -import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -const ROLES_KEY = 'roles'; -const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); - -@Injectable() -class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (!requiredRoles || requiredRoles.length === 0) return true; - const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!user) throw new ForbiddenException('No user context found'); - if (!requiredRoles.includes(user.role)) { - throw new ForbiddenException(`Requires one of roles: ${requiredRoles.join(', ')}`); - } - return true; - } -} +import { RolesGuard, Roles } from '../guards/roles.guard'; @ApiTags('Admin - Coupon Analytics') @Controller('admin/analytics/coupons') diff --git a/backend/services/issuer-service/src/interface/http/controllers/admin-coupon.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/admin-coupon.controller.ts index bd09825..c46e565 100644 --- a/backend/services/issuer-service/src/interface/http/controllers/admin-coupon.controller.ts +++ b/backend/services/issuer-service/src/interface/http/controllers/admin-coupon.controller.ts @@ -8,34 +8,11 @@ import { UseGuards, ParseUUIDPipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { AdminCouponService } from '../../../application/services/admin-coupon.service'; - -import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -const ROLES_KEY = 'roles'; -const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); - -@Injectable() -class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (!requiredRoles || requiredRoles.length === 0) return true; - const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!user) throw new ForbiddenException('No user context found'); - if (!requiredRoles.includes(user.role)) { - throw new ForbiddenException(`Requires one of roles: ${requiredRoles.join(', ')}`); - } - return true; - } -} +import { RolesGuard, Roles } from '../guards/roles.guard'; +import { ListCouponsQueryDto, RejectCouponDto } from '../dto/coupon.dto'; @ApiTags('Admin - Coupons') @Controller('admin/coupons') @@ -47,27 +24,14 @@ export class AdminCouponController { @Get() @ApiOperation({ summary: 'List coupons with filters (admin)' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'status', required: false, enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] }) - @ApiQuery({ name: 'issuerId', required: false, type: String }) - @ApiQuery({ name: 'category', required: false, type: String }) - @ApiQuery({ name: 'search', required: false, type: String }) - async listCoupons( - @Query('page') page?: string, - @Query('limit') limit?: string, - @Query('status') status?: string, - @Query('issuerId') issuerId?: string, - @Query('category') category?: string, - @Query('search') search?: string, - ) { + async listCoupons(@Query() query: ListCouponsQueryDto) { const result = await this.adminCouponService.listCoupons({ - page: page ? parseInt(page, 10) : 1, - limit: limit ? parseInt(limit, 10) : 20, - status, - issuerId, - category, - search, + page: query.page, + limit: query.limit, + status: query.status, + issuerId: query.issuerId, + category: query.category, + search: query.search, }); return { code: 0, data: result }; } @@ -90,12 +54,9 @@ export class AdminCouponController { @ApiOperation({ summary: 'Reject a draft coupon (admin)' }) async rejectCoupon( @Param('id', ParseUUIDPipe) id: string, - @Body('reason') reason: string, + @Body() dto: RejectCouponDto, ) { - if (!reason || reason.trim().length === 0) { - return { code: 1, message: 'Rejection reason is required' }; - } - const coupon = await this.adminCouponService.rejectCoupon(id, reason); + const coupon = await this.adminCouponService.rejectCoupon(id, dto.reason); return { code: 0, data: coupon, message: 'Coupon rejected' }; } diff --git a/backend/services/issuer-service/src/interface/http/controllers/admin-issuer.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/admin-issuer.controller.ts index 295016d..3d1e717 100644 --- a/backend/services/issuer-service/src/interface/http/controllers/admin-issuer.controller.ts +++ b/backend/services/issuer-service/src/interface/http/controllers/admin-issuer.controller.ts @@ -11,38 +11,8 @@ import { import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { AdminIssuerService } from '../../../application/services/admin-issuer.service'; - -// Guards and decorators - using local implementations compatible with @nestjs/passport -// In production, import from @genex/common: JwtAuthGuard, RolesGuard, Roles, UserRole - -/** - * Simple roles guard for admin endpoints. - * Checks that req.user.role matches one of the allowed roles. - */ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -const ROLES_KEY = 'roles'; -const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); - -@Injectable() -class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (!requiredRoles || requiredRoles.length === 0) return true; - const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!user) throw new ForbiddenException('No user context found'); - if (!requiredRoles.includes(user.role)) { - throw new ForbiddenException(`Requires one of roles: ${requiredRoles.join(', ')}`); - } - return true; - } -} +import { RolesGuard, Roles } from '../guards/roles.guard'; +import { ListIssuersQueryDto, RejectIssuerDto } from '../dto/issuer.dto'; @ApiTags('Admin - Issuers') @Controller('admin/issuers') @@ -54,21 +24,12 @@ export class AdminIssuerController { @Get() @ApiOperation({ summary: 'List issuers with filters (admin)' }) - @ApiQuery({ name: 'page', required: false, type: Number }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'status', required: false, enum: ['pending', 'active', 'suspended', 'rejected'] }) - @ApiQuery({ name: 'search', required: false, type: String }) - async listIssuers( - @Query('page') page?: string, - @Query('limit') limit?: string, - @Query('status') status?: string, - @Query('search') search?: string, - ) { + async listIssuers(@Query() query: ListIssuersQueryDto) { const result = await this.adminIssuerService.listIssuers({ - page: page ? parseInt(page, 10) : 1, - limit: limit ? parseInt(limit, 10) : 20, - status, - search, + page: query.page, + limit: query.limit, + status: query.status, + search: query.search, }); return { code: 0, data: result }; } @@ -98,12 +59,9 @@ export class AdminIssuerController { @ApiOperation({ summary: 'Reject a pending issuer with reason (admin)' }) async rejectIssuer( @Param('id', ParseUUIDPipe) id: string, - @Body('reason') reason: string, + @Body() dto: RejectIssuerDto, ) { - if (!reason || reason.trim().length === 0) { - return { code: 1, message: 'Rejection reason is required' }; - } - const issuer = await this.adminIssuerService.rejectIssuer(id, reason); + const issuer = await this.adminIssuerService.rejectIssuer(id, dto.reason); return { code: 0, data: issuer, message: 'Issuer rejected' }; } } diff --git a/backend/services/issuer-service/src/interface/http/controllers/admin-merchant.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/admin-merchant.controller.ts index 43470ab..c77dcc7 100644 --- a/backend/services/issuer-service/src/interface/http/controllers/admin-merchant.controller.ts +++ b/backend/services/issuer-service/src/interface/http/controllers/admin-merchant.controller.ts @@ -11,31 +11,8 @@ import { import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { AdminMerchantService } from '../../../application/services/admin-merchant.service'; - -import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -const ROLES_KEY = 'roles'; -const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); - -@Injectable() -class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (!requiredRoles || requiredRoles.length === 0) return true; - const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!user) throw new ForbiddenException('No user context found'); - if (!requiredRoles.includes(user.role)) { - throw new ForbiddenException(`Requires one of roles: ${requiredRoles.join(', ')}`); - } - return true; - } -} +import { RolesGuard, Roles } from '../guards/roles.guard'; +import { FlagStoreDto } from '../dto/coupon.dto'; @ApiTags('Admin - Merchant') @Controller('admin/merchant') @@ -76,12 +53,9 @@ export class AdminMerchantController { @ApiOperation({ summary: 'Flag a store as abnormal for investigation' }) async flagStore( @Param('id', ParseUUIDPipe) id: string, - @Body('reason') reason: string, + @Body() dto: FlagStoreDto, ) { - if (!reason || reason.trim().length === 0) { - return { code: 1, message: 'Flag reason is required' }; - } - const store = await this.adminMerchantService.flagStore(id, reason); + const store = await this.adminMerchantService.flagStore(id, dto.reason); return { code: 0, data: store, message: 'Store flagged for investigation' }; } } diff --git a/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts b/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts index a4be24c..426e42c 100644 --- a/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts +++ b/backend/services/issuer-service/src/interface/http/controllers/coupon.controller.ts @@ -2,7 +2,12 @@ import { Controller, Get, Post, Put, Body, Param, Query, UseGuards, Req } from ' import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { CouponService } from '../../../application/services/coupon.service'; -import { CreateCouponDto } from '../dto/coupon.dto'; +import { + CreateCouponDto, + UpdateCouponStatusDto, + PurchaseCouponDto, + ListCouponsQueryDto, +} from '../dto/coupon.dto'; @ApiTags('Coupons') @Controller('coupons') @@ -14,22 +19,29 @@ export class CouponController { @ApiBearerAuth() @ApiOperation({ summary: 'Create a coupon (issuer only)' }) async create(@Req() req: any, @Body() dto: CreateCouponDto) { - // In real implementation, verify user is an approved issuer const coupon = await this.couponService.create(req.user.id, { - ...dto, faceValue: String(dto.faceValue), price: String(dto.price), - validFrom: new Date(dto.validFrom), validUntil: new Date(dto.validUntil), + ...dto, + faceValue: String(dto.faceValue), + price: String(dto.price), + validFrom: new Date(dto.validFrom), + validUntil: new Date(dto.validUntil), } as any); return { code: 0, data: coupon }; } @Get() @ApiOperation({ summary: 'List/search coupons' }) - async list( - @Query('page') page: string = '1', @Query('limit') limit: string = '20', - @Query('category') category?: string, @Query('status') status?: string, - @Query('search') search?: string, @Query('issuerId') issuerId?: string, - ) { - const result = await this.couponService.list(parseInt(page), parseInt(limit), { category, status, search, issuerId }); + async list(@Query() query: ListCouponsQueryDto) { + const result = await this.couponService.list( + query.page || 1, + query.limit || 20, + { + category: query.category, + status: query.status, + search: query.search, + issuerId: query.issuerId, + }, + ); return { code: 0, data: result }; } @@ -44,8 +56,8 @@ export class CouponController { @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Update coupon status' }) - async updateStatus(@Param('id') id: string, @Body('status') status: string) { - const coupon = await this.couponService.updateStatus(id, status as any); + async updateStatus(@Param('id') id: string, @Body() dto: UpdateCouponStatusDto) { + const coupon = await this.couponService.updateStatus(id, dto.status as any); return { code: 0, data: coupon }; } @@ -53,8 +65,8 @@ export class CouponController { @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() @ApiOperation({ summary: 'Purchase coupon' }) - async purchase(@Param('id') id: string, @Body('quantity') quantity: number) { - const result = await this.couponService.purchase(id, quantity || 1); + async purchase(@Param('id') id: string, @Body() dto: PurchaseCouponDto) { + const result = await this.couponService.purchase(id, dto.quantity || 1); return { code: 0, data: result }; } } diff --git a/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts b/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts index 8d3f55c..92bbf50 100644 --- a/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts +++ b/backend/services/issuer-service/src/interface/http/dto/coupon.dto.ts @@ -1,18 +1,149 @@ -import { IsString, IsOptional, IsNumber, IsBoolean, IsDateString, IsArray, Min } from 'class-validator'; +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + IsDateString, + IsArray, + Min, + Max, + MaxLength, + IsEnum, + IsInt, + ValidateNested, + IsObject, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; export class CreateCouponDto { - @ApiProperty() @IsString() name: string; - @ApiPropertyOptional() @IsOptional() @IsString() description?: string; - @ApiProperty() @IsString() type: string; - @ApiProperty() @IsString() category: string; - @ApiProperty() @IsNumber() faceValue: number; - @ApiProperty() @IsNumber() price: number; - @ApiProperty() @IsNumber() @Min(1) totalSupply: number; - @ApiPropertyOptional() @IsOptional() @IsString() imageUrl?: string; - @ApiProperty() @IsDateString() validFrom: string; - @ApiProperty() @IsDateString() validUntil: string; - @ApiPropertyOptional() @IsOptional() @IsBoolean() isTradable?: boolean; - @ApiPropertyOptional() @IsOptional() @IsBoolean() isTransferable?: boolean; - @ApiPropertyOptional() @IsOptional() @IsArray() rules?: any[]; + @ApiProperty({ maxLength: 200 }) + @IsString() + @MaxLength(200) + name: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ enum: ['discount', 'voucher', 'gift_card', 'loyalty'] }) + @IsString() + type: string; + + @ApiProperty() + @IsString() + @MaxLength(50) + category: string; + + @ApiProperty({ minimum: 0.01 }) + @IsNumber() + @Min(0.01) + faceValue: number; + + @ApiProperty({ minimum: 0.01 }) + @IsNumber() + @Min(0.01) + price: number; + + @ApiProperty({ minimum: 1 }) + @IsNumber() + @IsInt() + @Min(1) + totalSupply: number; + + @ApiPropertyOptional({ maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @ApiProperty({ description: 'ISO 8601 date string' }) + @IsDateString() + validFrom: string; + + @ApiProperty({ description: 'ISO 8601 date string' }) + @IsDateString() + validUntil: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isTradable?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isTransferable?: boolean; + + @ApiPropertyOptional({ type: [Object] }) + @IsOptional() + @IsArray() + rules?: any[]; +} + +export class UpdateCouponStatusDto { + @ApiProperty({ enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] }) + @IsString() + status: string; +} + +export class PurchaseCouponDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @IsNumber() + @IsInt() + @Min(1) + quantity?: number = 1; +} + +export class ListCouponsQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + category?: string; + + @ApiPropertyOptional({ enum: ['draft', 'active', 'paused', 'expired', 'sold_out'] }) + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + issuerId?: string; +} + +export class RejectCouponDto { + @ApiProperty({ description: 'Reason for rejecting the coupon' }) + @IsString() + @MaxLength(1000) + reason: string; +} + +export class FlagStoreDto { + @ApiProperty({ description: 'Reason for flagging the store' }) + @IsString() + @MaxLength(1000) + reason: string; } diff --git a/backend/services/issuer-service/src/interface/http/dto/issuer.dto.ts b/backend/services/issuer-service/src/interface/http/dto/issuer.dto.ts index 5b9dd5e..8e18ed5 100644 --- a/backend/services/issuer-service/src/interface/http/dto/issuer.dto.ts +++ b/backend/services/issuer-service/src/interface/http/dto/issuer.dto.ts @@ -1,12 +1,85 @@ -import { IsString, IsOptional, IsEmail, MaxLength } from 'class-validator'; +import { + IsString, + IsOptional, + IsEmail, + MaxLength, + IsEnum, + IsInt, + Min, + Max, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; export class RegisterIssuerDto { - @ApiProperty() @IsString() @MaxLength(200) companyName: string; - @ApiProperty() @IsString() @MaxLength(100) contactName: string; - @ApiProperty() @IsString() @MaxLength(20) contactPhone: string; - @ApiPropertyOptional() @IsOptional() @IsEmail() contactEmail?: string; - @ApiPropertyOptional() @IsOptional() @IsString() businessLicense?: string; - @ApiPropertyOptional() @IsOptional() @IsString() description?: string; - @ApiPropertyOptional() @IsOptional() @IsString() logoUrl?: string; + @ApiProperty({ maxLength: 200 }) + @IsString() + @MaxLength(200) + companyName: string; + + @ApiProperty({ maxLength: 100 }) + @IsString() + @MaxLength(100) + contactName: string; + + @ApiProperty({ maxLength: 20 }) + @IsString() + @MaxLength(20) + contactPhone: string; + + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + contactEmail?: string; + + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + logoUrl?: string; +} + +export class ListIssuersQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ enum: ['pending', 'active', 'suspended', 'rejected'] }) + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; +} + +export class RejectIssuerDto { + @ApiProperty({ description: 'Reason for rejecting the issuer application' }) + @IsString() + @MaxLength(1000) + reason: string; } diff --git a/backend/services/issuer-service/src/interface/http/guards/roles.guard.ts b/backend/services/issuer-service/src/interface/http/guards/roles.guard.ts new file mode 100644 index 0000000..1944261 --- /dev/null +++ b/backend/services/issuer-service/src/interface/http/guards/roles.guard.ts @@ -0,0 +1,48 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +export const ROLES_KEY = 'roles'; + +/** + * Decorator to specify which roles can access a route. + */ +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); + +/** + * Guard that checks user's role against the allowed roles metadata. + * Use with @Roles('admin') decorator on controller or handler. + */ +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException('No user context found'); + } + + if (!requiredRoles.includes(user.role)) { + throw new ForbiddenException(`Requires one of roles: ${requiredRoles.join(', ')}`); + } + + return true; + } +} diff --git a/backend/services/issuer-service/src/issuer.module.ts b/backend/services/issuer-service/src/issuer.module.ts index 4b9cc32..aa174f7 100644 --- a/backend/services/issuer-service/src/issuer.module.ts +++ b/backend/services/issuer-service/src/issuer.module.ts @@ -2,11 +2,35 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; + +// Domain entities import { Issuer } from './domain/entities/issuer.entity'; import { Coupon } from './domain/entities/coupon.entity'; import { Store } from './domain/entities/store.entity'; import { CouponRule } from './domain/entities/coupon-rule.entity'; import { CreditMetric } from './domain/entities/credit-metric.entity'; + +// Domain repository interfaces (Symbols) +import { ISSUER_REPOSITORY } from './domain/repositories/issuer.repository.interface'; +import { COUPON_REPOSITORY } from './domain/repositories/coupon.repository.interface'; +import { COUPON_RULE_REPOSITORY } from './domain/repositories/coupon-rule.repository.interface'; +import { STORE_REPOSITORY } from './domain/repositories/store.repository.interface'; +import { CREDIT_METRIC_REPOSITORY } from './domain/repositories/credit-metric.repository.interface'; + +// Infrastructure persistence implementations +import { IssuerRepository } from './infrastructure/persistence/issuer.repository'; +import { CouponRepository } from './infrastructure/persistence/coupon.repository'; +import { CouponRuleRepository } from './infrastructure/persistence/coupon-rule.repository'; +import { StoreRepository } from './infrastructure/persistence/store.repository'; +import { CreditMetricRepository } from './infrastructure/persistence/credit-metric.repository'; + +// Domain ports +import { AI_SERVICE_CLIENT } from './domain/ports/ai-service.client.interface'; + +// Infrastructure external services +import { AiServiceClient } from './infrastructure/external/ai-service.client'; + +// Application services import { IssuerService } from './application/services/issuer.service'; import { CouponService } from './application/services/coupon.service'; import { PricingService } from './application/services/pricing.service'; @@ -15,6 +39,8 @@ import { AdminIssuerService } from './application/services/admin-issuer.service' import { AdminCouponService } from './application/services/admin-coupon.service'; import { AdminCouponAnalyticsService } from './application/services/admin-coupon-analytics.service'; import { AdminMerchantService } from './application/services/admin-merchant.service'; + +// Interface controllers import { IssuerController } from './interface/http/controllers/issuer.controller'; import { CouponController } from './interface/http/controllers/coupon.controller'; import { AdminIssuerController } from './interface/http/controllers/admin-issuer.controller'; @@ -22,6 +48,9 @@ import { AdminCouponController } from './interface/http/controllers/admin-coupon import { AdminAnalyticsController } from './interface/http/controllers/admin-analytics.controller'; import { AdminMerchantController } from './interface/http/controllers/admin-merchant.controller'; +// Interface guards +import { RolesGuard } from './interface/http/guards/roles.guard'; + @Module({ imports: [ TypeOrmModule.forFeature([Issuer, Coupon, Store, CouponRule, CreditMetric]), @@ -37,6 +66,20 @@ import { AdminMerchantController } from './interface/http/controllers/admin-merc AdminMerchantController, ], providers: [ + // Infrastructure -> Domain port binding (Repository pattern) + { provide: ISSUER_REPOSITORY, useClass: IssuerRepository }, + { provide: COUPON_REPOSITORY, useClass: CouponRepository }, + { provide: COUPON_RULE_REPOSITORY, useClass: CouponRuleRepository }, + { provide: STORE_REPOSITORY, useClass: StoreRepository }, + { provide: CREDIT_METRIC_REPOSITORY, useClass: CreditMetricRepository }, + + // Infrastructure external services + { provide: AI_SERVICE_CLIENT, useClass: AiServiceClient }, + + // Infrastructure guards + RolesGuard, + + // Application services IssuerService, CouponService, PricingService, diff --git a/backend/services/notification-service/src/application/services/admin-notification.service.ts b/backend/services/notification-service/src/application/services/admin-notification.service.ts index b9fbd2f..67d675f 100644 --- a/backend/services/notification-service/src/application/services/admin-notification.service.ts +++ b/backend/services/notification-service/src/application/services/admin-notification.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Notification, NotificationChannel, NotificationStatus } from '../../domain/entities/notification.entity'; +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { NotificationChannel, NotificationStatus } from '../../domain/entities/notification.entity'; +import { NOTIFICATION_REPOSITORY, INotificationRepository } from '../../domain/repositories/notification.repository.interface'; import { NotificationService } from './notification.service'; +import { BroadcastDto } from '../../interface/http/dto/broadcast.dto'; export interface NotificationStats { totalSent: number; @@ -14,14 +14,6 @@ export interface NotificationStats { todaySent: number; } -export interface BroadcastDto { - title: string; - body: string; - channel: NotificationChannel; - segment?: 'all' | 'active' | 'new'; - data?: Record; -} - export interface NotificationTemplate { id: string; name: string; @@ -36,7 +28,8 @@ export class AdminNotificationService { private readonly logger = new Logger('AdminNotificationService'); constructor( - @InjectRepository(Notification) private readonly notificationRepo: Repository, + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: INotificationRepository, private readonly notificationService: NotificationService, ) {} @@ -44,29 +37,9 @@ export class AdminNotificationService { * Get notification delivery stats. */ async getStats(): Promise { - const statusCounts = await this.notificationRepo - .createQueryBuilder('n') - .select('n.status', 'status') - .addSelect('COUNT(n.id)', 'count') - .groupBy('n.status') - .getRawMany(); - - const channelCounts = await this.notificationRepo - .createQueryBuilder('n') - .select('n.channel', 'channel') - .addSelect('n.status', 'status') - .addSelect('COUNT(n.id)', 'count') - .groupBy('n.channel') - .addGroupBy('n.status') - .getRawMany(); - - // Today's sent count - const todayResult = await this.notificationRepo - .createQueryBuilder('n') - .select('COUNT(n.id)', 'count') - .where('n.status = :status', { status: NotificationStatus.SENT }) - .andWhere('n.sent_at >= CURRENT_DATE') - .getRawOne(); + const statusCounts = await this.notificationRepo.getStatusCounts(); + const channelCounts = await this.notificationRepo.getChannelStatusCounts(); + const todaySent = await this.notificationRepo.countTodaySent(); const statusMap: Record = {}; for (const row of statusCounts) { @@ -98,9 +71,9 @@ export class AdminNotificationService { totalFailed, totalPending, totalRead, - deliveryRate: Math.round(deliveryRate * 10000) / 100, // percentage with 2 decimals + deliveryRate: Math.round(deliveryRate * 10000) / 100, channelBreakdown, - todaySent: parseInt(todayResult?.count || '0', 10), + todaySent, }; } @@ -110,7 +83,6 @@ export class AdminNotificationService { */ async sendBroadcast(dto: BroadcastDto): Promise<{ queued: number; message: string }> { // Mock: In production, we would query user-service for user IDs - // For now, we create a single broadcast notification record const mockUserIds = this.getMockUserIds(dto.segment || 'all'); let queued = 0; @@ -202,8 +174,6 @@ export class AdminNotificationService { * Mock user IDs for broadcast (in production: query user-service). */ private getMockUserIds(segment: string): string[] { - // In production, this would be an inter-service call to user-service - // returning real user IDs based on the segment criteria const baseMockIds = [ '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', diff --git a/backend/services/notification-service/src/application/services/announcement.service.ts b/backend/services/notification-service/src/application/services/announcement.service.ts new file mode 100644 index 0000000..fa3d470 --- /dev/null +++ b/backend/services/notification-service/src/application/services/announcement.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common'; +import { + Announcement, + AnnouncementType, + TargetType, + AnnouncementTargetConfig, +} from '../../domain/entities/announcement.entity'; +import { + ANNOUNCEMENT_REPOSITORY, + IAnnouncementRepository, + AnnouncementWithReadStatus, +} from '../../domain/repositories/announcement.repository.interface'; +import { + USER_TAG_REPOSITORY, + IUserTagRepository, +} from '../../domain/repositories/user-tag.repository.interface'; + +export interface CreateAnnouncementParams { + title: string; + content: string; + type: AnnouncementType; + priority?: string; + targetType?: TargetType; + targetConfig?: { tags?: string[]; userIds?: string[] }; + imageUrl?: string; + linkUrl?: string; + publishedAt?: Date; + expiresAt?: Date; + createdBy?: string; +} + +export interface UpdateAnnouncementParams { + title?: string; + content?: string; + type?: AnnouncementType; + priority?: string; + targetType?: TargetType; + targetConfig?: { tags?: string[]; userIds?: string[] }; + imageUrl?: string | null; + linkUrl?: string | null; + isEnabled?: boolean; + publishedAt?: Date | null; + expiresAt?: Date | null; +} + +@Injectable() +export class AnnouncementService { + private readonly logger = new Logger(AnnouncementService.name); + + constructor( + @Inject(ANNOUNCEMENT_REPOSITORY) + private readonly announcementRepo: IAnnouncementRepository, + @Inject(USER_TAG_REPOSITORY) + private readonly userTagRepo: IUserTagRepository, + ) {} + + async create(params: CreateAnnouncementParams): Promise { + const targetType = params.targetType ?? TargetType.ALL; + + let targetConfig: AnnouncementTargetConfig | null = null; + if (params.targetConfig || targetType !== TargetType.ALL) { + targetConfig = { + type: targetType, + tags: params.targetConfig?.tags, + userIds: params.targetConfig?.userIds, + }; + } + + const announcement = this.announcementRepo.save( + Object.assign(new Announcement(), { + title: params.title, + content: params.content, + type: params.type, + priority: params.priority ?? 'NORMAL', + targetType, + targetConfig, + imageUrl: params.imageUrl ?? null, + linkUrl: params.linkUrl ?? null, + isEnabled: true, + publishedAt: params.publishedAt ?? null, + expiresAt: params.expiresAt ?? null, + createdBy: params.createdBy ?? null, + }), + ); + + const saved = await announcement; + + // Save targeting data to junction tables + await this.announcementRepo.clearTargets(saved.id); + + if (targetType === TargetType.BY_TAG && params.targetConfig?.tags?.length) { + await this.announcementRepo.saveTagTargets(saved.id, params.targetConfig.tags); + } + + if (targetType === TargetType.SPECIFIC && params.targetConfig?.userIds?.length) { + await this.announcementRepo.saveUserTargets(saved.id, params.targetConfig.userIds); + } + + this.logger.log(`Announcement created: ${saved.id} (targetType=${targetType})`); + return saved; + } + + async update(id: string, params: UpdateAnnouncementParams): Promise { + const existing = await this.announcementRepo.findById(id); + if (!existing) { + throw new NotFoundException('Announcement not found'); + } + + // Apply partial update + if (params.title !== undefined) existing.title = params.title; + if (params.content !== undefined) existing.content = params.content; + if (params.type !== undefined) existing.type = params.type; + if (params.priority !== undefined) existing.priority = params.priority as any; + if (params.isEnabled !== undefined) existing.isEnabled = params.isEnabled; + if (params.imageUrl !== undefined) existing.imageUrl = params.imageUrl; + if (params.linkUrl !== undefined) existing.linkUrl = params.linkUrl; + if (params.publishedAt !== undefined) existing.publishedAt = params.publishedAt; + if (params.expiresAt !== undefined) existing.expiresAt = params.expiresAt; + + // Update targeting + if (params.targetType !== undefined || params.targetConfig !== undefined) { + const targetType = params.targetType ?? existing.targetType; + existing.targetType = targetType; + + if (params.targetConfig || targetType !== TargetType.ALL) { + existing.targetConfig = { + type: targetType, + tags: params.targetConfig?.tags ?? existing.targetConfig?.tags, + userIds: params.targetConfig?.userIds ?? existing.targetConfig?.userIds, + }; + } else { + existing.targetConfig = null; + } + + // Refresh junction tables + await this.announcementRepo.clearTargets(id); + + if (targetType === TargetType.BY_TAG && existing.targetConfig?.tags?.length) { + await this.announcementRepo.saveTagTargets(id, existing.targetConfig.tags); + } + if (targetType === TargetType.SPECIFIC && existing.targetConfig?.userIds?.length) { + await this.announcementRepo.saveUserTargets(id, existing.targetConfig.userIds); + } + } + + const saved = await this.announcementRepo.save(existing); + this.logger.log(`Announcement updated: ${id}`); + return saved; + } + + async delete(id: string): Promise { + await this.announcementRepo.delete(id); + this.logger.log(`Announcement deleted: ${id}`); + } + + async findAll(params?: { + type?: AnnouncementType; + isEnabled?: boolean; + page?: number; + limit?: number; + }): Promise<{ items: Announcement[]; total: number; page: number; limit: number }> { + const page = params?.page ?? 1; + const limit = params?.limit ?? 20; + const offset = (page - 1) * limit; + + const [items, total] = await this.announcementRepo.findAll({ + type: params?.type, + isEnabled: params?.isEnabled, + limit, + offset, + }); + + return { items, total, page, limit }; + } + + async findById(id: string): Promise { + const announcement = await this.announcementRepo.findById(id); + if (!announcement) { + throw new NotFoundException('Announcement not found'); + } + return announcement; + } + + async getForUser( + userId: string, + params?: { type?: AnnouncementType; page?: number; limit?: number }, + ): Promise<{ + items: AnnouncementWithReadStatus[]; + unreadCount: number; + page: number; + limit: number; + }> { + const page = params?.page ?? 1; + const limit = params?.limit ?? 20; + const offset = (page - 1) * limit; + + const userTags = await this.userTagRepo.findTagsByUserId(userId); + + const [items, unreadCount] = await Promise.all([ + this.announcementRepo.findForUser({ + userId, + userTags, + type: params?.type, + limit, + offset, + }), + this.announcementRepo.countUnreadForUser(userId, userTags), + ]); + + return { items, unreadCount, page, limit }; + } + + async countUnreadForUser(userId: string): Promise { + const userTags = await this.userTagRepo.findTagsByUserId(userId); + return this.announcementRepo.countUnreadForUser(userId, userTags); + } + + async markAsRead(announcementId: string, userId: string): Promise { + await this.announcementRepo.markAsRead(announcementId, userId); + } + + async markAllAsRead(userId: string): Promise { + const userTags = await this.userTagRepo.findTagsByUserId(userId); + await this.announcementRepo.markAllAsRead(userId, userTags); + } +} diff --git a/backend/services/notification-service/src/application/services/event-consumer.service.ts b/backend/services/notification-service/src/application/services/event-consumer.service.ts index a50a320..f501599 100644 --- a/backend/services/notification-service/src/application/services/event-consumer.service.ts +++ b/backend/services/notification-service/src/application/services/event-consumer.service.ts @@ -15,7 +15,7 @@ export class EventConsumerService implements OnModuleInit { this.logger.log('Event consumer ready (will connect to Kafka when configured)'); } - async handleUserRegistered(event: { userId: string; phone?: string; email?: string }) { + async handleUserRegistered(event: { userId: string; phone?: string; email?: string }): Promise { await this.notificationService.send({ userId: event.userId, channel: NotificationChannel.IN_APP, @@ -25,7 +25,13 @@ export class EventConsumerService implements OnModuleInit { }); } - async handleTradeMatched(event: { buyerId: string; sellerId: string; couponId: string; price: number; quantity: number }) { + async handleTradeMatched(event: { + buyerId: string; + sellerId: string; + couponId: string; + price: number; + quantity: number; + }): Promise { await this.notificationService.send({ userId: event.buyerId, channel: NotificationChannel.IN_APP, @@ -42,7 +48,7 @@ export class EventConsumerService implements OnModuleInit { }); } - async handleKycApproved(event: { userId: string; level: number }) { + async handleKycApproved(event: { userId: string; level: number }): Promise { await this.notificationService.send({ userId: event.userId, channel: NotificationChannel.IN_APP, @@ -52,7 +58,7 @@ export class EventConsumerService implements OnModuleInit { }); } - async handleAmlAlert(event: { userId: string; pattern: string; riskScore: number }) { + async handleAmlAlert(event: { userId: string; pattern: string; riskScore: number }): Promise { await this.notificationService.send({ userId: event.userId, channel: NotificationChannel.IN_APP, diff --git a/backend/services/notification-service/src/application/services/notification.service.ts b/backend/services/notification-service/src/application/services/notification.service.ts index 0657de4..6cf0945 100644 --- a/backend/services/notification-service/src/application/services/notification.service.ts +++ b/backend/services/notification-service/src/application/services/notification.service.ts @@ -1,57 +1,107 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, Logger } from '@nestjs/common'; import { Notification, NotificationChannel, NotificationStatus } from '../../domain/entities/notification.entity'; +import { NOTIFICATION_REPOSITORY, INotificationRepository } from '../../domain/repositories/notification.repository.interface'; +import { + PUSH_NOTIFICATION_PROVIDER, + SMS_NOTIFICATION_PROVIDER, + EMAIL_NOTIFICATION_PROVIDER, + INotificationProvider, +} from '../../domain/ports/notification-provider.interface'; +import { NotificationChannelVO } from '../../domain/value-objects/notification-channel.vo'; +import { NotificationStatusVO } from '../../domain/value-objects/notification-status.vo'; +import { SendNotificationDto } from '../../interface/http/dto/send-notification.dto'; @Injectable() export class NotificationService { private readonly logger = new Logger('NotificationService'); - constructor(@InjectRepository(Notification) private readonly repo: Repository) {} + private readonly providerMap: Map; - async send(data: { userId: string; channel: NotificationChannel; title: string; body: string; data?: Record }): Promise { - const notification = this.repo.create({ ...data, status: NotificationStatus.PENDING }); - const saved = await this.repo.save(notification); + constructor( + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: INotificationRepository, + @Inject(PUSH_NOTIFICATION_PROVIDER) + private readonly pushProvider: INotificationProvider, + @Inject(SMS_NOTIFICATION_PROVIDER) + private readonly smsProvider: INotificationProvider, + @Inject(EMAIL_NOTIFICATION_PROVIDER) + private readonly emailProvider: INotificationProvider, + ) { + this.providerMap = new Map([ + [NotificationChannel.PUSH, this.pushProvider], + [NotificationChannel.SMS, this.smsProvider], + [NotificationChannel.EMAIL, this.emailProvider], + ]); + } - // Dispatch based on channel (mock implementations) - try { - switch (data.channel) { - case NotificationChannel.PUSH: await this.sendPush(saved); break; - case NotificationChannel.SMS: await this.sendSms(saved); break; - case NotificationChannel.EMAIL: await this.sendEmail(saved); break; - case NotificationChannel.IN_APP: break; // In-app notifications are just stored + async send(data: SendNotificationDto): Promise { + // Validate channel via Value Object + const channelVO = NotificationChannelVO.create(data.channel); + + const notification = await this.notificationRepo.create({ + userId: data.userId, + channel: channelVO.value, + title: data.title, + body: data.body, + data: data.data || null, + status: NotificationStatus.PENDING, + }); + + // Dispatch based on channel using provider abstraction + const statusVO = NotificationStatusVO.pending(); + + if (channelVO.requiresExternalDelivery) { + const provider = this.providerMap.get(channelVO.value); + if (provider) { + try { + const success = await provider.send(notification); + if (success) { + const newStatus = statusVO.transitionTo(NotificationStatus.SENT); + notification.status = newStatus.value; + notification.sentAt = new Date(); + } else { + const newStatus = statusVO.transitionTo(NotificationStatus.FAILED); + notification.status = newStatus.value; + } + } catch (error) { + this.logger.error(`Failed to send notification ${notification.id}: ${error.message}`); + const newStatus = statusVO.transitionTo(NotificationStatus.FAILED); + notification.status = newStatus.value; + } } - saved.status = NotificationStatus.SENT; - saved.sentAt = new Date(); - } catch (error) { - this.logger.error(`Failed to send notification ${saved.id}: ${error.message}`); - saved.status = NotificationStatus.FAILED; + } else { + // IN_APP notifications are considered sent immediately (just stored) + const newStatus = statusVO.transitionTo(NotificationStatus.SENT); + notification.status = newStatus.value; + notification.sentAt = new Date(); } - return this.repo.save(saved); + return this.notificationRepo.save(notification); } async getByUserId(userId: string, page: number, limit: number) { - const [items, total] = await this.repo.findAndCount({ where: { userId }, skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' } }); + const [items, total] = await this.notificationRepo.findByUserId( + userId, + (page - 1) * limit, + limit, + ); return { items, total, page, limit }; } - async markAsRead(id: string, userId: string) { - await this.repo.update({ id, userId }, { status: NotificationStatus.READ, readAt: new Date() }); + async markAsRead(id: string, userId: string): Promise { + const notification = await this.notificationRepo.findByIdAndUserId(id, userId); + if (!notification) { + return; + } + + // Use value object to validate status transition + const currentStatus = NotificationStatusVO.create(notification.status); + if (currentStatus.canTransitionTo(NotificationStatus.READ)) { + const newStatus = currentStatus.transitionTo(NotificationStatus.READ); + await this.notificationRepo.updateStatus(id, newStatus.value, { readAt: new Date() }); + } } async countUnread(userId: string): Promise { - return this.repo.count({ where: { userId, status: NotificationStatus.SENT } }); - } - - private async sendPush(n: Notification): Promise { - this.logger.log(`[MOCK] Push notification to ${n.userId}: ${n.title}`); - } - - private async sendSms(n: Notification): Promise { - this.logger.log(`[MOCK] SMS to ${n.userId}: ${n.title}`); - } - - private async sendEmail(n: Notification): Promise { - this.logger.log(`[MOCK] Email to ${n.userId}: ${n.title}`); + return this.notificationRepo.countByUserIdAndStatus(userId, NotificationStatus.SENT); } } diff --git a/backend/services/notification-service/src/application/services/user-tag.service.ts b/backend/services/notification-service/src/application/services/user-tag.service.ts new file mode 100644 index 0000000..9e445d4 --- /dev/null +++ b/backend/services/notification-service/src/application/services/user-tag.service.ts @@ -0,0 +1,42 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + USER_TAG_REPOSITORY, + IUserTagRepository, +} from '../../domain/repositories/user-tag.repository.interface'; + +@Injectable() +export class UserTagService { + private readonly logger = new Logger(UserTagService.name); + + constructor( + @Inject(USER_TAG_REPOSITORY) + private readonly userTagRepo: IUserTagRepository, + ) {} + + async getTagsByUserId(userId: string): Promise { + return this.userTagRepo.findTagsByUserId(userId); + } + + async addTag(userId: string, tag: string): Promise { + await this.userTagRepo.addTag(userId, tag); + this.logger.log(`Tag "${tag}" added to user ${userId}`); + } + + async removeTags(userId: string, tags: string[]): Promise { + await this.userTagRepo.removeTags(userId, tags); + this.logger.log(`Tags [${tags.join(', ')}] removed from user ${userId}`); + } + + async syncTags(userId: string, tags: string[]): Promise { + await this.userTagRepo.syncTags(userId, tags); + this.logger.log(`Tags synced for user ${userId}: [${tags.join(', ')}]`); + } + + async getAllTags(): Promise> { + return this.userTagRepo.getAllTags(); + } + + async getUserIdsByTag(tag: string): Promise { + return this.userTagRepo.findUserIdsByTag(tag); + } +} diff --git a/backend/services/notification-service/src/domain/entities/announcement-read.entity.ts b/backend/services/notification-service/src/domain/entities/announcement-read.entity.ts new file mode 100644 index 0000000..f53c40b --- /dev/null +++ b/backend/services/notification-service/src/domain/entities/announcement-read.entity.ts @@ -0,0 +1,26 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity('announcement_reads') +@Unique(['announcementId', 'userId']) +@Index('idx_ann_reads_user', ['userId']) +@Index('idx_ann_reads_ann', ['announcementId']) +export class AnnouncementRead { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'announcement_id', type: 'uuid' }) + announcementId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @CreateDateColumn({ name: 'read_at', type: 'timestamptz' }) + readAt: Date; +} diff --git a/backend/services/notification-service/src/domain/entities/announcement-tag-target.entity.ts b/backend/services/notification-service/src/domain/entities/announcement-tag-target.entity.ts new file mode 100644 index 0000000..b5ae8a2 --- /dev/null +++ b/backend/services/notification-service/src/domain/entities/announcement-tag-target.entity.ts @@ -0,0 +1,15 @@ +import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm'; + +@Entity('announcement_tag_targets') +@Index('idx_ann_tag_targets_ann', ['announcementId']) +@Index('idx_ann_tag_targets_tag', ['tag']) +export class AnnouncementTagTarget { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'announcement_id', type: 'uuid' }) + announcementId: string; + + @Column({ type: 'varchar', length: 100 }) + tag: string; +} diff --git a/backend/services/notification-service/src/domain/entities/announcement-user-target.entity.ts b/backend/services/notification-service/src/domain/entities/announcement-user-target.entity.ts new file mode 100644 index 0000000..048f1af --- /dev/null +++ b/backend/services/notification-service/src/domain/entities/announcement-user-target.entity.ts @@ -0,0 +1,15 @@ +import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm'; + +@Entity('announcement_user_targets') +@Index('idx_ann_user_targets_ann', ['announcementId']) +@Index('idx_ann_user_targets_user', ['userId']) +export class AnnouncementUserTarget { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'announcement_id', type: 'uuid' }) + announcementId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; +} diff --git a/backend/services/notification-service/src/domain/entities/announcement.entity.ts b/backend/services/notification-service/src/domain/entities/announcement.entity.ts new file mode 100644 index 0000000..0b3c040 --- /dev/null +++ b/backend/services/notification-service/src/domain/entities/announcement.entity.ts @@ -0,0 +1,127 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum AnnouncementType { + SYSTEM = 'SYSTEM', + ACTIVITY = 'ACTIVITY', + REWARD = 'REWARD', + UPGRADE = 'UPGRADE', + ANNOUNCEMENT = 'ANNOUNCEMENT', +} + +export enum AnnouncementPriority { + LOW = 'LOW', + NORMAL = 'NORMAL', + HIGH = 'HIGH', + URGENT = 'URGENT', +} + +export enum TargetType { + ALL = 'ALL', + BY_TAG = 'BY_TAG', + SPECIFIC = 'SPECIFIC', +} + +export interface AnnouncementTargetConfig { + type: TargetType; + tags?: string[]; + userIds?: string[]; +} + +@Entity('announcements') +@Index('idx_announcements_type', ['type']) +@Index('idx_announcements_target', ['targetType']) +@Index('idx_announcements_enabled', ['isEnabled', 'publishedAt']) +export class Announcement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text' }) + content: string; + + @Column({ type: 'varchar', length: 20, default: AnnouncementType.SYSTEM }) + type: AnnouncementType; + + @Column({ type: 'varchar', length: 10, default: AnnouncementPriority.NORMAL }) + priority: AnnouncementPriority; + + @Column({ name: 'target_type', type: 'varchar', length: 10, default: TargetType.ALL }) + targetType: TargetType; + + @Column({ name: 'target_config', type: 'jsonb', nullable: true }) + targetConfig: AnnouncementTargetConfig | null; + + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string | null; + + @Column({ name: 'link_url', type: 'varchar', length: 500, nullable: true }) + linkUrl: string | null; + + @Column({ name: 'is_enabled', type: 'boolean', default: true }) + isEnabled: boolean; + + @Column({ name: 'published_at', type: 'timestamptz', nullable: true }) + publishedAt: Date | null; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // ── Domain Methods ── + + isActive(): boolean { + if (!this.isEnabled || !this.publishedAt) { + return false; + } + const now = new Date(); + if (this.publishedAt > now) { + return false; + } + if (this.expiresAt && this.expiresAt < now) { + return false; + } + return true; + } + + isExpired(): boolean { + if (!this.expiresAt) { + return false; + } + return this.expiresAt < new Date(); + } + + isTargeted(): boolean { + return this.targetType !== TargetType.ALL; + } + + requiresTagFilter(): boolean { + return ( + this.targetType === TargetType.BY_TAG && + !!this.targetConfig?.tags?.length + ); + } + + isSpecificUsers(): boolean { + return ( + this.targetType === TargetType.SPECIFIC && + !!this.targetConfig?.userIds?.length + ); + } +} diff --git a/backend/services/notification-service/src/domain/entities/user-tag.entity.ts b/backend/services/notification-service/src/domain/entities/user-tag.entity.ts new file mode 100644 index 0000000..26cf8d0 --- /dev/null +++ b/backend/services/notification-service/src/domain/entities/user-tag.entity.ts @@ -0,0 +1,26 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity('user_tags') +@Unique(['userId', 'tag']) +@Index('idx_user_tags_user', ['userId']) +@Index('idx_user_tags_tag', ['tag']) +export class UserTag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ type: 'varchar', length: 100 }) + tag: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/backend/services/notification-service/src/domain/events/notification.events.ts b/backend/services/notification-service/src/domain/events/notification.events.ts new file mode 100644 index 0000000..00dca0b --- /dev/null +++ b/backend/services/notification-service/src/domain/events/notification.events.ts @@ -0,0 +1,59 @@ +import { NotificationChannel, NotificationStatus } from '../entities/notification.entity'; + +export interface NotificationCreatedEvent { + notificationId: string; + userId: string; + channel: NotificationChannel; + title: string; + timestamp: string; +} + +export interface NotificationSentEvent { + notificationId: string; + userId: string; + channel: NotificationChannel; + status: NotificationStatus; + sentAt: string; + timestamp: string; +} + +export interface NotificationReadEvent { + notificationId: string; + userId: string; + readAt: string; + timestamp: string; +} + +export interface NotificationFailedEvent { + notificationId: string; + userId: string; + channel: NotificationChannel; + reason: string; + timestamp: string; +} + +export interface BroadcastSentEvent { + broadcastId: string; + channel: NotificationChannel; + segment: string; + recipientCount: number; + timestamp: string; +} + +// ── Announcement Events ── + +export interface AnnouncementCreatedEvent { + announcementId: string; + title: string; + targetType: string; + createdBy: string | null; + timestamp: string; +} + +export interface AnnouncementPublishedEvent { + announcementId: string; + title: string; + targetType: string; + publishedAt: string; + timestamp: string; +} diff --git a/backend/services/notification-service/src/domain/ports/notification-provider.interface.ts b/backend/services/notification-service/src/domain/ports/notification-provider.interface.ts new file mode 100644 index 0000000..11173fd --- /dev/null +++ b/backend/services/notification-service/src/domain/ports/notification-provider.interface.ts @@ -0,0 +1,22 @@ +import { Notification, NotificationChannel } from '../entities/notification.entity'; + +/** + * Abstract interface for notification delivery providers. + * Each channel (PUSH, SMS, EMAIL) implements this interface. + */ +export interface INotificationProvider { + /** + * The channel this provider handles. + */ + readonly channel: NotificationChannel; + + /** + * Send a notification through this provider's channel. + * @returns true if sent successfully, false otherwise. + */ + send(notification: Notification): Promise; +} + +export const PUSH_NOTIFICATION_PROVIDER = Symbol('IPushNotificationProvider'); +export const SMS_NOTIFICATION_PROVIDER = Symbol('ISmsNotificationProvider'); +export const EMAIL_NOTIFICATION_PROVIDER = Symbol('IEmailNotificationProvider'); diff --git a/backend/services/notification-service/src/domain/repositories/announcement.repository.interface.ts b/backend/services/notification-service/src/domain/repositories/announcement.repository.interface.ts new file mode 100644 index 0000000..7f1df96 --- /dev/null +++ b/backend/services/notification-service/src/domain/repositories/announcement.repository.interface.ts @@ -0,0 +1,34 @@ +import { Announcement, AnnouncementType } from '../entities/announcement.entity'; + +export interface AnnouncementWithReadStatus { + announcement: Announcement; + isRead: boolean; + readAt: Date | null; +} + +export interface IAnnouncementRepository { + save(announcement: Announcement): Promise; + findById(id: string): Promise; + findAll(params?: { + type?: AnnouncementType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise<[Announcement[], number]>; + findForUser(params: { + userId: string; + userTags: string[]; + type?: AnnouncementType; + limit?: number; + offset?: number; + }): Promise; + countUnreadForUser(userId: string, userTags: string[]): Promise; + markAsRead(announcementId: string, userId: string): Promise; + markAllAsRead(userId: string, userTags: string[]): Promise; + delete(id: string): Promise; + saveTagTargets(announcementId: string, tags: string[]): Promise; + saveUserTargets(announcementId: string, userIds: string[]): Promise; + clearTargets(announcementId: string): Promise; +} + +export const ANNOUNCEMENT_REPOSITORY = Symbol('IAnnouncementRepository'); diff --git a/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts b/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts new file mode 100644 index 0000000..7a75814 --- /dev/null +++ b/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts @@ -0,0 +1,16 @@ +import { Notification, NotificationStatus } from '../entities/notification.entity'; + +export interface INotificationRepository { + findById(id: string): Promise; + findByIdAndUserId(id: string, userId: string): Promise; + findByUserId(userId: string, skip: number, take: number): Promise<[Notification[], number]>; + countByUserIdAndStatus(userId: string, status: NotificationStatus): Promise; + create(data: Partial): Promise; + save(notification: Notification): Promise; + updateStatus(id: string, status: NotificationStatus, extra?: Partial): Promise; + getStatusCounts(): Promise>; + getChannelStatusCounts(): Promise>; + countTodaySent(): Promise; +} + +export const NOTIFICATION_REPOSITORY = Symbol('INotificationRepository'); diff --git a/backend/services/notification-service/src/domain/repositories/user-tag.repository.interface.ts b/backend/services/notification-service/src/domain/repositories/user-tag.repository.interface.ts new file mode 100644 index 0000000..a1a50eb --- /dev/null +++ b/backend/services/notification-service/src/domain/repositories/user-tag.repository.interface.ts @@ -0,0 +1,10 @@ +export interface IUserTagRepository { + findTagsByUserId(userId: string): Promise; + findUserIdsByTag(tag: string): Promise; + addTag(userId: string, tag: string): Promise; + removeTags(userId: string, tags: string[]): Promise; + syncTags(userId: string, tags: string[]): Promise; + getAllTags(): Promise>; +} + +export const USER_TAG_REPOSITORY = Symbol('IUserTagRepository'); diff --git a/backend/services/notification-service/src/domain/value-objects/notification-channel.vo.ts b/backend/services/notification-service/src/domain/value-objects/notification-channel.vo.ts new file mode 100644 index 0000000..958d77e --- /dev/null +++ b/backend/services/notification-service/src/domain/value-objects/notification-channel.vo.ts @@ -0,0 +1,52 @@ +import { NotificationChannel } from '../entities/notification.entity'; + +/** + * Value Object for Notification Channel. + * Encapsulates channel validation and provides type-safe channel handling. + */ +export class NotificationChannelVO { + private static readonly VALID_CHANNELS = new Set( + Object.values(NotificationChannel), + ); + + private constructor(private readonly _value: NotificationChannel) {} + + static create(channel: string): NotificationChannelVO { + if (!NotificationChannelVO.VALID_CHANNELS.has(channel)) { + throw new Error( + `Invalid notification channel: "${channel}". Valid channels: ${Array.from(NotificationChannelVO.VALID_CHANNELS).join(', ')}`, + ); + } + return new NotificationChannelVO(channel as NotificationChannel); + } + + static fromEnum(channel: NotificationChannel): NotificationChannelVO { + return new NotificationChannelVO(channel); + } + + get value(): NotificationChannel { + return this._value; + } + + /** + * Whether this channel requires an external delivery provider (not just DB storage). + */ + get requiresExternalDelivery(): boolean { + return this._value !== NotificationChannel.IN_APP; + } + + /** + * Whether this channel supports rich content (HTML, markdown). + */ + get supportsRichContent(): boolean { + return this._value === NotificationChannel.EMAIL; + } + + equals(other: NotificationChannelVO): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/notification-service/src/domain/value-objects/notification-status.vo.ts b/backend/services/notification-service/src/domain/value-objects/notification-status.vo.ts new file mode 100644 index 0000000..c347827 --- /dev/null +++ b/backend/services/notification-service/src/domain/value-objects/notification-status.vo.ts @@ -0,0 +1,85 @@ +import { NotificationStatus } from '../entities/notification.entity'; + +/** + * Valid status transitions: + * PENDING -> SENT + * PENDING -> FAILED + * SENT -> READ + * FAILED -> PENDING (retry) + */ +const ALLOWED_TRANSITIONS: Record = { + [NotificationStatus.PENDING]: [NotificationStatus.SENT, NotificationStatus.FAILED], + [NotificationStatus.SENT]: [NotificationStatus.READ], + [NotificationStatus.FAILED]: [NotificationStatus.PENDING], + [NotificationStatus.READ]: [], +}; + +/** + * Value Object for Notification Status. + * Encapsulates status validation and enforces valid state transitions. + */ +export class NotificationStatusVO { + private constructor(private readonly _value: NotificationStatus) {} + + static create(status: NotificationStatus): NotificationStatusVO { + return new NotificationStatusVO(status); + } + + static pending(): NotificationStatusVO { + return new NotificationStatusVO(NotificationStatus.PENDING); + } + + get value(): NotificationStatus { + return this._value; + } + + get isPending(): boolean { + return this._value === NotificationStatus.PENDING; + } + + get isSent(): boolean { + return this._value === NotificationStatus.SENT; + } + + get isFailed(): boolean { + return this._value === NotificationStatus.FAILED; + } + + get isRead(): boolean { + return this._value === NotificationStatus.READ; + } + + get isTerminal(): boolean { + return this._value === NotificationStatus.READ; + } + + /** + * Transition to a new status, enforcing valid transitions. + * @throws Error if the transition is not allowed. + */ + transitionTo(newStatus: NotificationStatus): NotificationStatusVO { + const allowed = ALLOWED_TRANSITIONS[this._value]; + if (!allowed.includes(newStatus)) { + throw new Error( + `Invalid status transition: "${this._value}" -> "${newStatus}". ` + + `Allowed transitions from "${this._value}": [${allowed.join(', ')}]`, + ); + } + return new NotificationStatusVO(newStatus); + } + + /** + * Check if transition to the given status is valid without throwing. + */ + canTransitionTo(newStatus: NotificationStatus): boolean { + return ALLOWED_TRANSITIONS[this._value].includes(newStatus); + } + + equals(other: NotificationStatusVO): boolean { + return this._value === other._value; + } + + toString(): string { + return this._value; + } +} diff --git a/backend/services/notification-service/src/infrastructure/persistence/announcement.repository.ts b/backend/services/notification-service/src/infrastructure/persistence/announcement.repository.ts new file mode 100644 index 0000000..9651658 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/persistence/announcement.repository.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Announcement, AnnouncementType, TargetType } from '../../domain/entities/announcement.entity'; +import { AnnouncementRead } from '../../domain/entities/announcement-read.entity'; +import { AnnouncementTagTarget } from '../../domain/entities/announcement-tag-target.entity'; +import { AnnouncementUserTarget } from '../../domain/entities/announcement-user-target.entity'; +import { + IAnnouncementRepository, + AnnouncementWithReadStatus, +} from '../../domain/repositories/announcement.repository.interface'; + +@Injectable() +export class AnnouncementRepositoryImpl implements IAnnouncementRepository { + constructor( + @InjectRepository(Announcement) + private readonly announcementRepo: Repository, + @InjectRepository(AnnouncementRead) + private readonly readRepo: Repository, + @InjectRepository(AnnouncementTagTarget) + private readonly tagTargetRepo: Repository, + @InjectRepository(AnnouncementUserTarget) + private readonly userTargetRepo: Repository, + ) {} + + async save(announcement: Announcement): Promise { + return this.announcementRepo.save(announcement); + } + + async findById(id: string): Promise { + return this.announcementRepo.findOne({ where: { id } }); + } + + async findAll(params?: { + type?: AnnouncementType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise<[Announcement[], number]> { + const qb = this.announcementRepo.createQueryBuilder('a'); + + if (params?.type) { + qb.andWhere('a.type = :type', { type: params.type }); + } + if (params?.isEnabled !== undefined) { + qb.andWhere('a.is_enabled = :isEnabled', { isEnabled: params.isEnabled }); + } + + qb.orderBy('a.created_at', 'DESC'); + qb.take(params?.limit ?? 50); + qb.skip(params?.offset ?? 0); + + return qb.getManyAndCount(); + } + + async findForUser(params: { + userId: string; + userTags: string[]; + type?: AnnouncementType; + limit?: number; + offset?: number; + }): Promise { + const now = new Date(); + const limit = params.limit ?? 50; + const offset = params.offset ?? 0; + + // Build subquery for visible announcements + const qb = this.announcementRepo.createQueryBuilder('a'); + + qb.andWhere('a.is_enabled = true'); + + // Published: publishedAt is null (immediate) or publishedAt <= now + qb.andWhere('(a.published_at IS NULL OR a.published_at <= :now)', { now }); + + // Not expired: expiresAt is null or expiresAt > now + qb.andWhere('(a.expires_at IS NULL OR a.expires_at > :now)', { now }); + + if (params.type) { + qb.andWhere('a.type = :type', { type: params.type }); + } + + // Target filtering: ALL, BY_TAG (user has matching tag), SPECIFIC (user in target list) + const targetConditions = [`a.target_type = '${TargetType.ALL}'`]; + + if (params.userTags.length > 0) { + targetConditions.push( + `(a.target_type = '${TargetType.BY_TAG}' AND EXISTS ( + SELECT 1 FROM announcement_tag_targets att + WHERE att.announcement_id = a.id AND att.tag IN (:...userTags) + ))`, + ); + } + + targetConditions.push( + `(a.target_type = '${TargetType.SPECIFIC}' AND EXISTS ( + SELECT 1 FROM announcement_user_targets aut + WHERE aut.announcement_id = a.id AND aut.user_id = :userId + ))`, + ); + + qb.andWhere(`(${targetConditions.join(' OR ')})`, { + userId: params.userId, + ...(params.userTags.length > 0 ? { userTags: params.userTags } : {}), + }); + + qb.orderBy('a.published_at', 'DESC', 'NULLS FIRST'); + qb.addOrderBy('a.created_at', 'DESC'); + qb.take(limit); + qb.skip(offset); + + const announcements = await qb.getMany(); + + if (announcements.length === 0) { + return []; + } + + // Fetch read status for these announcements + const announcementIds = announcements.map((a) => a.id); + const reads = await this.readRepo + .createQueryBuilder('r') + .where('r.announcement_id IN (:...ids)', { ids: announcementIds }) + .andWhere('r.user_id = :userId', { userId: params.userId }) + .getMany(); + + const readMap = new Map(); + for (const r of reads) { + readMap.set(r.announcementId, r.readAt); + } + + return announcements.map((a) => ({ + announcement: a, + isRead: readMap.has(a.id), + readAt: readMap.get(a.id) ?? null, + })); + } + + async countUnreadForUser(userId: string, userTags: string[]): Promise { + const now = new Date(); + + const qb = this.announcementRepo.createQueryBuilder('a'); + + qb.andWhere('a.is_enabled = true'); + qb.andWhere('(a.published_at IS NULL OR a.published_at <= :now)', { now }); + qb.andWhere('(a.expires_at IS NULL OR a.expires_at > :now)', { now }); + + // Exclude already-read + qb.andWhere( + `NOT EXISTS ( + SELECT 1 FROM announcement_reads ar + WHERE ar.announcement_id = a.id AND ar.user_id = :userId + )`, + { userId }, + ); + + // Target filtering + const targetConditions = [`a.target_type = '${TargetType.ALL}'`]; + + if (userTags.length > 0) { + targetConditions.push( + `(a.target_type = '${TargetType.BY_TAG}' AND EXISTS ( + SELECT 1 FROM announcement_tag_targets att + WHERE att.announcement_id = a.id AND att.tag IN (:...userTags) + ))`, + ); + } + + targetConditions.push( + `(a.target_type = '${TargetType.SPECIFIC}' AND EXISTS ( + SELECT 1 FROM announcement_user_targets aut + WHERE aut.announcement_id = a.id AND aut.user_id = :userId2 + ))`, + ); + + qb.andWhere(`(${targetConditions.join(' OR ')})`, { + userId2: userId, + ...(userTags.length > 0 ? { userTags } : {}), + }); + + return qb.getCount(); + } + + async markAsRead(announcementId: string, userId: string): Promise { + // Upsert: insert if not exists, ignore if already exists + await this.readRepo + .createQueryBuilder() + .insert() + .into(AnnouncementRead) + .values({ announcementId, userId }) + .orIgnore() + .execute(); + } + + async markAllAsRead(userId: string, userTags: string[]): Promise { + const now = new Date(); + + // Find all unread announcement IDs for this user + const qb = this.announcementRepo.createQueryBuilder('a'); + qb.select('a.id'); + qb.andWhere('a.is_enabled = true'); + qb.andWhere('(a.published_at IS NULL OR a.published_at <= :now)', { now }); + qb.andWhere('(a.expires_at IS NULL OR a.expires_at > :now)', { now }); + qb.andWhere( + `NOT EXISTS ( + SELECT 1 FROM announcement_reads ar + WHERE ar.announcement_id = a.id AND ar.user_id = :userId + )`, + { userId }, + ); + + const targetConditions = [`a.target_type = '${TargetType.ALL}'`]; + if (userTags.length > 0) { + targetConditions.push( + `(a.target_type = '${TargetType.BY_TAG}' AND EXISTS ( + SELECT 1 FROM announcement_tag_targets att + WHERE att.announcement_id = a.id AND att.tag IN (:...userTags) + ))`, + ); + } + targetConditions.push( + `(a.target_type = '${TargetType.SPECIFIC}' AND EXISTS ( + SELECT 1 FROM announcement_user_targets aut + WHERE aut.announcement_id = a.id AND aut.user_id = :userId2 + ))`, + ); + qb.andWhere(`(${targetConditions.join(' OR ')})`, { + userId2: userId, + ...(userTags.length > 0 ? { userTags } : {}), + }); + + const unreadAnnouncements = await qb.getRawMany(); + + if (unreadAnnouncements.length === 0) { + return; + } + + const values = unreadAnnouncements.map((a: { a_id: string }) => ({ + announcementId: a.a_id, + userId, + })); + + await this.readRepo + .createQueryBuilder() + .insert() + .into(AnnouncementRead) + .values(values) + .orIgnore() + .execute(); + } + + async delete(id: string): Promise { + await this.announcementRepo.delete(id); + } + + async saveTagTargets(announcementId: string, tags: string[]): Promise { + if (tags.length === 0) return; + + const values = tags.map((tag) => ({ announcementId, tag })); + await this.tagTargetRepo + .createQueryBuilder() + .insert() + .into(AnnouncementTagTarget) + .values(values) + .execute(); + } + + async saveUserTargets(announcementId: string, userIds: string[]): Promise { + if (userIds.length === 0) return; + + const values = userIds.map((userId) => ({ announcementId, userId })); + await this.userTargetRepo + .createQueryBuilder() + .insert() + .into(AnnouncementUserTarget) + .values(values) + .execute(); + } + + async clearTargets(announcementId: string): Promise { + await this.tagTargetRepo.delete({ announcementId }); + await this.userTargetRepo.delete({ announcementId }); + } +} diff --git a/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts b/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts new file mode 100644 index 0000000..fbfdb70 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Notification, NotificationStatus } from '../../domain/entities/notification.entity'; +import { INotificationRepository } from '../../domain/repositories/notification.repository.interface'; + +@Injectable() +export class NotificationRepository implements INotificationRepository { + constructor( + @InjectRepository(Notification) + private readonly repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findByIdAndUserId(id: string, userId: string): Promise { + return this.repo.findOne({ where: { id, userId } }); + } + + async findByUserId(userId: string, skip: number, take: number): Promise<[Notification[], number]> { + return this.repo.findAndCount({ + where: { userId }, + skip, + take, + order: { createdAt: 'DESC' }, + }); + } + + async countByUserIdAndStatus(userId: string, status: NotificationStatus): Promise { + return this.repo.count({ where: { userId, status } }); + } + + async create(data: Partial): Promise { + const notification = this.repo.create(data); + return this.repo.save(notification); + } + + async save(notification: Notification): Promise { + return this.repo.save(notification); + } + + async updateStatus(id: string, status: NotificationStatus, extra?: Partial): Promise { + await this.repo.update(id, { status, ...extra }); + } + + async getStatusCounts(): Promise> { + return this.repo + .createQueryBuilder('n') + .select('n.status', 'status') + .addSelect('COUNT(n.id)', 'count') + .groupBy('n.status') + .getRawMany(); + } + + async getChannelStatusCounts(): Promise> { + return this.repo + .createQueryBuilder('n') + .select('n.channel', 'channel') + .addSelect('n.status', 'status') + .addSelect('COUNT(n.id)', 'count') + .groupBy('n.channel') + .addGroupBy('n.status') + .getRawMany(); + } + + async countTodaySent(): Promise { + const result = await this.repo + .createQueryBuilder('n') + .select('COUNT(n.id)', 'count') + .where('n.status = :status', { status: NotificationStatus.SENT }) + .andWhere('n.sent_at >= CURRENT_DATE') + .getRawOne(); + return parseInt(result?.count || '0', 10); + } +} diff --git a/backend/services/notification-service/src/infrastructure/persistence/user-tag.repository.ts b/backend/services/notification-service/src/infrastructure/persistence/user-tag.repository.ts new file mode 100644 index 0000000..19ed622 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/persistence/user-tag.repository.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { UserTag } from '../../domain/entities/user-tag.entity'; +import { IUserTagRepository } from '../../domain/repositories/user-tag.repository.interface'; + +@Injectable() +export class UserTagRepositoryImpl implements IUserTagRepository { + constructor( + @InjectRepository(UserTag) + private readonly repo: Repository, + ) {} + + async findTagsByUserId(userId: string): Promise { + const tags = await this.repo.find({ where: { userId }, select: ['tag'] }); + return tags.map((t) => t.tag); + } + + async findUserIdsByTag(tag: string): Promise { + const rows = await this.repo.find({ where: { tag }, select: ['userId'] }); + return rows.map((r) => r.userId); + } + + async addTag(userId: string, tag: string): Promise { + await this.repo + .createQueryBuilder() + .insert() + .into(UserTag) + .values({ userId, tag }) + .orIgnore() + .execute(); + } + + async removeTags(userId: string, tags: string[]): Promise { + if (tags.length === 0) return; + await this.repo.delete({ userId, tag: In(tags) }); + } + + async syncTags(userId: string, tags: string[]): Promise { + // Delete all existing tags for this user + await this.repo.delete({ userId }); + + if (tags.length === 0) return; + + // Insert new tags + const values = tags.map((tag) => ({ userId, tag })); + await this.repo + .createQueryBuilder() + .insert() + .into(UserTag) + .values(values) + .orIgnore() + .execute(); + } + + async getAllTags(): Promise> { + const result = await this.repo + .createQueryBuilder('ut') + .select('ut.tag', 'tag') + .addSelect('COUNT(ut.id)', 'count') + .groupBy('ut.tag') + .orderBy('count', 'DESC') + .getRawMany(); + + return result.map((r) => ({ tag: r.tag, count: parseInt(r.count, 10) })); + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/email-notification.provider.ts b/backend/services/notification-service/src/infrastructure/providers/email-notification.provider.ts new file mode 100644 index 0000000..2b4e8a9 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/email-notification.provider.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Notification, NotificationChannel } from '../../domain/entities/notification.entity'; +import { INotificationProvider } from '../../domain/ports/notification-provider.interface'; + +/** + * Email notification provider. + * In production, this would integrate with SendGrid / AWS SES / SMTP. + * Currently a mock implementation with proper DDD structure. + */ +@Injectable() +export class EmailNotificationProvider implements INotificationProvider { + private readonly logger = new Logger('EmailNotificationProvider'); + readonly channel = NotificationChannel.EMAIL; + + async send(notification: Notification): Promise { + // TODO: Integrate with email service (SendGrid, AWS SES, etc.) in production + this.logger.log( + `[MOCK] Email sent to user ${notification.userId}: "${notification.title}"`, + ); + return true; + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/notification-provider.interface.ts b/backend/services/notification-service/src/infrastructure/providers/notification-provider.interface.ts new file mode 100644 index 0000000..39434b1 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/notification-provider.interface.ts @@ -0,0 +1,22 @@ +import { Notification, NotificationChannel } from '../../domain/entities/notification.entity'; + +/** + * Abstract interface for notification delivery providers. + * Each channel (PUSH, SMS, EMAIL) implements this interface. + */ +export interface INotificationProvider { + /** + * The channel this provider handles. + */ + readonly channel: NotificationChannel; + + /** + * Send a notification through this provider's channel. + * @returns true if sent successfully, false otherwise. + */ + send(notification: Notification): Promise; +} + +export const PUSH_NOTIFICATION_PROVIDER = Symbol('IPushNotificationProvider'); +export const SMS_NOTIFICATION_PROVIDER = Symbol('ISmsNotificationProvider'); +export const EMAIL_NOTIFICATION_PROVIDER = Symbol('IEmailNotificationProvider'); diff --git a/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts b/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts new file mode 100644 index 0000000..da8403b --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/push-notification.provider.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Notification, NotificationChannel } from '../../domain/entities/notification.entity'; +import { INotificationProvider } from '../../domain/ports/notification-provider.interface'; + +/** + * Push notification provider. + * In production, this would integrate with FCM/APNs/Web Push. + * Currently a mock implementation with proper DDD structure. + */ +@Injectable() +export class PushNotificationProvider implements INotificationProvider { + private readonly logger = new Logger('PushNotificationProvider'); + readonly channel = NotificationChannel.PUSH; + + async send(notification: Notification): Promise { + // TODO: Integrate with FCM / APNs in production + this.logger.log( + `[MOCK] Push notification sent to user ${notification.userId}: "${notification.title}"`, + ); + return true; + } +} diff --git a/backend/services/notification-service/src/infrastructure/providers/sms-notification.provider.ts b/backend/services/notification-service/src/infrastructure/providers/sms-notification.provider.ts new file mode 100644 index 0000000..ece93b6 --- /dev/null +++ b/backend/services/notification-service/src/infrastructure/providers/sms-notification.provider.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Notification, NotificationChannel } from '../../domain/entities/notification.entity'; +import { INotificationProvider } from '../../domain/ports/notification-provider.interface'; + +/** + * SMS notification provider. + * In production, this would integrate with Twilio / Alibaba Cloud SMS / AWS SNS. + * Currently a mock implementation with proper DDD structure. + */ +@Injectable() +export class SmsNotificationProvider implements INotificationProvider { + private readonly logger = new Logger('SmsNotificationProvider'); + readonly channel = NotificationChannel.SMS; + + async send(notification: Notification): Promise { + // TODO: Integrate with SMS gateway (Twilio, Alibaba Cloud SMS, etc.) in production + this.logger.log( + `[MOCK] SMS sent to user ${notification.userId}: "${notification.title}"`, + ); + return true; + } +} diff --git a/backend/services/notification-service/src/interface/http/controllers/admin-notification.controller.ts b/backend/services/notification-service/src/interface/http/controllers/admin-notification.controller.ts index cf96ccf..b3c2248 100644 --- a/backend/services/notification-service/src/interface/http/controllers/admin-notification.controller.ts +++ b/backend/services/notification-service/src/interface/http/controllers/admin-notification.controller.ts @@ -1,7 +1,8 @@ import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, RolesGuard, Roles, UserRole } from '@genex/common'; -import { AdminNotificationService, BroadcastDto } from '../../../application/services/admin-notification.service'; +import { AdminNotificationService } from '../../../application/services/admin-notification.service'; +import { BroadcastDto } from '../dto/broadcast.dto'; @ApiTags('Admin - Notifications') @Controller('admin/notifications') diff --git a/backend/services/notification-service/src/interface/http/controllers/announcement.controller.ts b/backend/services/notification-service/src/interface/http/controllers/announcement.controller.ts new file mode 100644 index 0000000..2746cfa --- /dev/null +++ b/backend/services/notification-service/src/interface/http/controllers/announcement.controller.ts @@ -0,0 +1,212 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Req, + HttpCode, + HttpStatus, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { JwtAuthGuard, RolesGuard, Roles, UserRole } from '@genex/common'; +import { AnnouncementService } from '../../../application/services/announcement.service'; +import { UserTagService } from '../../../application/services/user-tag.service'; +import { + CreateAnnouncementDto, + UpdateAnnouncementDto, + ListAnnouncementsQueryDto, + UserAnnouncementsQueryDto, +} from '../dto/announcement.dto'; +import { AddTagDto, RemoveTagsDto, SyncTagsDto } from '../dto/user-tag.dto'; + +// ────────────────────────────────────────────── +// Admin Announcement Controller +// ────────────────────────────────────────────── + +@ApiTags('Admin - Announcements') +@Controller('admin/announcements') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@ApiBearerAuth() +export class AdminAnnouncementController { + constructor(private readonly announcementService: AnnouncementService) {} + + @Post() + @ApiOperation({ summary: 'Create announcement with targeting (ALL / BY_TAG / SPECIFIC)' }) + async create(@Body() dto: CreateAnnouncementDto, @Req() req: any) { + const announcement = await this.announcementService.create({ + title: dto.title, + content: dto.content, + type: dto.type, + priority: dto.priority, + targetType: dto.targetType, + targetConfig: dto.targetConfig, + imageUrl: dto.imageUrl, + linkUrl: dto.linkUrl, + publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : undefined, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + createdBy: req.user?.id, + }); + return { code: 0, data: announcement }; + } + + @Get() + @ApiOperation({ summary: 'List announcements (admin)' }) + async findAll(@Query() query: ListAnnouncementsQueryDto) { + const result = await this.announcementService.findAll({ + type: query.type, + isEnabled: query.isEnabled, + page: query.page, + limit: query.limit, + }); + return { code: 0, data: result }; + } + + @Get(':id') + @ApiOperation({ summary: 'Get announcement detail' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const announcement = await this.announcementService.findById(id); + return { code: 0, data: announcement }; + } + + @Put(':id') + @ApiOperation({ summary: 'Update announcement' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAnnouncementDto, + ) { + const announcement = await this.announcementService.update(id, { + title: dto.title, + content: dto.content, + type: dto.type, + priority: dto.priority, + targetType: dto.targetType, + targetConfig: dto.targetConfig, + imageUrl: dto.imageUrl, + linkUrl: dto.linkUrl, + isEnabled: dto.isEnabled, + publishedAt: dto.publishedAt !== undefined + ? dto.publishedAt ? new Date(dto.publishedAt) : null + : undefined, + expiresAt: dto.expiresAt !== undefined + ? dto.expiresAt ? new Date(dto.expiresAt) : null + : undefined, + }); + return { code: 0, data: announcement }; + } + + @Delete(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Delete announcement' }) + async delete(@Param('id', ParseUUIDPipe) id: string) { + await this.announcementService.delete(id); + return { code: 0, data: null }; + } +} + +// ────────────────────────────────────────────── +// Admin User Tag Controller +// ────────────────────────────────────────────── + +@ApiTags('Admin - User Tags') +@Controller('admin/user-tags') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@ApiBearerAuth() +export class AdminUserTagController { + constructor(private readonly userTagService: UserTagService) {} + + @Get() + @ApiOperation({ summary: 'List all tags with user count' }) + async getAllTags() { + return { code: 0, data: await this.userTagService.getAllTags() }; + } + + @Get(':userId') + @ApiOperation({ summary: 'Get tags for a specific user' }) + async getUserTags(@Param('userId', ParseUUIDPipe) userId: string) { + return { code: 0, data: await this.userTagService.getTagsByUserId(userId) }; + } + + @Post(':userId') + @ApiOperation({ summary: 'Add a tag to a user' }) + async addTag( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: AddTagDto, + ) { + await this.userTagService.addTag(userId, dto.tag); + return { code: 0, data: null }; + } + + @Delete(':userId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Remove tags from a user' }) + async removeTags( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: RemoveTagsDto, + ) { + await this.userTagService.removeTags(userId, dto.tags); + return { code: 0, data: null }; + } + + @Put(':userId/sync') + @ApiOperation({ summary: 'Sync (replace all) user tags' }) + async syncTags( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: SyncTagsDto, + ) { + await this.userTagService.syncTags(userId, dto.tags); + return { code: 0, data: null }; + } +} + +// ────────────────────────────────────────────── +// User Announcement Controller (mobile/web) +// ────────────────────────────────────────────── + +@ApiTags('Announcements') +@Controller('announcements') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class UserAnnouncementController { + constructor(private readonly announcementService: AnnouncementService) {} + + @Get() + @ApiOperation({ summary: 'Get announcements for current user (filtered by targeting)' }) + async list(@Req() req: any, @Query() query: UserAnnouncementsQueryDto) { + const result = await this.announcementService.getForUser(req.user.id, { + type: query.type, + page: query.page, + limit: query.limit, + }); + return { code: 0, data: result }; + } + + @Get('unread-count') + @ApiOperation({ summary: 'Get unread announcement count' }) + async unreadCount(@Req() req: any) { + const count = await this.announcementService.countUnreadForUser(req.user.id); + return { code: 0, data: { count } }; + } + + @Put(':id/read') + @ApiOperation({ summary: 'Mark announcement as read' }) + async markAsRead(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { + await this.announcementService.markAsRead(id, req.user.id); + return { code: 0, data: null }; + } + + @Put('read-all') + @ApiOperation({ summary: 'Mark all announcements as read' }) + async markAllAsRead(@Req() req: any) { + await this.announcementService.markAllAsRead(req.user.id); + return { code: 0, data: null }; + } +} diff --git a/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts b/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts index 3238746..8fac5ff 100644 --- a/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts +++ b/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts @@ -2,6 +2,8 @@ import { Controller, Get, Put, Param, Query, UseGuards, Req } from '@nestjs/comm import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { NotificationService } from '../../../application/services/notification.service'; +import { NotificationQueryDto } from '../dto/notification-query.dto'; +import { MarkReadParamDto } from '../dto/mark-read.dto'; @ApiTags('Notifications') @Controller('notifications') @@ -12,8 +14,8 @@ export class NotificationController { @Get() @ApiOperation({ summary: 'Get user notifications' }) - async list(@Req() req: any, @Query('page') page = '1', @Query('limit') limit = '20') { - return { code: 0, data: await this.notificationService.getByUserId(req.user.id, +page, +limit) }; + async list(@Req() req: any, @Query() query: NotificationQueryDto) { + return { code: 0, data: await this.notificationService.getByUserId(req.user.id, query.page, query.limit) }; } @Get('unread-count') @@ -24,8 +26,8 @@ export class NotificationController { @Put(':id/read') @ApiOperation({ summary: 'Mark notification as read' }) - async markAsRead(@Param('id') id: string, @Req() req: any) { - await this.notificationService.markAsRead(id, req.user.id); + async markAsRead(@Param() params: MarkReadParamDto, @Req() req: any) { + await this.notificationService.markAsRead(params.id, req.user.id); return { code: 0, data: null }; } } diff --git a/backend/services/notification-service/src/interface/http/dto/announcement.dto.ts b/backend/services/notification-service/src/interface/http/dto/announcement.dto.ts new file mode 100644 index 0000000..b307083 --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/announcement.dto.ts @@ -0,0 +1,209 @@ +import { + IsString, + IsOptional, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + IsArray, + IsUUID, + Min, + Max, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + AnnouncementType, + AnnouncementPriority, + TargetType, +} from '../../../domain/entities/announcement.entity'; + +// ── Target Config ── + +export class TargetConfigDto { + @ApiPropertyOptional({ description: 'Tag list for BY_TAG targeting', example: ['vip', 'active'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ description: 'User ID list for SPECIFIC targeting', example: ['uuid-1', 'uuid-2'] }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + userIds?: string[]; +} + +// ── Create ── + +export class CreateAnnouncementDto { + @ApiProperty({ description: 'Announcement title', example: 'System Maintenance', maxLength: 200 }) + @IsString() + @MaxLength(200) + title: string; + + @ApiProperty({ description: 'Announcement content', example: 'The platform will undergo scheduled maintenance.' }) + @IsString() + content: string; + + @ApiProperty({ description: 'Announcement type', enum: AnnouncementType, example: AnnouncementType.SYSTEM }) + @IsEnum(AnnouncementType) + type: AnnouncementType; + + @ApiPropertyOptional({ description: 'Priority', enum: AnnouncementPriority, default: AnnouncementPriority.NORMAL }) + @IsOptional() + @IsEnum(AnnouncementPriority) + priority?: AnnouncementPriority; + + @ApiPropertyOptional({ description: 'Target type', enum: TargetType, default: TargetType.ALL }) + @IsOptional() + @IsEnum(TargetType) + targetType?: TargetType; + + @ApiPropertyOptional({ description: 'Target configuration (tags or userIds)' }) + @IsOptional() + @ValidateNested() + @Type(() => TargetConfigDto) + targetConfig?: TargetConfigDto; + + @ApiPropertyOptional({ description: 'Image URL', example: 'https://cdn.example.com/banner.png' }) + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @ApiPropertyOptional({ description: 'Link URL', example: 'https://genex.com/promo' }) + @IsOptional() + @IsString() + @MaxLength(500) + linkUrl?: string; + + @ApiPropertyOptional({ description: 'Publish time (null = immediate)', example: '2025-06-01T00:00:00Z' }) + @IsOptional() + @IsDateString() + publishedAt?: string; + + @ApiPropertyOptional({ description: 'Expiration time', example: '2025-12-31T23:59:59Z' }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +// ── Update ── + +export class UpdateAnnouncementDto { + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + content?: string; + + @ApiPropertyOptional({ enum: AnnouncementType }) + @IsOptional() + @IsEnum(AnnouncementType) + type?: AnnouncementType; + + @ApiPropertyOptional({ enum: AnnouncementPriority }) + @IsOptional() + @IsEnum(AnnouncementPriority) + priority?: AnnouncementPriority; + + @ApiPropertyOptional({ enum: TargetType }) + @IsOptional() + @IsEnum(TargetType) + targetType?: TargetType; + + @ApiPropertyOptional() + @IsOptional() + @ValidateNested() + @Type(() => TargetConfigDto) + targetConfig?: TargetConfigDto; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + linkUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + publishedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +// ── Query (Admin) ── + +export class ListAnnouncementsQueryDto { + @ApiPropertyOptional({ enum: AnnouncementType }) + @IsOptional() + @IsEnum(AnnouncementType) + type?: AnnouncementType; + + @ApiPropertyOptional() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isEnabled?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} + +// ── Query (User) ── + +export class UserAnnouncementsQueryDto { + @ApiPropertyOptional({ enum: AnnouncementType }) + @IsOptional() + @IsEnum(AnnouncementType) + type?: AnnouncementType; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/backend/services/notification-service/src/interface/http/dto/broadcast.dto.ts b/backend/services/notification-service/src/interface/http/dto/broadcast.dto.ts new file mode 100644 index 0000000..40e76d6 --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/broadcast.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsEnum, IsOptional, IsObject, IsIn, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NotificationChannel } from '../../../domain/entities/notification.entity'; + +export class BroadcastDto { + @ApiProperty({ description: 'Broadcast title', example: 'System Maintenance', maxLength: 200 }) + @IsString() + @MaxLength(200) + title: string; + + @ApiProperty({ description: 'Broadcast body', example: 'The platform will undergo scheduled maintenance tonight.' }) + @IsString() + body: string; + + @ApiProperty({ description: 'Notification channel', enum: NotificationChannel, example: NotificationChannel.IN_APP }) + @IsEnum(NotificationChannel) + channel: NotificationChannel; + + @ApiPropertyOptional({ description: 'Target user segment', enum: ['all', 'active', 'new'], default: 'all' }) + @IsOptional() + @IsIn(['all', 'active', 'new']) + segment?: 'all' | 'active' | 'new'; + + @ApiPropertyOptional({ description: 'Additional payload data', example: { priority: 'high' } }) + @IsOptional() + @IsObject() + data?: Record; +} diff --git a/backend/services/notification-service/src/interface/http/dto/index.ts b/backend/services/notification-service/src/interface/http/dto/index.ts new file mode 100644 index 0000000..215b8c0 --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/index.ts @@ -0,0 +1,4 @@ +export { SendNotificationDto } from './send-notification.dto'; +export { BroadcastDto } from './broadcast.dto'; +export { NotificationQueryDto } from './notification-query.dto'; +export { MarkReadParamDto } from './mark-read.dto'; diff --git a/backend/services/notification-service/src/interface/http/dto/mark-read.dto.ts b/backend/services/notification-service/src/interface/http/dto/mark-read.dto.ts new file mode 100644 index 0000000..3bf6a2f --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/mark-read.dto.ts @@ -0,0 +1,8 @@ +import { IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class MarkReadParamDto { + @ApiProperty({ description: 'Notification UUID', example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsUUID() + id: string; +} diff --git a/backend/services/notification-service/src/interface/http/dto/notification-query.dto.ts b/backend/services/notification-service/src/interface/http/dto/notification-query.dto.ts new file mode 100644 index 0000000..8fa79a1 --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/notification-query.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class NotificationQueryDto { + @ApiPropertyOptional({ description: 'Page number (starts from 1)', default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/services/notification-service/src/interface/http/dto/send-notification.dto.ts b/backend/services/notification-service/src/interface/http/dto/send-notification.dto.ts new file mode 100644 index 0000000..e369309 --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/send-notification.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsUUID, IsEnum, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NotificationChannel } from '../../../domain/entities/notification.entity'; + +export class SendNotificationDto { + @ApiProperty({ description: 'Target user UUID', example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsUUID() + userId: string; + + @ApiProperty({ description: 'Notification channel', enum: NotificationChannel, example: NotificationChannel.IN_APP }) + @IsEnum(NotificationChannel) + channel: NotificationChannel; + + @ApiProperty({ description: 'Notification title', example: 'Trade Executed', maxLength: 200 }) + @IsString() + @MaxLength(200) + title: string; + + @ApiProperty({ description: 'Notification body', example: 'Your buy order was filled: 10 unit(s) at $25.50' }) + @IsString() + body: string; + + @ApiPropertyOptional({ description: 'Additional payload data', example: { type: 'trade', couponId: 'abc-123' } }) + @IsOptional() + @IsObject() + data?: Record; +} diff --git a/backend/services/notification-service/src/interface/http/dto/user-tag.dto.ts b/backend/services/notification-service/src/interface/http/dto/user-tag.dto.ts new file mode 100644 index 0000000..3528fed --- /dev/null +++ b/backend/services/notification-service/src/interface/http/dto/user-tag.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsArray, IsUUID, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AddTagDto { + @ApiProperty({ description: 'Tag to add', example: 'vip' }) + @IsString() + @MaxLength(100) + tag: string; +} + +export class RemoveTagsDto { + @ApiProperty({ description: 'Tags to remove', example: ['vip', 'beta'] }) + @IsArray() + @IsString({ each: true }) + tags: string[]; +} + +export class SyncTagsDto { + @ApiProperty({ description: 'Full list of tags (replaces existing)', example: ['vip', 'active', 'premium'] }) + @IsArray() + @IsString({ each: true }) + tags: string[]; +} diff --git a/backend/services/notification-service/src/notification.module.ts b/backend/services/notification-service/src/notification.module.ts index b938dbe..87a79e8 100644 --- a/backend/services/notification-service/src/notification.module.ts +++ b/backend/services/notification-service/src/notification.module.ts @@ -2,21 +2,89 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; + +// Domain entities import { Notification } from './domain/entities/notification.entity'; +import { Announcement } from './domain/entities/announcement.entity'; +import { AnnouncementRead } from './domain/entities/announcement-read.entity'; +import { AnnouncementTagTarget } from './domain/entities/announcement-tag-target.entity'; +import { AnnouncementUserTarget } from './domain/entities/announcement-user-target.entity'; +import { UserTag } from './domain/entities/user-tag.entity'; + +// Domain repository interfaces +import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository.interface'; +import { ANNOUNCEMENT_REPOSITORY } from './domain/repositories/announcement.repository.interface'; +import { USER_TAG_REPOSITORY } from './domain/repositories/user-tag.repository.interface'; + +// Infrastructure implementations +import { NotificationRepository } from './infrastructure/persistence/notification.repository'; +import { AnnouncementRepositoryImpl } from './infrastructure/persistence/announcement.repository'; +import { UserTagRepositoryImpl } from './infrastructure/persistence/user-tag.repository'; + +// Domain ports +import { + PUSH_NOTIFICATION_PROVIDER, + SMS_NOTIFICATION_PROVIDER, + EMAIL_NOTIFICATION_PROVIDER, +} from './domain/ports/notification-provider.interface'; +import { PushNotificationProvider } from './infrastructure/providers/push-notification.provider'; +import { SmsNotificationProvider } from './infrastructure/providers/sms-notification.provider'; +import { EmailNotificationProvider } from './infrastructure/providers/email-notification.provider'; + +// Application services import { NotificationService } from './application/services/notification.service'; import { EventConsumerService } from './application/services/event-consumer.service'; import { AdminNotificationService } from './application/services/admin-notification.service'; +import { AnnouncementService } from './application/services/announcement.service'; +import { UserTagService } from './application/services/user-tag.service'; + +// Interface controllers import { NotificationController } from './interface/http/controllers/notification.controller'; import { AdminNotificationController } from './interface/http/controllers/admin-notification.controller'; +import { + AdminAnnouncementController, + AdminUserTagController, + UserAnnouncementController, +} from './interface/http/controllers/announcement.controller'; @Module({ imports: [ - TypeOrmModule.forFeature([Notification]), + TypeOrmModule.forFeature([ + Notification, + Announcement, + AnnouncementRead, + AnnouncementTagTarget, + AnnouncementUserTarget, + UserTag, + ]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret' }), ], - controllers: [NotificationController, AdminNotificationController], - providers: [NotificationService, EventConsumerService, AdminNotificationService], - exports: [NotificationService], + controllers: [ + NotificationController, + AdminNotificationController, + AdminAnnouncementController, + AdminUserTagController, + UserAnnouncementController, + ], + providers: [ + // Infrastructure -> Domain port binding + { provide: NOTIFICATION_REPOSITORY, useClass: NotificationRepository }, + { provide: ANNOUNCEMENT_REPOSITORY, useClass: AnnouncementRepositoryImpl }, + { provide: USER_TAG_REPOSITORY, useClass: UserTagRepositoryImpl }, + + // Infrastructure -> Notification channel providers + { provide: PUSH_NOTIFICATION_PROVIDER, useClass: PushNotificationProvider }, + { provide: SMS_NOTIFICATION_PROVIDER, useClass: SmsNotificationProvider }, + { provide: EMAIL_NOTIFICATION_PROVIDER, useClass: EmailNotificationProvider }, + + // Application services + NotificationService, + EventConsumerService, + AdminNotificationService, + AnnouncementService, + UserTagService, + ], + exports: [NotificationService, AnnouncementService], }) export class NotificationModule {} diff --git a/backend/services/telemetry-service/src/application/services/telemetry-scheduler.service.ts b/backend/services/telemetry-service/src/application/services/telemetry-scheduler.service.ts index ce7c415..0070b28 100644 --- a/backend/services/telemetry-service/src/application/services/telemetry-scheduler.service.ts +++ b/backend/services/telemetry-service/src/application/services/telemetry-scheduler.service.ts @@ -1,35 +1,33 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Inject, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { OnlineSnapshot } from '../../domain/entities/online-snapshot.entity'; -import { DailyActiveStats } from '../../domain/entities/daily-active-stats.entity'; -import { TelemetryEvent } from '../../domain/entities/telemetry-event.entity'; -import { PresenceRedisService } from '../../infrastructure/redis/presence-redis.service'; -import { TelemetryMetricsService } from '../../infrastructure/metrics/telemetry-metrics.service'; +import { ONLINE_SNAPSHOT_REPOSITORY, IOnlineSnapshotRepository } from '../../domain/repositories/online-snapshot.repository.interface'; +import { DAILY_ACTIVE_STATS_REPOSITORY, IDailyActiveStatsRepository } from '../../domain/repositories/daily-active-stats.repository.interface'; +import { TELEMETRY_EVENT_REPOSITORY, ITelemetryEventRepository } from '../../domain/repositories/telemetry-event.repository.interface'; +import { PRESENCE_SERVICE, IPresenceService } from '../../domain/ports/presence.service.interface'; +import { TELEMETRY_METRICS_SERVICE, ITelemetryMetricsService } from '../../domain/ports/telemetry-metrics.service.interface'; @Injectable() export class TelemetrySchedulerService { private readonly logger = new Logger(TelemetrySchedulerService.name); constructor( - @InjectRepository(OnlineSnapshot) private readonly snapshotRepo: Repository, - @InjectRepository(DailyActiveStats) private readonly dauRepo: Repository, - @InjectRepository(TelemetryEvent) private readonly eventRepo: Repository, - private readonly presenceRedis: PresenceRedisService, - private readonly metrics: TelemetryMetricsService, + @Inject(ONLINE_SNAPSHOT_REPOSITORY) private readonly snapshotRepo: IOnlineSnapshotRepository, + @Inject(DAILY_ACTIVE_STATS_REPOSITORY) private readonly dauRepo: IDailyActiveStatsRepository, + @Inject(TELEMETRY_EVENT_REPOSITORY) private readonly eventRepo: ITelemetryEventRepository, + @Inject(PRESENCE_SERVICE) private readonly presenceService: IPresenceService, + @Inject(TELEMETRY_METRICS_SERVICE) private readonly metrics: ITelemetryMetricsService, ) {} /** Record online snapshot every minute */ @Cron(CronExpression.EVERY_MINUTE) async recordOnlineSnapshot() { try { - const count = await this.presenceRedis.countOnline(); - this.metrics.onlineUsers.set(count); + const count = await this.presenceService.countOnline(); + this.metrics.setOnlineUsers(count); const snapshot = this.snapshotRepo.create({ ts: new Date(), onlineCount: count, - windowSeconds: this.presenceRedis.getWindowSeconds(), + windowSeconds: this.presenceService.getWindowSeconds(), }); await this.snapshotRepo.save(snapshot); } catch (err) { @@ -41,7 +39,7 @@ export class TelemetrySchedulerService { @Cron(CronExpression.EVERY_HOUR) async cleanupExpiredPresence() { try { - const removed = await this.presenceRedis.cleanupExpired(); + const removed = await this.presenceService.cleanupExpired(); if (removed > 0) { this.logger.log(`Cleaned up ${removed} expired presence entries`); } @@ -78,69 +76,30 @@ export class TelemetrySchedulerService { private async calculateDauForDate(dayStr: string) { const startTime = new Date(`${dayStr}T00:00:00Z`); const endTime = new Date(`${dayStr}T23:59:59.999Z`); + const endTimePlusOne = new Date(endTime.getTime() + 1); - // Count distinct users/installIds for app_session_start events - const result = await this.eventRepo - .createQueryBuilder('e') - .select("COUNT(DISTINCT COALESCE(e.user_id::text, e.install_id))", 'dauCount') - .where("e.event_name = 'app_session_start'") - .andWhere('e.event_time >= :startTime AND e.event_time < :endTime', { - startTime, - endTime: new Date(endTime.getTime() + 1), - }) - .getRawOne(); - - // Platform breakdown - const platformResult = await this.eventRepo - .createQueryBuilder('e') - .select("e.properties->>'platform'", 'platform') - .addSelect("COUNT(DISTINCT COALESCE(e.user_id::text, e.install_id))", 'count') - .where("e.event_name = 'app_session_start'") - .andWhere('e.event_time >= :startTime AND e.event_time < :endTime', { - startTime, - endTime: new Date(endTime.getTime() + 1), - }) - .groupBy("e.properties->>'platform'") - .getRawMany(); - - // Region breakdown - const regionResult = await this.eventRepo - .createQueryBuilder('e') - .select("e.properties->>'region'", 'region') - .addSelect("COUNT(DISTINCT COALESCE(e.user_id::text, e.install_id))", 'count') - .where("e.event_name = 'app_session_start'") - .andWhere('e.event_time >= :startTime AND e.event_time < :endTime', { - startTime, - endTime: new Date(endTime.getTime() + 1), - }) - .andWhere("e.properties->>'region' IS NOT NULL") - .groupBy("e.properties->>'region'") - .getRawMany(); + const dauCount = await this.eventRepo.getDauCount(startTime, endTimePlusOne); + const platformBreakdown = await this.eventRepo.getDauByPlatform(startTime, endTimePlusOne); + const regionBreakdown = await this.eventRepo.getDauByRegion(startTime, endTimePlusOne); const dauByPlatform: Record = {}; - for (const r of platformResult) { - if (r.platform) dauByPlatform[r.platform] = parseInt(r.count, 10); + for (const r of platformBreakdown) { + dauByPlatform[r.key] = r.count; } const dauByRegion: Record = {}; - for (const r of regionResult) { - if (r.region) dauByRegion[r.region] = parseInt(r.count, 10); + for (const r of regionBreakdown) { + dauByRegion[r.key] = r.count; } - const dauCount = parseInt(result.dauCount, 10) || 0; + await this.dauRepo.upsert({ + day: dayStr, + dauCount, + dauByPlatform, + dauByRegion, + calculatedAt: new Date(), + }); - // Upsert - await this.dauRepo.upsert( - { - day: dayStr, - dauCount, - dauByPlatform, - dauByRegion, - calculatedAt: new Date(), - }, - ['day'], - ); - - this.metrics.dau.set({ date: dayStr }, dauCount); + this.metrics.setDau(dayStr, dauCount); } } diff --git a/backend/services/telemetry-service/src/application/services/telemetry.service.ts b/backend/services/telemetry-service/src/application/services/telemetry.service.ts index 8e019c2..26dc30e 100644 --- a/backend/services/telemetry-service/src/application/services/telemetry.service.ts +++ b/backend/services/telemetry-service/src/application/services/telemetry.service.ts @@ -1,24 +1,23 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Inject, Logger } from '@nestjs/common'; import { TelemetryEvent } from '../../domain/entities/telemetry-event.entity'; -import { OnlineSnapshot } from '../../domain/entities/online-snapshot.entity'; -import { DailyActiveStats } from '../../domain/entities/daily-active-stats.entity'; -import { PresenceRedisService } from '../../infrastructure/redis/presence-redis.service'; -import { TelemetryMetricsService } from '../../infrastructure/metrics/telemetry-metrics.service'; -import { TelemetryProducerService } from '../../infrastructure/kafka/telemetry-producer.service'; +import { TELEMETRY_EVENT_REPOSITORY, ITelemetryEventRepository, EventListFilters } from '../../domain/repositories/telemetry-event.repository.interface'; +import { ONLINE_SNAPSHOT_REPOSITORY, IOnlineSnapshotRepository } from '../../domain/repositories/online-snapshot.repository.interface'; +import { DAILY_ACTIVE_STATS_REPOSITORY, IDailyActiveStatsRepository } from '../../domain/repositories/daily-active-stats.repository.interface'; +import { PRESENCE_SERVICE, IPresenceService } from '../../domain/ports/presence.service.interface'; +import { TELEMETRY_METRICS_SERVICE, ITelemetryMetricsService } from '../../domain/ports/telemetry-metrics.service.interface'; +import { TELEMETRY_PRODUCER_SERVICE, ITelemetryProducerService } from '../../domain/ports/telemetry-producer.service.interface'; @Injectable() export class TelemetryService { private readonly logger = new Logger(TelemetryService.name); constructor( - @InjectRepository(TelemetryEvent) private readonly eventRepo: Repository, - @InjectRepository(OnlineSnapshot) private readonly snapshotRepo: Repository, - @InjectRepository(DailyActiveStats) private readonly dauRepo: Repository, - private readonly presenceRedis: PresenceRedisService, - private readonly metrics: TelemetryMetricsService, - private readonly kafkaProducer: TelemetryProducerService, + @Inject(TELEMETRY_EVENT_REPOSITORY) private readonly eventRepo: ITelemetryEventRepository, + @Inject(ONLINE_SNAPSHOT_REPOSITORY) private readonly snapshotRepo: IOnlineSnapshotRepository, + @Inject(DAILY_ACTIVE_STATS_REPOSITORY) private readonly dauRepo: IDailyActiveStatsRepository, + @Inject(PRESENCE_SERVICE) private readonly presenceService: IPresenceService, + @Inject(TELEMETRY_METRICS_SERVICE) private readonly metrics: ITelemetryMetricsService, + @Inject(TELEMETRY_PRODUCER_SERVICE) private readonly kafkaProducer: ITelemetryProducerService, ) {} /** Batch insert telemetry events */ @@ -29,7 +28,7 @@ export class TelemetryService { clientTs: number; properties?: Record; }>): Promise<{ recorded: number }> { - const timer = this.metrics.eventBatchDuration.startTimer(); + const timer = this.metrics.startBatchTimer(); const entities = events.map((e) => { const event = new TelemetryEvent(); @@ -41,11 +40,11 @@ export class TelemetryService { return event; }); - await this.eventRepo.save(entities); + await this.eventRepo.saveBatch(entities); // Increment event counters for (const e of events) { - this.metrics.eventsTotal.inc({ event_name: e.eventName }); + this.metrics.incEventsTotal(e.eventName); } // Update HyperLogLog for DAU on session_start events @@ -53,7 +52,7 @@ export class TelemetryService { for (const e of events) { if (e.eventName === 'app_session_start') { const identifier = e.userId || e.installId; - await this.presenceRedis.addDauIdentifier(today, identifier); + await this.presenceService.addDauIdentifier(today, identifier); // Publish session started event to Kafka await this.kafkaProducer.publishSessionStarted({ @@ -71,29 +70,25 @@ export class TelemetryService { /** Record heartbeat */ async recordHeartbeat(userId: string, installId: string, appVersion: string): Promise { - await this.presenceRedis.updatePresence(userId); - this.metrics.heartbeatTotal.inc({ app_version: appVersion }); + await this.presenceService.updatePresence(userId); + this.metrics.incHeartbeatTotal(appVersion); await this.kafkaProducer.publishHeartbeat(userId, installId, appVersion); } /** Get current online count */ async getOnlineCount(): Promise<{ count: number; windowSeconds: number; queriedAt: string }> { - const count = await this.presenceRedis.countOnline(); - this.metrics.onlineUsers.set(count); + const count = await this.presenceService.countOnline(); + this.metrics.setOnlineUsers(count); return { count, - windowSeconds: this.presenceRedis.getWindowSeconds(), + windowSeconds: this.presenceService.getWindowSeconds(), queriedAt: new Date().toISOString(), }; } /** Get online history with interval aggregation */ async getOnlineHistory(startTime: Date, endTime: Date, interval: '1m' | '5m' | '1h' = '5m') { - const snapshots = await this.snapshotRepo - .createQueryBuilder('s') - .where('s.ts >= :startTime AND s.ts <= :endTime', { startTime, endTime }) - .orderBy('s.ts', 'ASC') - .getMany(); + const snapshots = await this.snapshotRepo.findByTimeRange(startTime, endTime); // Aggregate by interval const intervalMs = interval === '1m' ? 60000 : interval === '5m' ? 300000 : 3600000; @@ -129,11 +124,7 @@ export class TelemetryService { /** Get DAU stats for date range */ async getDauStats(startDate: string, endDate: string) { - const stats = await this.dauRepo - .createQueryBuilder('d') - .where('d.day >= :startDate AND d.day <= :endDate', { startDate, endDate }) - .orderBy('d.day', 'ASC') - .getMany(); + const stats = await this.dauRepo.findByDateRange(startDate, endDate); return { data: stats.map((s) => ({ @@ -145,4 +136,30 @@ export class TelemetryService { total: stats.length, }; } + + /** List telemetry events with pagination and filters */ + async listEvents(filters: EventListFilters) { + const [items, total] = await this.eventRepo.findPaginated(filters); + return { items, total, page: filters.page, limit: filters.limit }; + } + + /** Get realtime analytics dashboard data */ + async getRealtimeData() { + const today = new Date().toISOString().slice(0, 10); + const todayStart = new Date(`${today}T00:00:00Z`); + + const [onlineCount, approxDau, eventsToday] = await Promise.all([ + this.presenceService.countOnline(), + this.presenceService.getApproxDau(today), + this.eventRepo.countSince(todayStart), + ]); + + return { + onlineUsers: onlineCount, + dauToday: approxDau, + eventsToday, + windowSeconds: 180, + queriedAt: new Date().toISOString(), + }; + } } diff --git a/backend/services/telemetry-service/src/domain/ports/presence.service.interface.ts b/backend/services/telemetry-service/src/domain/ports/presence.service.interface.ts new file mode 100644 index 0000000..f2abf2c --- /dev/null +++ b/backend/services/telemetry-service/src/domain/ports/presence.service.interface.ts @@ -0,0 +1,27 @@ +/** + * Presence Service domain port. + * + * Abstracts user online presence tracking (backed by Redis in infrastructure). + */ + +export const PRESENCE_SERVICE = Symbol('IPresenceService'); + +export interface IPresenceService { + /** Update user heartbeat timestamp */ + updatePresence(userId: string): Promise; + + /** Count users online within window */ + countOnline(): Promise; + + /** Add user/installId to HyperLogLog DAU */ + addDauIdentifier(date: string, identifier: string): Promise; + + /** Get approximate DAU from HyperLogLog */ + getApproxDau(date: string): Promise; + + /** Clean up expired presence data */ + cleanupExpired(): Promise; + + /** Get the online detection window in seconds */ + getWindowSeconds(): number; +} diff --git a/backend/services/telemetry-service/src/domain/ports/telemetry-metrics.service.interface.ts b/backend/services/telemetry-service/src/domain/ports/telemetry-metrics.service.interface.ts new file mode 100644 index 0000000..f484cb5 --- /dev/null +++ b/backend/services/telemetry-service/src/domain/ports/telemetry-metrics.service.interface.ts @@ -0,0 +1,30 @@ +/** + * Telemetry Metrics Service domain port. + * + * Abstracts Prometheus metrics collection for telemetry events. + */ + +export const TELEMETRY_METRICS_SERVICE = Symbol('ITelemetryMetricsService'); + +export interface ITelemetryMetricsService { + /** Increment event counter by event name */ + incEventsTotal(eventName: string): void; + + /** Increment heartbeat counter by app version */ + incHeartbeatTotal(appVersion: string): void; + + /** Set current online user gauge */ + setOnlineUsers(count: number): void; + + /** Set DAU gauge */ + setDau(date: string, count: number): void; + + /** Start a batch duration timer. Returns a function to call when done. */ + startBatchTimer(): () => void; + + /** Get serialized metrics for Prometheus scraping */ + getMetrics(): Promise; + + /** Get Prometheus content type */ + getContentType(): string; +} diff --git a/backend/services/telemetry-service/src/domain/ports/telemetry-producer.service.interface.ts b/backend/services/telemetry-service/src/domain/ports/telemetry-producer.service.interface.ts new file mode 100644 index 0000000..69d05f7 --- /dev/null +++ b/backend/services/telemetry-service/src/domain/ports/telemetry-producer.service.interface.ts @@ -0,0 +1,20 @@ +/** + * Telemetry Producer domain port. + * + * Abstracts Kafka event publishing for telemetry data. + */ + +export const TELEMETRY_PRODUCER_SERVICE = Symbol('ITelemetryProducerService'); + +export interface ITelemetryProducerService { + /** Publish session started event */ + publishSessionStarted(data: { + userId?: string; + installId: string; + timestamp: number; + properties?: Record; + }): Promise; + + /** Publish heartbeat event */ + publishHeartbeat(userId: string, installId: string, appVersion: string): Promise; +} diff --git a/backend/services/telemetry-service/src/domain/repositories/daily-active-stats.repository.interface.ts b/backend/services/telemetry-service/src/domain/repositories/daily-active-stats.repository.interface.ts new file mode 100644 index 0000000..fd6f6de --- /dev/null +++ b/backend/services/telemetry-service/src/domain/repositories/daily-active-stats.repository.interface.ts @@ -0,0 +1,8 @@ +import { DailyActiveStats } from '../entities/daily-active-stats.entity'; + +export const DAILY_ACTIVE_STATS_REPOSITORY = Symbol('DAILY_ACTIVE_STATS_REPOSITORY'); + +export interface IDailyActiveStatsRepository { + findByDateRange(startDate: string, endDate: string): Promise; + upsert(data: Partial): Promise; +} diff --git a/backend/services/telemetry-service/src/domain/repositories/online-snapshot.repository.interface.ts b/backend/services/telemetry-service/src/domain/repositories/online-snapshot.repository.interface.ts new file mode 100644 index 0000000..46044b2 --- /dev/null +++ b/backend/services/telemetry-service/src/domain/repositories/online-snapshot.repository.interface.ts @@ -0,0 +1,9 @@ +import { OnlineSnapshot } from '../entities/online-snapshot.entity'; + +export const ONLINE_SNAPSHOT_REPOSITORY = Symbol('ONLINE_SNAPSHOT_REPOSITORY'); + +export interface IOnlineSnapshotRepository { + create(data: Partial): OnlineSnapshot; + save(entity: OnlineSnapshot): Promise; + findByTimeRange(startTime: Date, endTime: Date): Promise; +} diff --git a/backend/services/telemetry-service/src/domain/repositories/telemetry-event.repository.interface.ts b/backend/services/telemetry-service/src/domain/repositories/telemetry-event.repository.interface.ts new file mode 100644 index 0000000..571b38b --- /dev/null +++ b/backend/services/telemetry-service/src/domain/repositories/telemetry-event.repository.interface.ts @@ -0,0 +1,24 @@ +import { TelemetryEvent } from '../entities/telemetry-event.entity'; + +export const TELEMETRY_EVENT_REPOSITORY = Symbol('TELEMETRY_EVENT_REPOSITORY'); + +export interface DauBreakdown { + key: string; + count: number; +} + +export interface EventListFilters { + eventName?: string; + userId?: string; + page: number; + limit: number; +} + +export interface ITelemetryEventRepository { + saveBatch(events: TelemetryEvent[]): Promise; + getDauCount(startTime: Date, endTime: Date): Promise; + getDauByPlatform(startTime: Date, endTime: Date): Promise; + getDauByRegion(startTime: Date, endTime: Date): Promise; + findPaginated(filters: EventListFilters): Promise<[TelemetryEvent[], number]>; + countSince(since: Date): Promise; +} diff --git a/backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts b/backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts index 0bf6d2a..ae559f0 100644 --- a/backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts +++ b/backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts @@ -1,8 +1,9 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Kafka, Producer } from 'kafkajs'; +import { ITelemetryProducerService } from '../../domain/ports/telemetry-producer.service.interface'; @Injectable() -export class TelemetryProducerService implements OnModuleInit, OnModuleDestroy { +export class TelemetryProducerService implements ITelemetryProducerService, OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(TelemetryProducerService.name); private readonly kafka: Kafka; private producer: Producer; diff --git a/backend/services/telemetry-service/src/infrastructure/metrics/telemetry-metrics.service.ts b/backend/services/telemetry-service/src/infrastructure/metrics/telemetry-metrics.service.ts index 92bda8f..cb03371 100644 --- a/backend/services/telemetry-service/src/infrastructure/metrics/telemetry-metrics.service.ts +++ b/backend/services/telemetry-service/src/infrastructure/metrics/telemetry-metrics.service.ts @@ -1,13 +1,14 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import * as client from 'prom-client'; +import { ITelemetryMetricsService } from '../../domain/ports/telemetry-metrics.service.interface'; @Injectable() -export class TelemetryMetricsService implements OnModuleInit { - readonly onlineUsers: client.Gauge; - readonly dau: client.Gauge; - readonly heartbeatTotal: client.Counter; - readonly eventsTotal: client.Counter; - readonly eventBatchDuration: client.Histogram; +export class TelemetryMetricsService implements ITelemetryMetricsService, OnModuleInit { + private readonly onlineUsers: client.Gauge; + private readonly dau: client.Gauge; + private readonly heartbeatTotal: client.Counter; + private readonly eventsTotal: client.Counter; + private readonly eventBatchDuration: client.Histogram; constructor() { this.onlineUsers = new client.Gauge({ @@ -44,6 +45,28 @@ export class TelemetryMetricsService implements OnModuleInit { client.collectDefaultMetrics(); } + // ── ITelemetryMetricsService interface methods ── + + incEventsTotal(eventName: string): void { + this.eventsTotal.inc({ event_name: eventName }); + } + + incHeartbeatTotal(appVersion: string): void { + this.heartbeatTotal.inc({ app_version: appVersion }); + } + + setOnlineUsers(count: number): void { + this.onlineUsers.set(count); + } + + setDau(date: string, count: number): void { + this.dau.set({ date }, count); + } + + startBatchTimer(): () => void { + return this.eventBatchDuration.startTimer(); + } + async getMetrics(): Promise { return client.register.metrics(); } diff --git a/backend/services/telemetry-service/src/infrastructure/persistence/daily-active-stats.repository.ts b/backend/services/telemetry-service/src/infrastructure/persistence/daily-active-stats.repository.ts new file mode 100644 index 0000000..e1df22a --- /dev/null +++ b/backend/services/telemetry-service/src/infrastructure/persistence/daily-active-stats.repository.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DailyActiveStats } from '../../domain/entities/daily-active-stats.entity'; +import { IDailyActiveStatsRepository } from '../../domain/repositories/daily-active-stats.repository.interface'; + +@Injectable() +export class DailyActiveStatsRepository implements IDailyActiveStatsRepository { + constructor( + @InjectRepository(DailyActiveStats) private readonly repo: Repository, + ) {} + + async findByDateRange(startDate: string, endDate: string): Promise { + return this.repo + .createQueryBuilder('d') + .where('d.day >= :startDate AND d.day <= :endDate', { startDate, endDate }) + .orderBy('d.day', 'ASC') + .getMany(); + } + + async upsert(data: Partial): Promise { + await this.repo.upsert(data as any, ['day']); + } +} diff --git a/backend/services/telemetry-service/src/infrastructure/persistence/online-snapshot.repository.ts b/backend/services/telemetry-service/src/infrastructure/persistence/online-snapshot.repository.ts new file mode 100644 index 0000000..d896e21 --- /dev/null +++ b/backend/services/telemetry-service/src/infrastructure/persistence/online-snapshot.repository.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OnlineSnapshot } from '../../domain/entities/online-snapshot.entity'; +import { IOnlineSnapshotRepository } from '../../domain/repositories/online-snapshot.repository.interface'; + +@Injectable() +export class OnlineSnapshotRepository implements IOnlineSnapshotRepository { + constructor( + @InjectRepository(OnlineSnapshot) private readonly repo: Repository, + ) {} + + create(data: Partial): OnlineSnapshot { + return this.repo.create(data); + } + + async save(entity: OnlineSnapshot): Promise { + return this.repo.save(entity); + } + + async findByTimeRange(startTime: Date, endTime: Date): Promise { + return this.repo + .createQueryBuilder('s') + .where('s.ts >= :startTime AND s.ts <= :endTime', { startTime, endTime }) + .orderBy('s.ts', 'ASC') + .getMany(); + } +} diff --git a/backend/services/telemetry-service/src/infrastructure/persistence/telemetry-event.repository.ts b/backend/services/telemetry-service/src/infrastructure/persistence/telemetry-event.repository.ts new file mode 100644 index 0000000..17e6191 --- /dev/null +++ b/backend/services/telemetry-service/src/infrastructure/persistence/telemetry-event.repository.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TelemetryEvent } from '../../domain/entities/telemetry-event.entity'; +import { ITelemetryEventRepository, DauBreakdown, EventListFilters } from '../../domain/repositories/telemetry-event.repository.interface'; + +@Injectable() +export class TelemetryEventRepository implements ITelemetryEventRepository { + constructor( + @InjectRepository(TelemetryEvent) private readonly repo: Repository, + ) {} + + async saveBatch(events: TelemetryEvent[]): Promise { + await this.repo.save(events); + } + + async getDauCount(startTime: Date, endTime: Date): Promise { + const result = await this.repo + .createQueryBuilder('e') + .select("COUNT(DISTINCT COALESCE(e.user_id::text, e.install_id))", 'dauCount') + .where("e.event_name = 'app_session_start'") + .andWhere('e.event_time >= :startTime AND e.event_time < :endTime', { + startTime, + endTime, + }) + .getRawOne(); + + return parseInt(result?.dauCount || '0', 10); + } + + async getDauByPlatform(startTime: Date, endTime: Date): Promise { + const rows = await this.repo + .createQueryBuilder('e') + .select("e.properties->>'platform'", 'key') + .addSelect("COUNT(DISTINCT COALESCE(e.user_id::text, e.install_id))", 'count') + .where("e.event_name = 'app_session_start'") + .andWhere('e.event_time >= :startTime AND e.event_time < :endTime', { + startTime, + endTime, + }) + .groupBy("e.properties->>'platform'") + .getRawMany(); + + return rows + .filter((r) => r.key) + .map((r) => ({ key: r.key, count: parseInt(r.count, 10) })); + } + + async getDauByRegion(startTime: Date, endTime: Date): Promise { + const rows = await this.repo + .createQueryBuilder('e') + .select("e.properties->>'region'", 'key') + .addSelect("COUNT(DISTINCT COALESCE(e.user_id::text, e.install_id))", 'count') + .where("e.event_name = 'app_session_start'") + .andWhere('e.event_time >= :startTime AND e.event_time < :endTime', { + startTime, + endTime, + }) + .andWhere("e.properties->>'region' IS NOT NULL") + .groupBy("e.properties->>'region'") + .getRawMany(); + + return rows + .filter((r) => r.key) + .map((r) => ({ key: r.key, count: parseInt(r.count, 10) })); + } + + async findPaginated(filters: EventListFilters): Promise<[TelemetryEvent[], number]> { + const qb = this.repo.createQueryBuilder('e'); + if (filters.eventName) qb.andWhere('e.event_name = :eventName', { eventName: filters.eventName }); + if (filters.userId) qb.andWhere('e.user_id = :userId', { userId: filters.userId }); + + qb.orderBy('e.event_time', 'DESC') + .skip((filters.page - 1) * filters.limit) + .take(filters.limit); + + return qb.getManyAndCount(); + } + + async countSince(since: Date): Promise { + const result = await this.repo + .createQueryBuilder('e') + .select('COUNT(*)', 'count') + .where('e.event_time >= :since', { since }) + .getRawOne(); + + return parseInt(result?.count || '0', 10); + } +} diff --git a/backend/services/telemetry-service/src/infrastructure/redis/presence-redis.service.ts b/backend/services/telemetry-service/src/infrastructure/redis/presence-redis.service.ts index 3ce78c7..90d9069 100644 --- a/backend/services/telemetry-service/src/infrastructure/redis/presence-redis.service.ts +++ b/backend/services/telemetry-service/src/infrastructure/redis/presence-redis.service.ts @@ -1,12 +1,13 @@ import { Injectable, OnModuleDestroy } from '@nestjs/common'; import Redis from 'ioredis'; +import { IPresenceService } from '../../domain/ports/presence.service.interface'; const ONLINE_KEY = 'genex:presence:online'; const DAU_KEY_PREFIX = 'genex:dau:'; const ONLINE_WINDOW = 180; // 3 minutes @Injectable() -export class PresenceRedisService implements OnModuleDestroy { +export class PresenceRedisService implements IPresenceService, OnModuleDestroy { private readonly redis: Redis; constructor() { diff --git a/backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts b/backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts index 58f67c0..27fc44a 100644 --- a/backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts +++ b/backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts @@ -2,10 +2,6 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { TelemetryService } from '../../../application/services/telemetry.service'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { TelemetryEvent } from '../../../domain/entities/telemetry-event.entity'; -import { PresenceRedisService } from '../../../infrastructure/redis/presence-redis.service'; import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto'; @ApiTags('admin-telemetry') @@ -16,8 +12,6 @@ import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto'; export class AdminTelemetryController { constructor( private readonly telemetryService: TelemetryService, - @InjectRepository(TelemetryEvent) private readonly eventRepo: Repository, - private readonly presenceRedis: PresenceRedisService, ) {} @Get('dau') @@ -32,45 +26,19 @@ export class AdminTelemetryController { async listEvents(@Query() query: QueryEventsDto) { const page = query.page || 1; const limit = query.limit || 20; - - const qb = this.eventRepo.createQueryBuilder('e'); - if (query.eventName) qb.andWhere('e.event_name = :eventName', { eventName: query.eventName }); - if (query.userId) qb.andWhere('e.user_id = :userId', { userId: query.userId }); - - qb.orderBy('e.event_time', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); - return { code: 0, data: { items, total, page, limit } }; + const result = await this.telemetryService.listEvents({ + eventName: query.eventName, + userId: query.userId, + page, + limit, + }); + return { code: 0, data: result }; } @Get('realtime') @ApiOperation({ summary: 'Get realtime analytics dashboard data' }) async getRealtimeData() { - const today = new Date().toISOString().slice(0, 10); - const [onlineCount, approxDau] = await Promise.all([ - this.presenceRedis.countOnline(), - this.presenceRedis.getApproxDau(today), - ]); - - // Events today - const todayStart = new Date(`${today}T00:00:00Z`); - const eventsToday = await this.eventRepo - .createQueryBuilder('e') - .select('COUNT(*)', 'count') - .where('e.event_time >= :todayStart', { todayStart }) - .getRawOne(); - - return { - code: 0, - data: { - onlineUsers: onlineCount, - dauToday: approxDau, - eventsToday: parseInt(eventsToday.count, 10), - windowSeconds: 180, - queriedAt: new Date().toISOString(), - }, - }; + const data = await this.telemetryService.getRealtimeData(); + return { code: 0, data }; } } diff --git a/backend/services/telemetry-service/src/interface/http/controllers/metrics.controller.ts b/backend/services/telemetry-service/src/interface/http/controllers/metrics.controller.ts index 0e91986..6b9ef68 100644 --- a/backend/services/telemetry-service/src/interface/http/controllers/metrics.controller.ts +++ b/backend/services/telemetry-service/src/interface/http/controllers/metrics.controller.ts @@ -1,12 +1,12 @@ -import { Controller, Get, Res } from '@nestjs/common'; +import { Controller, Get, Inject, Res } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { Response } from 'express'; -import { TelemetryMetricsService } from '../../../infrastructure/metrics/telemetry-metrics.service'; +import { TELEMETRY_METRICS_SERVICE, ITelemetryMetricsService } from '../../../domain/ports/telemetry-metrics.service.interface'; @ApiExcludeController() @Controller('metrics') export class MetricsController { - constructor(private readonly metricsService: TelemetryMetricsService) {} + constructor(@Inject(TELEMETRY_METRICS_SERVICE) private readonly metricsService: ITelemetryMetricsService) {} @Get() async getMetrics(@Res() res: Response) { diff --git a/backend/services/telemetry-service/src/telemetry.module.ts b/backend/services/telemetry-service/src/telemetry.module.ts index faa5dac..f40346e 100644 --- a/backend/services/telemetry-service/src/telemetry.module.ts +++ b/backend/services/telemetry-service/src/telemetry.module.ts @@ -9,7 +9,20 @@ import { TelemetryEvent } from './domain/entities/telemetry-event.entity'; import { OnlineSnapshot } from './domain/entities/online-snapshot.entity'; import { DailyActiveStats } from './domain/entities/daily-active-stats.entity'; +// Domain repositories +import { TELEMETRY_EVENT_REPOSITORY } from './domain/repositories/telemetry-event.repository.interface'; +import { ONLINE_SNAPSHOT_REPOSITORY } from './domain/repositories/online-snapshot.repository.interface'; +import { DAILY_ACTIVE_STATS_REPOSITORY } from './domain/repositories/daily-active-stats.repository.interface'; + +// Domain ports +import { PRESENCE_SERVICE } from './domain/ports/presence.service.interface'; +import { TELEMETRY_METRICS_SERVICE } from './domain/ports/telemetry-metrics.service.interface'; +import { TELEMETRY_PRODUCER_SERVICE } from './domain/ports/telemetry-producer.service.interface'; + // Infrastructure +import { TelemetryEventRepository } from './infrastructure/persistence/telemetry-event.repository'; +import { OnlineSnapshotRepository } from './infrastructure/persistence/online-snapshot.repository'; +import { DailyActiveStatsRepository } from './infrastructure/persistence/daily-active-stats.repository'; import { PresenceRedisService } from './infrastructure/redis/presence-redis.service'; import { TelemetryProducerService } from './infrastructure/kafka/telemetry-producer.service'; import { TelemetryMetricsService } from './infrastructure/metrics/telemetry-metrics.service'; @@ -59,9 +72,12 @@ import { HealthController } from './interface/http/controllers/health.controller HealthController, ], providers: [ - PresenceRedisService, - TelemetryProducerService, - TelemetryMetricsService, + { provide: TELEMETRY_EVENT_REPOSITORY, useClass: TelemetryEventRepository }, + { provide: ONLINE_SNAPSHOT_REPOSITORY, useClass: OnlineSnapshotRepository }, + { provide: DAILY_ACTIVE_STATS_REPOSITORY, useClass: DailyActiveStatsRepository }, + { provide: PRESENCE_SERVICE, useClass: PresenceRedisService }, + { provide: TELEMETRY_PRODUCER_SERVICE, useClass: TelemetryProducerService }, + { provide: TELEMETRY_METRICS_SERVICE, useClass: TelemetryMetricsService }, TelemetryService, TelemetrySchedulerService, ], diff --git a/backend/services/trading-service/cmd/server/main.go b/backend/services/trading-service/cmd/server/main.go index e08110b..d91dea1 100644 --- a/backend/services/trading-service/cmd/server/main.go +++ b/backend/services/trading-service/cmd/server/main.go @@ -2,18 +2,25 @@ package main import ( "context" + "fmt" "net/http" "os" "os/signal" + "strings" "syscall" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" + pgdriver "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + appservice "github.com/genex/trading-service/internal/application/service" + "github.com/genex/trading-service/internal/infrastructure/kafka" + "github.com/genex/trading-service/internal/infrastructure/postgres" "github.com/genex/trading-service/internal/interface/http/handler" "github.com/genex/trading-service/internal/interface/http/middleware" - "github.com/genex/trading-service/internal/matching" ) func main() { @@ -25,10 +32,20 @@ func main() { port = "3003" } - // Initialize matching engine - engine := matching.NewEngine() + // ── Infrastructure Layer ──────────────────────────────────────────── + db := mustInitDB(logger) - // Setup Gin router + orderRepo := postgres.NewPostgresOrderRepository(db) + tradeRepo := postgres.NewPostgresTradeRepository(db) + + eventPublisher := mustInitKafka(logger) + defer eventPublisher.Close() + + // ── Application Layer ─────────────────────────────────────────────── + matchingService := appservice.NewMatchingService(eventPublisher) + tradeService := appservice.NewTradeService(orderRepo, tradeRepo, matchingService, eventPublisher) + + // ── Interface Layer (HTTP) ────────────────────────────────────────── r := gin.New() r.Use(gin.Recovery()) @@ -42,7 +59,8 @@ func main() { // API routes api := r.Group("/api/v1") - tradeHandler := handler.NewTradeHandler(engine) + // User-facing trade handler + tradeHandler := handler.NewTradeHandler(tradeService) trades := api.Group("/trades") trades.Use(middleware.JWTAuth()) @@ -55,8 +73,8 @@ func main() { api.GET("/trades/orderbook/:couponId", tradeHandler.GetOrderBook) // Admin routes (require JWT + admin role) - adminTradeHandler := handler.NewAdminTradeHandler(engine) - adminMMHandler := handler.NewAdminMMHandler(engine) + adminTradeHandler := handler.NewAdminTradeHandler(tradeService) + adminMMHandler := handler.NewAdminMMHandler(tradeService) admin := api.Group("/admin") admin.Use(middleware.JWTAuth(), middleware.RequireAdmin()) @@ -107,3 +125,49 @@ func main() { } logger.Info("Trading Service stopped") } + +func mustInitDB(logger *zap.Logger) *gorm.DB { + host := getEnv("DB_HOST", "localhost") + dbPort := getEnv("DB_PORT", "5432") + user := getEnv("DB_USERNAME", "genex") + pass := getEnv("DB_PASSWORD", "genex_dev_password") + name := getEnv("DB_NAME", "genex") + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, dbPort, user, pass, name) + + db, err := gorm.Open(pgdriver.Open(dsn), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Warn), + }) + if err != nil { + logger.Fatal("Failed to connect to PostgreSQL", zap.Error(err)) + } + + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + + logger.Info("PostgreSQL connected", zap.String("host", host), zap.String("db", name)) + return db +} + +func mustInitKafka(logger *zap.Logger) *kafka.KafkaEventPublisher { + brokersEnv := getEnv("KAFKA_BROKERS", "localhost:9092") + brokers := strings.Split(brokersEnv, ",") + + publisher, err := kafka.NewKafkaEventPublisher(brokers) + if err != nil { + logger.Fatal("Failed to connect to Kafka", zap.Error(err)) + } + + logger.Info("Kafka producer connected", zap.Strings("brokers", brokers)) + return publisher +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/services/trading-service/go.mod b/backend/services/trading-service/go.mod index d0550e3..ad7f5f1 100644 --- a/backend/services/trading-service/go.mod +++ b/backend/services/trading-service/go.mod @@ -3,37 +3,60 @@ module github.com/genex/trading-service go 1.22 require ( + github.com/IBM/sarama v1.43.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.1 go.uber.org/zap v1.27.0 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.10 ) require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eapache/go-resiliency v1.6.0 // indirect + github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect + github.com/eapache/queue v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.7 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/kr/pretty v0.3.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rogpeppe/go-internal v1.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect google.golang.org/protobuf v1.30.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/services/trading-service/go.sum b/backend/services/trading-service/go.sum index 948d2c1..af4b1d1 100644 --- a/backend/services/trading-service/go.sum +++ b/backend/services/trading-service/go.sum @@ -1,13 +1,22 @@ +github.com/IBM/sarama v1.43.0 h1:YFFDn8mMI2QL0wOrG0J2sFoVIAFl7hS9JQi2YZsXtJc= +github.com/IBM/sarama v1.43.0/go.mod h1:zlE6HEbC/SMQ9mhEYaF7nNLYOUyrs0obySKCckWP9BM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eapache/go-resiliency v1.6.0 h1:CqGDTLtpwuWKn6Nj3uNUdflaq+/kIPsg0gfNzHton30= +github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= +github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= +github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -27,16 +36,50 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -54,25 +97,32 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= @@ -82,16 +132,47 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= @@ -102,7 +183,12 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/services/trading-service/internal/application/service/matching_service.go b/backend/services/trading-service/internal/application/service/matching_service.go new file mode 100644 index 0000000..61965e4 --- /dev/null +++ b/backend/services/trading-service/internal/application/service/matching_service.go @@ -0,0 +1,138 @@ +package service + +import ( + "fmt" + "sync" + "sync/atomic" + + "github.com/genex/trading-service/internal/domain/entity" + "github.com/genex/trading-service/internal/domain/event" + "github.com/genex/trading-service/internal/domain/vo" +) + +// MatchResult holds the outcome of a matching attempt. +type MatchResult struct { + Trades []*entity.Trade + UpdatedOrder *entity.Order +} + +// MatchingService is the application-level service responsible for the matching engine. +// It orchestrates order books and produces trades, delegating persistence to repositories +// and event publishing to the event publisher. +type MatchingService struct { + orderbooks map[string]*entity.OrderBook + mu sync.RWMutex + tradeSeq int64 + publisher event.EventPublisher +} + +// NewMatchingService creates a new MatchingService. +func NewMatchingService(publisher event.EventPublisher) *MatchingService { + if publisher == nil { + publisher = &event.NoopEventPublisher{} + } + return &MatchingService{ + orderbooks: make(map[string]*entity.OrderBook), + publisher: publisher, + } +} + +// getOrCreateOrderBook retrieves or lazily initializes an order book for a coupon. +func (s *MatchingService) getOrCreateOrderBook(couponID string) *entity.OrderBook { + s.mu.Lock() + defer s.mu.Unlock() + ob, exists := s.orderbooks[couponID] + if !exists { + ob = entity.NewOrderBook(couponID) + s.orderbooks[couponID] = ob + } + return ob +} + +// PlaceOrder submits an order to the matching engine, attempts matching, +// and places any unmatched remainder on the book. +func (s *MatchingService) PlaceOrder(order *entity.Order) *MatchResult { + ob := s.getOrCreateOrderBook(order.CouponID) + result := &MatchResult{UpdatedOrder: order} + + // Create a trade factory closure that the orderbook entity can use + createTrade := func(buy, sell *entity.Order, price vo.Price, qty int) *entity.Trade { + seq := atomic.AddInt64(&s.tradeSeq, 1) + tradeID := fmt.Sprintf("trade-%d", seq) + trade, _ := entity.NewTrade(tradeID, buy, sell, price, qty) + + // Publish trade executed event + _ = s.publisher.Publish(event.NewTradeExecutedEvent( + trade.ID, trade.CouponID, trade.BuyOrderID, trade.SellOrderID, + trade.BuyerID, trade.SellerID, trade.Price.Float64(), trade.Quantity, + )) + + return trade + } + + // Execute matching via the domain entity + if order.Side == vo.Buy { + result.Trades = ob.MatchBuyOrder(order, createTrade) + } else { + result.Trades = ob.MatchSellOrder(order, createTrade) + } + + // If the order still has remaining quantity, add to the book (limit orders only) + if order.RemainingQty.IsPositive() && order.Status != entity.OrderCancelled { + if order.Type == vo.Limit { + ob.AddOrder(order) + if order.FilledQty.IsPositive() { + order.Status = entity.OrderPartial + } + } + } + + // Publish order placed event + _ = s.publisher.Publish(event.NewOrderPlacedEvent( + order.ID, order.UserID, order.CouponID, + order.Side, order.Type, order.Price.Float64(), order.Quantity.Int(), + )) + + // Publish match events for each trade + for _, t := range result.Trades { + _ = s.publisher.Publish(event.NewOrderMatchedEvent( + order.ID, order.CouponID, t.Quantity, t.Price.Float64(), order.IsFilled(), + )) + } + + return result +} + +// CancelOrder removes an order from the order book. +func (s *MatchingService) CancelOrder(couponID, orderID string, side vo.OrderSide) bool { + ob := s.getOrCreateOrderBook(couponID) + removed := ob.RemoveOrder(orderID, side) + + if removed { + _ = s.publisher.Publish(event.NewOrderCancelledEvent(orderID, "", couponID)) + } + + return removed +} + +// GetOrderBookSnapshot returns a depth-limited snapshot of an order book. +func (s *MatchingService) GetOrderBookSnapshot(couponID string, depth int) (bids []entity.PriceLevel, asks []entity.PriceLevel) { + ob := s.getOrCreateOrderBook(couponID) + return ob.Snapshot(depth) +} + +// GetAllOrderBooks returns a snapshot map of all active order books (for admin use). +func (s *MatchingService) GetAllOrderBooks() map[string]*entity.OrderBook { + s.mu.RLock() + defer s.mu.RUnlock() + result := make(map[string]*entity.OrderBook, len(s.orderbooks)) + for k, v := range s.orderbooks { + result[k] = v + } + return result +} + +// GetTradeCount returns the total number of trades executed. +func (s *MatchingService) GetTradeCount() int64 { + return atomic.LoadInt64(&s.tradeSeq) +} diff --git a/backend/services/trading-service/internal/application/service/trade_service.go b/backend/services/trading-service/internal/application/service/trade_service.go new file mode 100644 index 0000000..2a4d756 --- /dev/null +++ b/backend/services/trading-service/internal/application/service/trade_service.go @@ -0,0 +1,152 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/genex/trading-service/internal/domain/entity" + "github.com/genex/trading-service/internal/domain/event" + "github.com/genex/trading-service/internal/domain/repository" + "github.com/genex/trading-service/internal/domain/vo" +) + +// TradeService is the application-level use-case service for trading operations. +// It coordinates between the matching engine, repositories, and event publishing. +type TradeService struct { + orderRepo repository.OrderRepository + tradeRepo repository.TradeRepository + matchingService *MatchingService + publisher event.EventPublisher +} + +// NewTradeService creates a new TradeService with all dependencies injected. +func NewTradeService( + orderRepo repository.OrderRepository, + tradeRepo repository.TradeRepository, + matchingService *MatchingService, + publisher event.EventPublisher, +) *TradeService { + if publisher == nil { + publisher = &event.NoopEventPublisher{} + } + return &TradeService{ + orderRepo: orderRepo, + tradeRepo: tradeRepo, + matchingService: matchingService, + publisher: publisher, + } +} + +// PlaceOrderInput carries the validated input for placing an order. +type PlaceOrderInput struct { + UserID string + CouponID string + Side vo.OrderSide + Type vo.OrderType + Price vo.Price + Quantity vo.Quantity +} + +// PlaceOrderOutput is the result of a place order use case. +type PlaceOrderOutput struct { + Order *entity.Order + Trades []*entity.Trade +} + +// PlaceOrder handles the full workflow of placing an order: +// 1. Create the domain entity +// 2. Persist the order +// 3. Submit to matching engine +// 4. Persist resulting trades +// 5. Update order status +func (s *TradeService) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*PlaceOrderOutput, error) { + // Generate order ID + orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano()) + + // Create domain entity with validation + order, err := entity.NewOrder( + orderID, input.UserID, input.CouponID, + input.Side, input.Type, input.Price, input.Quantity, + ) + if err != nil { + return nil, fmt.Errorf("invalid order: %w", err) + } + + // Persist the new order + if err := s.orderRepo.Save(ctx, order); err != nil { + return nil, fmt.Errorf("failed to save order: %w", err) + } + + // Submit to matching engine + matchResult := s.matchingService.PlaceOrder(order) + + // Persist resulting trades + for _, trade := range matchResult.Trades { + if err := s.tradeRepo.Save(ctx, trade); err != nil { + return nil, fmt.Errorf("failed to save trade %s: %w", trade.ID, err) + } + } + + // Update order status after matching + if err := s.orderRepo.UpdateStatus(ctx, order); err != nil { + return nil, fmt.Errorf("failed to update order status: %w", err) + } + + return &PlaceOrderOutput{ + Order: matchResult.UpdatedOrder, + Trades: matchResult.Trades, + }, nil +} + +// CancelOrder cancels an active order by removing it from the order book. +func (s *TradeService) CancelOrder(ctx context.Context, couponID, orderID string, side vo.OrderSide) error { + success := s.matchingService.CancelOrder(couponID, orderID, side) + if !success { + return fmt.Errorf("order not found in order book: %s", orderID) + } + + // Update order status in repository + order, err := s.orderRepo.FindByID(ctx, orderID) + if err == nil && order != nil { + order.Cancel() + _ = s.orderRepo.UpdateStatus(ctx, order) + } + + return nil +} + +// GetOrdersByUser retrieves all orders for a given user. +func (s *TradeService) GetOrdersByUser(ctx context.Context, userID string) ([]*entity.Order, error) { + return s.orderRepo.FindByUserID(ctx, userID) +} + +// GetOrder retrieves a single order by ID. +func (s *TradeService) GetOrder(ctx context.Context, orderID string) (*entity.Order, error) { + return s.orderRepo.FindByID(ctx, orderID) +} + +// GetTradesByOrder retrieves all trades associated with an order. +func (s *TradeService) GetTradesByOrder(ctx context.Context, orderID string) ([]*entity.Trade, error) { + return s.tradeRepo.FindByOrderID(ctx, orderID) +} + +// GetRecentTrades retrieves the most recent trades. +func (s *TradeService) GetRecentTrades(ctx context.Context, limit int) ([]*entity.Trade, error) { + return s.tradeRepo.FindRecent(ctx, limit) +} + +// GetOrderBookSnapshot delegates to the matching service for order book data. +func (s *TradeService) GetOrderBookSnapshot(couponID string, depth int) (bids []entity.PriceLevel, asks []entity.PriceLevel) { + return s.matchingService.GetOrderBookSnapshot(couponID, depth) +} + +// GetAllOrderBooks returns all active order books (admin use). +func (s *TradeService) GetAllOrderBooks() map[string]*entity.OrderBook { + return s.matchingService.GetAllOrderBooks() +} + +// GetTradeCount returns the total trade count. +func (s *TradeService) GetTradeCount() int64 { + return s.matchingService.GetTradeCount() +} diff --git a/backend/services/trading-service/internal/domain/entity/order.go b/backend/services/trading-service/internal/domain/entity/order.go index 06b03c9..ab97f4a 100644 --- a/backend/services/trading-service/internal/domain/entity/order.go +++ b/backend/services/trading-service/internal/domain/entity/order.go @@ -1,34 +1,101 @@ package entity -import "time" +import ( + "fmt" + "time" -type OrderSide string -type OrderType string + "github.com/genex/trading-service/internal/domain/vo" +) + +// OrderStatus represents the lifecycle status of an order. type OrderStatus string const ( - Buy OrderSide = "buy" - Sell OrderSide = "sell" - - Limit OrderType = "limit" - Market OrderType = "market" - OrderPending OrderStatus = "pending" OrderPartial OrderStatus = "partial" OrderFilled OrderStatus = "filled" OrderCancelled OrderStatus = "cancelled" ) +// Order is the core domain entity representing a trading order. type Order struct { - ID string `json:"id"` - UserID string `json:"userId"` - CouponID string `json:"couponId"` - Side OrderSide `json:"side"` - Type OrderType `json:"type"` - Price float64 `json:"price"` - Quantity int `json:"quantity"` - FilledQty int `json:"filledQty"` - RemainingQty int `json:"remainingQty"` - Status OrderStatus `json:"status"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + UserID string `json:"userId"` + CouponID string `json:"couponId"` + Side vo.OrderSide `json:"side"` + Type vo.OrderType `json:"type"` + Price vo.Price `json:"price"` + Quantity vo.Quantity `json:"quantity"` + FilledQty vo.Quantity `json:"filledQty"` + RemainingQty vo.Quantity `json:"remainingQty"` + Status OrderStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// NewOrder creates a new Order with validated inputs. +func NewOrder(id, userID, couponID string, side vo.OrderSide, orderType vo.OrderType, price vo.Price, quantity vo.Quantity) (*Order, error) { + if id == "" { + return nil, fmt.Errorf("order ID cannot be empty") + } + if userID == "" { + return nil, fmt.Errorf("user ID cannot be empty") + } + if couponID == "" { + return nil, fmt.Errorf("coupon ID cannot be empty") + } + if !side.IsValid() { + return nil, fmt.Errorf("invalid order side: %s", side) + } + if !orderType.IsValid() { + return nil, fmt.Errorf("invalid order type: %s", orderType) + } + if orderType == vo.Limit && !price.IsPositive() { + return nil, fmt.Errorf("limit order must have a positive price") + } + + now := time.Now() + return &Order{ + ID: id, + UserID: userID, + CouponID: couponID, + Side: side, + Type: orderType, + Price: price, + Quantity: quantity, + FilledQty: vo.ZeroQuantity(), + RemainingQty: quantity, + Status: OrderPending, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// Fill records a partial or full fill of the given quantity. +func (o *Order) Fill(qty vo.Quantity) { + o.FilledQty = o.FilledQty.Add(qty) + o.RemainingQty = o.RemainingQty.Subtract(qty) + o.UpdatedAt = time.Now() + + if o.RemainingQty.IsZero() { + o.Status = OrderFilled + } else { + o.Status = OrderPartial + } +} + +// Cancel marks the order as cancelled. +func (o *Order) Cancel() { + o.Status = OrderCancelled + o.UpdatedAt = time.Now() +} + +// IsActive returns true if the order can still participate in matching. +func (o *Order) IsActive() bool { + return o.Status == OrderPending || o.Status == OrderPartial +} + +// IsFilled returns true if the order is fully filled. +func (o *Order) IsFilled() bool { + return o.Status == OrderFilled } diff --git a/backend/services/trading-service/internal/domain/entity/orderbook.go b/backend/services/trading-service/internal/domain/entity/orderbook.go new file mode 100644 index 0000000..a548532 --- /dev/null +++ b/backend/services/trading-service/internal/domain/entity/orderbook.go @@ -0,0 +1,219 @@ +package entity + +import ( + "sort" + "sync" + + "github.com/genex/trading-service/internal/domain/vo" +) + +// PriceLevel represents a group of orders at the same price level. +type PriceLevel struct { + Price vo.Price `json:"price"` + Orders []*Order `json:"orders"` +} + +// TotalQuantity returns the sum of remaining quantities at this price level. +func (pl *PriceLevel) TotalQuantity() int { + total := 0 + for _, o := range pl.Orders { + total += o.RemainingQty.Int() + } + return total +} + +// OrderCount returns the number of orders at this price level. +func (pl *PriceLevel) OrderCount() int { + return len(pl.Orders) +} + +// OrderBook is a domain entity representing the order book for a single coupon. +// It maintains bid and ask price levels sorted for price-time priority matching. +type OrderBook struct { + CouponID string `json:"couponId"` + Bids []PriceLevel `json:"bids"` // sorted descending (highest bid first) + Asks []PriceLevel `json:"asks"` // sorted ascending (lowest ask first) + mu sync.RWMutex +} + +// NewOrderBook creates an empty order book for a given coupon. +func NewOrderBook(couponID string) *OrderBook { + return &OrderBook{CouponID: couponID} +} + +// AddOrder inserts an order into the appropriate side of the order book. +func (ob *OrderBook) AddOrder(order *Order) { + ob.mu.Lock() + defer ob.mu.Unlock() + + if order.Side == vo.Buy { + ob.addToPriceLevels(&ob.Bids, order, true) + } else { + ob.addToPriceLevels(&ob.Asks, order, false) + } +} + +// RemoveOrder removes an order from the book by ID and side. Returns true if found. +func (ob *OrderBook) RemoveOrder(orderID string, side vo.OrderSide) bool { + ob.mu.Lock() + defer ob.mu.Unlock() + + levels := &ob.Bids + if side == vo.Sell { + levels = &ob.Asks + } + + for i, level := range *levels { + for j, o := range level.Orders { + if o.ID == orderID { + level.Orders = append(level.Orders[:j], level.Orders[j+1:]...) + if len(level.Orders) == 0 { + *levels = append((*levels)[:i], (*levels)[i+1:]...) + } else { + (*levels)[i] = level + } + return true + } + } + } + return false +} + +// BestBid returns the highest bid price level, or nil if no bids exist. +func (ob *OrderBook) BestBid() *PriceLevel { + ob.mu.RLock() + defer ob.mu.RUnlock() + if len(ob.Bids) == 0 { + return nil + } + return &ob.Bids[0] +} + +// BestAsk returns the lowest ask price level, or nil if no asks exist. +func (ob *OrderBook) BestAsk() *PriceLevel { + ob.mu.RLock() + defer ob.mu.RUnlock() + if len(ob.Asks) == 0 { + return nil + } + return &ob.Asks[0] +} + +// Snapshot returns a copy of up to `depth` price levels from each side. +func (ob *OrderBook) Snapshot(depth int) (bids []PriceLevel, asks []PriceLevel) { + ob.mu.RLock() + defer ob.mu.RUnlock() + + bidDepth := minInt(depth, len(ob.Bids)) + askDepth := minInt(depth, len(ob.Asks)) + + bids = make([]PriceLevel, bidDepth) + copy(bids, ob.Bids[:bidDepth]) + asks = make([]PriceLevel, askDepth) + copy(asks, ob.Asks[:askDepth]) + return +} + +// MatchBuyOrder attempts to match a buy order against the ask side. +// Returns the list of trades created and modifies the order book in place. +// The caller must hold no lock; this method acquires the write lock internally. +func (ob *OrderBook) MatchBuyOrder(buyOrder *Order, createTrade func(buy, sell *Order, price vo.Price, qty int) *Trade) []*Trade { + ob.mu.Lock() + defer ob.mu.Unlock() + + var trades []*Trade + for len(ob.Asks) > 0 && buyOrder.RemainingQty.IsPositive() { + bestAsk := &ob.Asks[0] + + // For limit orders, stop if the ask price exceeds our limit + if buyOrder.Type == vo.Limit && bestAsk.Price.GreaterThan(buyOrder.Price) { + break + } + + for len(bestAsk.Orders) > 0 && buyOrder.RemainingQty.IsPositive() { + sellOrder := bestAsk.Orders[0] + matchQty := buyOrder.RemainingQty.Min(sellOrder.RemainingQty) + matchPrice := sellOrder.Price + + trade := createTrade(buyOrder, sellOrder, matchPrice, matchQty.Int()) + trades = append(trades, trade) + + buyOrder.Fill(matchQty) + sellOrder.Fill(matchQty) + + if sellOrder.RemainingQty.IsZero() { + bestAsk.Orders = bestAsk.Orders[1:] + } + } + + if len(bestAsk.Orders) == 0 { + ob.Asks = ob.Asks[1:] + } + } + + return trades +} + +// MatchSellOrder attempts to match a sell order against the bid side. +// Returns the list of trades created and modifies the order book in place. +func (ob *OrderBook) MatchSellOrder(sellOrder *Order, createTrade func(buy, sell *Order, price vo.Price, qty int) *Trade) []*Trade { + ob.mu.Lock() + defer ob.mu.Unlock() + + var trades []*Trade + for len(ob.Bids) > 0 && sellOrder.RemainingQty.IsPositive() { + bestBid := &ob.Bids[0] + + // For limit orders, stop if the bid price is below our limit + if sellOrder.Type == vo.Limit && bestBid.Price.LessThan(sellOrder.Price) { + break + } + + for len(bestBid.Orders) > 0 && sellOrder.RemainingQty.IsPositive() { + buyOrder := bestBid.Orders[0] + matchQty := sellOrder.RemainingQty.Min(buyOrder.RemainingQty) + matchPrice := buyOrder.Price + + trade := createTrade(buyOrder, sellOrder, matchPrice, matchQty.Int()) + trades = append(trades, trade) + + sellOrder.Fill(matchQty) + buyOrder.Fill(matchQty) + + if buyOrder.RemainingQty.IsZero() { + bestBid.Orders = bestBid.Orders[1:] + } + } + + if len(bestBid.Orders) == 0 { + ob.Bids = ob.Bids[1:] + } + } + + return trades +} + +// addToPriceLevels inserts an order into the correct price level, +// creating a new level if needed. Maintains sort order. +func (ob *OrderBook) addToPriceLevels(levels *[]PriceLevel, order *Order, descending bool) { + for i, level := range *levels { + if level.Price.Equal(order.Price) { + (*levels)[i].Orders = append((*levels)[i].Orders, order) + return + } + } + *levels = append(*levels, PriceLevel{Price: order.Price, Orders: []*Order{order}}) + sort.Slice(*levels, func(i, j int) bool { + if descending { + return (*levels)[i].Price.GreaterThan((*levels)[j].Price) + } + return (*levels)[i].Price.LessThan((*levels)[j].Price) + }) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/services/trading-service/internal/domain/entity/trade.go b/backend/services/trading-service/internal/domain/entity/trade.go index 117dfd8..15938b3 100644 --- a/backend/services/trading-service/internal/domain/entity/trade.go +++ b/backend/services/trading-service/internal/domain/entity/trade.go @@ -1,17 +1,68 @@ package entity -import "time" +import ( + "fmt" + "time" + "github.com/genex/trading-service/internal/domain/vo" +) + +const ( + // TakerFeeRate is the fee rate charged to the taker (0.5%). + TakerFeeRate = 0.005 + // MakerFeeRate is the fee rate charged to the maker (0.1%). + MakerFeeRate = 0.001 +) + +// Trade represents an executed trade between a buyer and seller. type Trade struct { - ID string `json:"id"` - CouponID string `json:"couponId"` - BuyOrderID string `json:"buyOrderId"` - SellOrderID string `json:"sellOrderId"` - BuyerID string `json:"buyerId"` - SellerID string `json:"sellerId"` - Price float64 `json:"price"` - Quantity int `json:"quantity"` - BuyerFee float64 `json:"buyerFee"` - SellerFee float64 `json:"sellerFee"` + ID string `json:"id"` + CouponID string `json:"couponId"` + BuyOrderID string `json:"buyOrderId"` + SellOrderID string `json:"sellOrderId"` + BuyerID string `json:"buyerId"` + SellerID string `json:"sellerId"` + Price vo.Price `json:"price"` + Quantity int `json:"quantity"` + BuyerFee float64 `json:"buyerFee"` + SellerFee float64 `json:"sellerFee"` CreatedAt time.Time `json:"createdAt"` } + +// NewTrade creates a new Trade from matched orders. +func NewTrade(id string, buyOrder, sellOrder *Order, price vo.Price, matchQty int) (*Trade, error) { + if id == "" { + return nil, fmt.Errorf("trade ID cannot be empty") + } + if matchQty <= 0 { + return nil, fmt.Errorf("trade quantity must be positive") + } + + notional := price.Multiply(matchQty) + takerFee := notional * TakerFeeRate + makerFee := notional * MakerFeeRate + + return &Trade{ + ID: id, + CouponID: buyOrder.CouponID, + BuyOrderID: buyOrder.ID, + SellOrderID: sellOrder.ID, + BuyerID: buyOrder.UserID, + SellerID: sellOrder.UserID, + Price: price, + Quantity: matchQty, + BuyerFee: takerFee, + SellerFee: makerFee, + CreatedAt: time.Now(), + }, nil +} + +// Notional returns the total value of the trade (price * quantity). +func (t *Trade) Notional() float64 { + return t.Price.Multiply(t.Quantity) +} + +// TotalFees returns the sum of buyer and seller fees. +func (t *Trade) TotalFees() float64 { + return t.BuyerFee + t.SellerFee +} diff --git a/backend/services/trading-service/internal/domain/event/events.go b/backend/services/trading-service/internal/domain/event/events.go new file mode 100644 index 0000000..6d126af --- /dev/null +++ b/backend/services/trading-service/internal/domain/event/events.go @@ -0,0 +1,129 @@ +package event + +import ( + "time" + + "github.com/genex/trading-service/internal/domain/vo" +) + +// DomainEvent is the base interface for all domain events. +type DomainEvent interface { + EventName() string + OccurredAt() time.Time +} + +// EventPublisher defines the interface for publishing domain events. +type EventPublisher interface { + Publish(event DomainEvent) error +} + +// --- Concrete Domain Events --- + +// OrderPlacedEvent is emitted when a new order is successfully placed. +type OrderPlacedEvent struct { + OrderID string `json:"orderId"` + UserID string `json:"userId"` + CouponID string `json:"couponId"` + Side vo.OrderSide `json:"side"` + Type vo.OrderType `json:"type"` + Price float64 `json:"price"` + Quantity int `json:"quantity"` + OccurredOn time.Time `json:"occurredAt"` +} + +func NewOrderPlacedEvent(orderID, userID, couponID string, side vo.OrderSide, orderType vo.OrderType, price float64, quantity int) OrderPlacedEvent { + return OrderPlacedEvent{ + OrderID: orderID, + UserID: userID, + CouponID: couponID, + Side: side, + Type: orderType, + Price: price, + Quantity: quantity, + OccurredOn: time.Now(), + } +} + +func (e OrderPlacedEvent) EventName() string { return "order.placed" } +func (e OrderPlacedEvent) OccurredAt() time.Time { return e.OccurredOn } + +// OrderMatchedEvent is emitted when an order is partially or fully matched. +type OrderMatchedEvent struct { + OrderID string `json:"orderId"` + CouponID string `json:"couponId"` + FilledQty int `json:"filledQty"` + FilledPrice float64 `json:"filledPrice"` + IsFull bool `json:"isFull"` + OccurredOn time.Time `json:"occurredAt"` +} + +func NewOrderMatchedEvent(orderID, couponID string, filledQty int, filledPrice float64, isFull bool) OrderMatchedEvent { + return OrderMatchedEvent{ + OrderID: orderID, + CouponID: couponID, + FilledQty: filledQty, + FilledPrice: filledPrice, + IsFull: isFull, + OccurredOn: time.Now(), + } +} + +func (e OrderMatchedEvent) EventName() string { return "order.matched" } +func (e OrderMatchedEvent) OccurredAt() time.Time { return e.OccurredOn } + +// OrderCancelledEvent is emitted when an order is cancelled. +type OrderCancelledEvent struct { + OrderID string `json:"orderId"` + UserID string `json:"userId"` + CouponID string `json:"couponId"` + OccurredOn time.Time `json:"occurredAt"` +} + +func NewOrderCancelledEvent(orderID, userID, couponID string) OrderCancelledEvent { + return OrderCancelledEvent{ + OrderID: orderID, + UserID: userID, + CouponID: couponID, + OccurredOn: time.Now(), + } +} + +func (e OrderCancelledEvent) EventName() string { return "order.cancelled" } +func (e OrderCancelledEvent) OccurredAt() time.Time { return e.OccurredOn } + +// TradeExecutedEvent is emitted when a trade is completed. +type TradeExecutedEvent struct { + TradeID string `json:"tradeId"` + CouponID string `json:"couponId"` + BuyOrderID string `json:"buyOrderId"` + SellOrderID string `json:"sellOrderId"` + BuyerID string `json:"buyerId"` + SellerID string `json:"sellerId"` + Price float64 `json:"price"` + Quantity int `json:"quantity"` + OccurredOn time.Time `json:"occurredAt"` +} + +func NewTradeExecutedEvent(tradeID, couponID, buyOrderID, sellOrderID, buyerID, sellerID string, price float64, quantity int) TradeExecutedEvent { + return TradeExecutedEvent{ + TradeID: tradeID, + CouponID: couponID, + BuyOrderID: buyOrderID, + SellOrderID: sellOrderID, + BuyerID: buyerID, + SellerID: sellerID, + Price: price, + Quantity: quantity, + OccurredOn: time.Now(), + } +} + +func (e TradeExecutedEvent) EventName() string { return "trade.executed" } +func (e TradeExecutedEvent) OccurredAt() time.Time { return e.OccurredOn } + +// NoopEventPublisher is a no-op publisher used as a default/fallback. +type NoopEventPublisher struct{} + +func (p *NoopEventPublisher) Publish(event DomainEvent) error { + return nil +} diff --git a/backend/services/trading-service/internal/domain/repository/order_repository.go b/backend/services/trading-service/internal/domain/repository/order_repository.go new file mode 100644 index 0000000..466bb83 --- /dev/null +++ b/backend/services/trading-service/internal/domain/repository/order_repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + + "github.com/genex/trading-service/internal/domain/entity" +) + +// OrderRepository defines the contract for order persistence. +type OrderRepository interface { + // Save persists an order (insert or update). + Save(ctx context.Context, order *entity.Order) error + + // FindByID retrieves an order by its unique ID. + FindByID(ctx context.Context, id string) (*entity.Order, error) + + // FindByUserID retrieves all orders belonging to a specific user. + FindByUserID(ctx context.Context, userID string) ([]*entity.Order, error) + + // FindActive retrieves all orders with active status (pending or partial). + FindActive(ctx context.Context) ([]*entity.Order, error) + + // FindByCouponID retrieves all orders for a specific coupon. + FindByCouponID(ctx context.Context, couponID string) ([]*entity.Order, error) + + // UpdateStatus updates the status and fill quantities of an order. + UpdateStatus(ctx context.Context, order *entity.Order) error + + // FindAll retrieves all orders with optional pagination. + FindAll(ctx context.Context, offset, limit int) ([]*entity.Order, int, error) +} diff --git a/backend/services/trading-service/internal/domain/repository/trade_repository.go b/backend/services/trading-service/internal/domain/repository/trade_repository.go new file mode 100644 index 0000000..291cf7e --- /dev/null +++ b/backend/services/trading-service/internal/domain/repository/trade_repository.go @@ -0,0 +1,29 @@ +package repository + +import ( + "context" + + "github.com/genex/trading-service/internal/domain/entity" +) + +// TradeRepository defines the contract for trade persistence. +type TradeRepository interface { + // Save persists a trade record. + Save(ctx context.Context, trade *entity.Trade) error + + // FindByID retrieves a trade by its unique ID. + FindByID(ctx context.Context, id string) (*entity.Trade, error) + + // FindByOrderID retrieves all trades associated with a given order ID + // (either as buy order or sell order). + FindByOrderID(ctx context.Context, orderID string) ([]*entity.Trade, error) + + // FindRecent retrieves the most recent trades, limited by count. + FindRecent(ctx context.Context, limit int) ([]*entity.Trade, error) + + // FindByCouponID retrieves all trades for a given coupon. + FindByCouponID(ctx context.Context, couponID string) ([]*entity.Trade, error) + + // Count returns the total number of trades. + Count(ctx context.Context) (int64, error) +} diff --git a/backend/services/trading-service/internal/domain/vo/order_side.go b/backend/services/trading-service/internal/domain/vo/order_side.go new file mode 100644 index 0000000..25866c7 --- /dev/null +++ b/backend/services/trading-service/internal/domain/vo/order_side.go @@ -0,0 +1,41 @@ +package vo + +import "fmt" + +// OrderSide is a value object representing the side of an order (buy or sell). +type OrderSide string + +const ( + Buy OrderSide = "buy" + Sell OrderSide = "sell" +) + +// ValidOrderSides lists all valid OrderSide values. +var ValidOrderSides = []OrderSide{Buy, Sell} + +// NewOrderSide creates a validated OrderSide from a string. +func NewOrderSide(s string) (OrderSide, error) { + side := OrderSide(s) + if side != Buy && side != Sell { + return "", fmt.Errorf("invalid order side: %q, must be 'buy' or 'sell'", s) + } + return side, nil +} + +// String returns the string representation of the OrderSide. +func (s OrderSide) String() string { + return string(s) +} + +// IsValid returns true if the OrderSide is a recognized value. +func (s OrderSide) IsValid() bool { + return s == Buy || s == Sell +} + +// Opposite returns the opposite side. +func (s OrderSide) Opposite() OrderSide { + if s == Buy { + return Sell + } + return Buy +} diff --git a/backend/services/trading-service/internal/domain/vo/order_type.go b/backend/services/trading-service/internal/domain/vo/order_type.go new file mode 100644 index 0000000..60ea3df --- /dev/null +++ b/backend/services/trading-service/internal/domain/vo/order_type.go @@ -0,0 +1,33 @@ +package vo + +import "fmt" + +// OrderType is a value object representing the type of an order. +type OrderType string + +const ( + Limit OrderType = "limit" + Market OrderType = "market" +) + +// ValidOrderTypes lists all valid OrderType values. +var ValidOrderTypes = []OrderType{Limit, Market} + +// NewOrderType creates a validated OrderType from a string. +func NewOrderType(s string) (OrderType, error) { + t := OrderType(s) + if t != Limit && t != Market { + return "", fmt.Errorf("invalid order type: %q, must be 'limit' or 'market'", s) + } + return t, nil +} + +// String returns the string representation of the OrderType. +func (t OrderType) String() string { + return string(t) +} + +// IsValid returns true if the OrderType is a recognized value. +func (t OrderType) IsValid() bool { + return t == Limit || t == Market +} diff --git a/backend/services/trading-service/internal/domain/vo/price.go b/backend/services/trading-service/internal/domain/vo/price.go new file mode 100644 index 0000000..0e01873 --- /dev/null +++ b/backend/services/trading-service/internal/domain/vo/price.go @@ -0,0 +1,100 @@ +package vo + +import ( + "fmt" + "math" +) + +// Price is a value object representing a positive price with decimal precision. +type Price struct { + value float64 +} + +// NewPrice creates a validated Price. Value must be positive. +func NewPrice(v float64) (Price, error) { + if v < 0 { + return Price{}, fmt.Errorf("price must be non-negative, got %f", v) + } + if math.IsNaN(v) || math.IsInf(v, 0) { + return Price{}, fmt.Errorf("price must be a finite number, got %f", v) + } + return Price{value: v}, nil +} + +// MustNewPrice creates a Price, panicking on invalid input. Use only in tests. +func MustNewPrice(v float64) Price { + p, err := NewPrice(v) + if err != nil { + panic(err) + } + return p +} + +// Zero returns a zero price. +func ZeroPrice() Price { + return Price{value: 0} +} + +// Float64 returns the underlying float64 value. +func (p Price) Float64() float64 { + return p.value +} + +// IsZero returns true if the price is zero. +func (p Price) IsZero() bool { + return p.value == 0 +} + +// IsPositive returns true if the price is strictly greater than zero. +func (p Price) IsPositive() bool { + return p.value > 0 +} + +// GreaterThan returns true if this price is greater than other. +func (p Price) GreaterThan(other Price) bool { + return p.value > other.value +} + +// LessThan returns true if this price is less than other. +func (p Price) LessThan(other Price) bool { + return p.value < other.value +} + +// Equal returns true if both prices are equal. +func (p Price) Equal(other Price) bool { + return p.value == other.value +} + +// Add returns a new Price that is the sum of two prices. +func (p Price) Add(other Price) Price { + return Price{value: p.value + other.value} +} + +// Multiply returns price * quantity as a float64 (for notional calculation). +func (p Price) Multiply(qty int) float64 { + return p.value * float64(qty) +} + +// MidPrice returns the midpoint between two prices. +func MidPrice(a, b Price) Price { + return Price{value: (a.value + b.value) / 2} +} + +// Spread returns (ask - bid) / bid as a float64 ratio. +func Spread(bid, ask Price) float64 { + if bid.IsZero() { + return 0 + } + return (ask.value - bid.value) / bid.value +} + +// RoundTo rounds the price to the given number of decimal places. +func (p Price) RoundTo(decimals int) Price { + ratio := math.Pow(10, float64(decimals)) + return Price{value: math.Round(p.value*ratio) / ratio} +} + +// String returns a human-readable representation. +func (p Price) String() string { + return fmt.Sprintf("%.4f", p.value) +} diff --git a/backend/services/trading-service/internal/domain/vo/quantity.go b/backend/services/trading-service/internal/domain/vo/quantity.go new file mode 100644 index 0000000..51e9d00 --- /dev/null +++ b/backend/services/trading-service/internal/domain/vo/quantity.go @@ -0,0 +1,80 @@ +package vo + +import "fmt" + +// Quantity is a value object representing a positive integer quantity. +type Quantity struct { + value int +} + +// NewQuantity creates a validated Quantity. Value must be positive (> 0). +func NewQuantity(v int) (Quantity, error) { + if v <= 0 { + return Quantity{}, fmt.Errorf("quantity must be positive, got %d", v) + } + return Quantity{value: v}, nil +} + +// MustNewQuantity creates a Quantity, panicking on invalid input. Use only in tests. +func MustNewQuantity(v int) Quantity { + q, err := NewQuantity(v) + if err != nil { + panic(err) + } + return q +} + +// QuantityFromInt creates a Quantity from an int, allowing zero (for remaining qty tracking). +func QuantityFromInt(v int) Quantity { + if v < 0 { + v = 0 + } + return Quantity{value: v} +} + +// ZeroQuantity returns a zero quantity. +func ZeroQuantity() Quantity { + return Quantity{value: 0} +} + +// Int returns the underlying integer value. +func (q Quantity) Int() int { + return q.value +} + +// IsZero returns true if quantity is zero. +func (q Quantity) IsZero() bool { + return q.value == 0 +} + +// IsPositive returns true if quantity is > 0. +func (q Quantity) IsPositive() bool { + return q.value > 0 +} + +// Add returns a new Quantity that is the sum of two quantities. +func (q Quantity) Add(other Quantity) Quantity { + return Quantity{value: q.value + other.value} +} + +// Subtract returns a new Quantity, clamped to zero if result would be negative. +func (q Quantity) Subtract(other Quantity) Quantity { + result := q.value - other.value + if result < 0 { + result = 0 + } + return Quantity{value: result} +} + +// Min returns the smaller of two quantities. +func (q Quantity) Min(other Quantity) Quantity { + if q.value < other.value { + return q + } + return other +} + +// String returns a human-readable representation. +func (q Quantity) String() string { + return fmt.Sprintf("%d", q.value) +} diff --git a/backend/services/trading-service/internal/infrastructure/kafka/event_publisher.go b/backend/services/trading-service/internal/infrastructure/kafka/event_publisher.go new file mode 100644 index 0000000..c3bb91e --- /dev/null +++ b/backend/services/trading-service/internal/infrastructure/kafka/event_publisher.go @@ -0,0 +1,72 @@ +package kafka + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/IBM/sarama" + "github.com/genex/trading-service/internal/domain/event" +) + +// KafkaEventPublisher implements event.EventPublisher by publishing domain events to Kafka +// using the IBM/sarama client. +type KafkaEventPublisher struct { + producer sarama.SyncProducer +} + +// NewKafkaEventPublisher creates a new Kafka event publisher connected to the given brokers. +func NewKafkaEventPublisher(brokers []string) (*KafkaEventPublisher, error) { + config := sarama.NewConfig() + config.Producer.RequiredAcks = sarama.WaitForAll + config.Producer.Retry.Max = 3 + config.Producer.Return.Successes = true + + producer, err := sarama.NewSyncProducer(brokers, config) + if err != nil { + return nil, fmt.Errorf("failed to create Kafka producer: %w", err) + } + + return &KafkaEventPublisher{producer: producer}, nil +} + +// Publish serializes a domain event to JSON and publishes it to the appropriate Kafka topic. +// Topic is derived from the event name: "order.placed" → "trading.orders", +// "trade.executed" → "trading.trades". +func (p *KafkaEventPublisher) Publish(evt event.DomainEvent) error { + payload, err := json.Marshal(evt) + if err != nil { + return fmt.Errorf("failed to marshal event %s: %w", evt.EventName(), err) + } + + topic := resolveTopic(evt.EventName()) + + msg := &sarama.ProducerMessage{ + Topic: topic, + Key: sarama.StringEncoder(evt.EventName()), + Value: sarama.ByteEncoder(payload), + } + + _, _, err = p.producer.SendMessage(msg) + if err != nil { + return fmt.Errorf("failed to publish event %s to topic %s: %w", evt.EventName(), topic, err) + } + + return nil +} + +// Close shuts down the Kafka producer gracefully. +func (p *KafkaEventPublisher) Close() error { + if p.producer != nil { + return p.producer.Close() + } + return nil +} + +// resolveTopic maps event names to Kafka topics. +func resolveTopic(eventName string) string { + if strings.HasPrefix(eventName, "trade.") { + return "trading.trades" + } + return "trading.orders" +} diff --git a/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go b/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go new file mode 100644 index 0000000..b343809 --- /dev/null +++ b/backend/services/trading-service/internal/infrastructure/postgres/order_repository.go @@ -0,0 +1,207 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/genex/trading-service/internal/domain/entity" + "github.com/genex/trading-service/internal/domain/repository" + "github.com/genex/trading-service/internal/domain/vo" + "gorm.io/gorm" +) + +// Compile-time check: PostgresOrderRepository implements repository.OrderRepository. +var _ repository.OrderRepository = (*PostgresOrderRepository)(nil) + +// orderModel is the GORM persistence model for the orders table. +// It lives in the infrastructure layer, keeping the domain entity free of ORM concerns. +type orderModel struct { + ID string `gorm:"column:id;primaryKey"` + UserID string `gorm:"column:user_id;not null"` + CouponID string `gorm:"column:coupon_id;not null"` + Side string `gorm:"column:side;not null"` + OrderType string `gorm:"column:order_type;not null;default:limit"` + Price float64 `gorm:"column:price;not null"` + Quantity int `gorm:"column:quantity;not null;default:1"` + FilledQuantity int `gorm:"column:filled_quantity;not null;default:0"` + Status string `gorm:"column:status;not null;default:open"` + IsMaker bool `gorm:"column:is_maker;not null;default:false"` + CancelledAt *time.Time `gorm:"column:cancelled_at"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` +} + +func (orderModel) TableName() string { return "orders" } + +// dbStatusToDomain maps database status values to domain OrderStatus. +func dbStatusToDomain(dbStatus string) entity.OrderStatus { + switch dbStatus { + case "open": + return entity.OrderPending + case "partial": + return entity.OrderPartial + case "filled": + return entity.OrderFilled + case "cancelled": + return entity.OrderCancelled + default: + return entity.OrderStatus(dbStatus) + } +} + +// domainStatusToDB maps domain OrderStatus to database status values. +func domainStatusToDB(status entity.OrderStatus) string { + switch status { + case entity.OrderPending: + return "open" + case entity.OrderPartial: + return "partial" + case entity.OrderFilled: + return "filled" + case entity.OrderCancelled: + return "cancelled" + default: + return string(status) + } +} + +func (m *orderModel) toEntity() *entity.Order { + remaining := m.Quantity - m.FilledQuantity + if remaining < 0 { + remaining = 0 + } + return &entity.Order{ + ID: m.ID, + UserID: m.UserID, + CouponID: m.CouponID, + Side: vo.OrderSide(m.Side), + Type: vo.OrderType(m.OrderType), + Price: vo.MustNewPrice(m.Price), + Quantity: vo.QuantityFromInt(m.Quantity), + FilledQty: vo.QuantityFromInt(m.FilledQuantity), + RemainingQty: vo.QuantityFromInt(remaining), + Status: dbStatusToDomain(m.Status), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func orderFromEntity(e *entity.Order) *orderModel { + var cancelledAt *time.Time + if e.Status == entity.OrderCancelled { + now := time.Now() + cancelledAt = &now + } + return &orderModel{ + ID: e.ID, + UserID: e.UserID, + CouponID: e.CouponID, + Side: string(e.Side), + OrderType: string(e.Type), + Price: e.Price.Float64(), + Quantity: e.Quantity.Int(), + FilledQuantity: e.FilledQty.Int(), + Status: domainStatusToDB(e.Status), + CancelledAt: cancelledAt, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +// PostgresOrderRepository is the GORM-backed implementation of repository.OrderRepository. +type PostgresOrderRepository struct { + db *gorm.DB +} + +// NewPostgresOrderRepository creates a new repository backed by PostgreSQL via GORM. +func NewPostgresOrderRepository(db *gorm.DB) *PostgresOrderRepository { + return &PostgresOrderRepository{db: db} +} + +func (r *PostgresOrderRepository) Save(ctx context.Context, order *entity.Order) error { + if order == nil { + return fmt.Errorf("order must not be nil") + } + model := orderFromEntity(order) + return r.db.WithContext(ctx).Save(model).Error +} + +func (r *PostgresOrderRepository) FindByID(ctx context.Context, id string) (*entity.Order, error) { + var model orderModel + err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("order not found: %s", id) + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresOrderRepository) FindByUserID(ctx context.Context, userID string) ([]*entity.Order, error) { + var models []orderModel + err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Order, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresOrderRepository) FindActive(ctx context.Context) ([]*entity.Order, error) { + var models []orderModel + err := r.db.WithContext(ctx).Where("status IN ?", []string{"open", "partial"}).Order("created_at ASC").Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Order, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresOrderRepository) FindByCouponID(ctx context.Context, couponID string) ([]*entity.Order, error) { + var models []orderModel + err := r.db.WithContext(ctx).Where("coupon_id = ?", couponID).Order("created_at DESC").Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Order, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresOrderRepository) UpdateStatus(ctx context.Context, order *entity.Order) error { + if order == nil { + return fmt.Errorf("order must not be nil") + } + model := orderFromEntity(order) + return r.db.WithContext(ctx).Save(model).Error +} + +func (r *PostgresOrderRepository) FindAll(ctx context.Context, offset, limit int) ([]*entity.Order, int, error) { + var models []orderModel + var total int64 + + base := r.db.WithContext(ctx).Model(&orderModel{}) + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := base.Order("created_at DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil { + return nil, 0, err + } + + result := make([]*entity.Order, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, int(total), nil +} diff --git a/backend/services/trading-service/internal/infrastructure/postgres/trade_repository.go b/backend/services/trading-service/internal/infrastructure/postgres/trade_repository.go new file mode 100644 index 0000000..1ac6bb1 --- /dev/null +++ b/backend/services/trading-service/internal/infrastructure/postgres/trade_repository.go @@ -0,0 +1,146 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/genex/trading-service/internal/domain/entity" + "github.com/genex/trading-service/internal/domain/repository" + "github.com/genex/trading-service/internal/domain/vo" + "gorm.io/gorm" +) + +// Compile-time check: PostgresTradeRepository implements repository.TradeRepository. +var _ repository.TradeRepository = (*PostgresTradeRepository)(nil) + +// tradeModel is the GORM persistence model for the trades table. +type tradeModel struct { + ID string `gorm:"column:id;primaryKey"` + BuyOrderID string `gorm:"column:buy_order_id;not null"` + SellOrderID string `gorm:"column:sell_order_id;not null"` + CouponID string `gorm:"column:coupon_id;not null"` + BuyerID string `gorm:"column:buyer_id;not null"` + SellerID string `gorm:"column:seller_id;not null"` + Price float64 `gorm:"column:price;not null"` + Quantity int `gorm:"column:quantity;not null;default:1"` + BuyerFee float64 `gorm:"column:buyer_fee;not null;default:0"` + SellerFee float64 `gorm:"column:seller_fee;not null;default:0"` + Status string `gorm:"column:status;not null;default:pending"` + TxHash *string `gorm:"column:tx_hash"` + SettledAt *time.Time `gorm:"column:settled_at"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` +} + +func (tradeModel) TableName() string { return "trades" } + +func (m *tradeModel) toEntity() *entity.Trade { + return &entity.Trade{ + ID: m.ID, + CouponID: m.CouponID, + BuyOrderID: m.BuyOrderID, + SellOrderID: m.SellOrderID, + BuyerID: m.BuyerID, + SellerID: m.SellerID, + Price: vo.MustNewPrice(m.Price), + Quantity: m.Quantity, + BuyerFee: m.BuyerFee, + SellerFee: m.SellerFee, + CreatedAt: m.CreatedAt, + } +} + +func tradeFromEntity(e *entity.Trade) *tradeModel { + return &tradeModel{ + ID: e.ID, + BuyOrderID: e.BuyOrderID, + SellOrderID: e.SellOrderID, + CouponID: e.CouponID, + BuyerID: e.BuyerID, + SellerID: e.SellerID, + Price: e.Price.Float64(), + Quantity: e.Quantity, + BuyerFee: e.BuyerFee, + SellerFee: e.SellerFee, + Status: "pending", + CreatedAt: e.CreatedAt, + } +} + +// PostgresTradeRepository is the GORM-backed implementation of repository.TradeRepository. +type PostgresTradeRepository struct { + db *gorm.DB +} + +// NewPostgresTradeRepository creates a new repository backed by PostgreSQL via GORM. +func NewPostgresTradeRepository(db *gorm.DB) *PostgresTradeRepository { + return &PostgresTradeRepository{db: db} +} + +func (r *PostgresTradeRepository) Save(ctx context.Context, trade *entity.Trade) error { + if trade == nil { + return fmt.Errorf("trade must not be nil") + } + model := tradeFromEntity(trade) + return r.db.WithContext(ctx).Save(model).Error +} + +func (r *PostgresTradeRepository) FindByID(ctx context.Context, id string) (*entity.Trade, error) { + var model tradeModel + err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("trade not found: %s", id) + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresTradeRepository) FindByOrderID(ctx context.Context, orderID string) ([]*entity.Trade, error) { + var models []tradeModel + err := r.db.WithContext(ctx). + Where("buy_order_id = ? OR sell_order_id = ?", orderID, orderID). + Order("created_at DESC"). + Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Trade, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresTradeRepository) FindRecent(ctx context.Context, limit int) ([]*entity.Trade, error) { + var models []tradeModel + err := r.db.WithContext(ctx).Order("created_at DESC").Limit(limit).Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Trade, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresTradeRepository) FindByCouponID(ctx context.Context, couponID string) ([]*entity.Trade, error) { + var models []tradeModel + err := r.db.WithContext(ctx).Where("coupon_id = ?", couponID).Order("created_at DESC").Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.Trade, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresTradeRepository) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&tradeModel{}).Count(&count).Error + return count, err +} diff --git a/backend/services/trading-service/internal/interface/http/handler/admin_mm_handler.go b/backend/services/trading-service/internal/interface/http/handler/admin_mm_handler.go index 4428063..fe852b8 100644 --- a/backend/services/trading-service/internal/interface/http/handler/admin_mm_handler.go +++ b/backend/services/trading-service/internal/interface/http/handler/admin_mm_handler.go @@ -6,22 +6,21 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/genex/trading-service/internal/matching" + appservice "github.com/genex/trading-service/internal/application/service" ) // AdminMMHandler handles admin market maker endpoints. type AdminMMHandler struct { - engine *matching.Engine + tradeService *appservice.TradeService } // NewAdminMMHandler creates a new AdminMMHandler. -func NewAdminMMHandler(engine *matching.Engine) *AdminMMHandler { - return &AdminMMHandler{engine: engine} +func NewAdminMMHandler(tradeService *appservice.TradeService) *AdminMMHandler { + return &AdminMMHandler{tradeService: tradeService} } // ListMarketMakers returns market maker list with statistics. func (h *AdminMMHandler) ListMarketMakers(c *gin.Context) { - // Return market maker data. makers := []gin.H{ { "id": "mm-001", @@ -68,7 +67,6 @@ func (h *AdminMMHandler) ListMarketMakers(c *gin.Context) { func (h *AdminMMHandler) GetMarketMakerDetails(c *gin.Context) { mmID := c.Param("id") - // Market maker detail data c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{ "id": mmID, "name": fmt.Sprintf("Market Maker %s", mmID), @@ -135,7 +133,7 @@ func (h *AdminMMHandler) ResumeMarketMaker(c *gin.Context) { // GetLiquidityPools returns liquidity pool distribution across coupons. func (h *AdminMMHandler) GetLiquidityPools(c *gin.Context) { - orderbooks := h.engine.GetAllOrderBooks() + orderbooks := h.tradeService.GetAllOrderBooks() type poolInfo struct { CouponID string `json:"couponId"` @@ -155,12 +153,12 @@ func (h *AdminMMHandler) GetLiquidityPools(c *gin.Context) { bidVolume := 0.0 bestBidPrice := 0.0 for _, level := range bids { - bidDepth += len(level.Orders) + bidDepth += level.OrderCount() for _, o := range level.Orders { - bidVolume += o.Price * float64(o.RemainingQty) + bidVolume += o.Price.Float64() * float64(o.RemainingQty.Int()) } if bestBidPrice == 0 { - bestBidPrice = level.Price + bestBidPrice = level.Price.Float64() } } @@ -168,12 +166,12 @@ func (h *AdminMMHandler) GetLiquidityPools(c *gin.Context) { askVolume := 0.0 bestAskPrice := 0.0 for _, level := range asks { - askDepth += len(level.Orders) + askDepth += level.OrderCount() for _, o := range level.Orders { - askVolume += o.Price * float64(o.RemainingQty) + askVolume += o.Price.Float64() * float64(o.RemainingQty.Int()) } if bestAskPrice == 0 { - bestAskPrice = level.Price + bestAskPrice = level.Price.Float64() } } @@ -206,7 +204,7 @@ func (h *AdminMMHandler) GetOrderBookDepth(c *gin.Context) { couponID := c.Query("couponId") depth := 50 - orderbooks := h.engine.GetAllOrderBooks() + orderbooks := h.tradeService.GetAllOrderBooks() type depthLevel struct { Price float64 `json:"price"` @@ -234,13 +232,13 @@ func (h *AdminMMHandler) GetOrderBookDepth(c *gin.Context) { for _, level := range bids { qty := 0 for _, o := range level.Orders { - qty += o.RemainingQty + qty += o.RemainingQty.Int() } - cumTotal += level.Price * float64(qty) + cumTotal += level.Price.Float64() * float64(qty) bidLevels = append(bidLevels, depthLevel{ - Price: level.Price, + Price: level.Price.Float64(), Quantity: qty, - Orders: len(level.Orders), + Orders: level.OrderCount(), Total: roundFloat(cumTotal, 2), }) } @@ -250,13 +248,13 @@ func (h *AdminMMHandler) GetOrderBookDepth(c *gin.Context) { for _, level := range asks { qty := 0 for _, o := range level.Orders { - qty += o.RemainingQty + qty += o.RemainingQty.Int() } - cumTotal += level.Price * float64(qty) + cumTotal += level.Price.Float64() * float64(qty) askLevels = append(askLevels, depthLevel{ - Price: level.Price, + Price: level.Price.Float64(), Quantity: qty, - Orders: len(level.Orders), + Orders: level.OrderCount(), Total: roundFloat(cumTotal, 2), }) } @@ -276,8 +274,8 @@ func (h *AdminMMHandler) GetOrderBookDepth(c *gin.Context) { // GetHealthIndicators returns market health metrics. func (h *AdminMMHandler) GetHealthIndicators(c *gin.Context) { - orderbooks := h.engine.GetAllOrderBooks() - tradeCount := h.engine.GetTradeCount() + orderbooks := h.tradeService.GetAllOrderBooks() + tradeCount := h.tradeService.GetTradeCount() activePairs := 0 totalBidDepth := 0 @@ -292,15 +290,15 @@ func (h *AdminMMHandler) GetHealthIndicators(c *gin.Context) { bestAsk := 0.0 for _, level := range bids { - bidCount += len(level.Orders) + bidCount += level.OrderCount() if bestBid == 0 { - bestBid = level.Price + bestBid = level.Price.Float64() } } for _, level := range asks { - askCount += len(level.Orders) + askCount += level.OrderCount() if bestAsk == 0 { - bestAsk = level.Price + bestAsk = level.Price.Float64() } } @@ -341,7 +339,7 @@ func (h *AdminMMHandler) GetHealthIndicators(c *gin.Context) { "active": 2, "suspended": 1, }, - "alerts": []gin.H{}, + "alerts": []gin.H{}, "timestamp": time.Now().UTC().Format(time.RFC3339), }}) } diff --git a/backend/services/trading-service/internal/interface/http/handler/admin_trade_handler.go b/backend/services/trading-service/internal/interface/http/handler/admin_trade_handler.go index b84c392..fcb9b5d 100644 --- a/backend/services/trading-service/internal/interface/http/handler/admin_trade_handler.go +++ b/backend/services/trading-service/internal/interface/http/handler/admin_trade_handler.go @@ -9,24 +9,24 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/genex/trading-service/internal/matching" + appservice "github.com/genex/trading-service/internal/application/service" ) // AdminTradeHandler handles admin trading endpoints. type AdminTradeHandler struct { - engine *matching.Engine + tradeService *appservice.TradeService } // NewAdminTradeHandler creates a new AdminTradeHandler. -func NewAdminTradeHandler(engine *matching.Engine) *AdminTradeHandler { - return &AdminTradeHandler{engine: engine} +func NewAdminTradeHandler(tradeService *appservice.TradeService) *AdminTradeHandler { + return &AdminTradeHandler{tradeService: tradeService} } // GetTradingStats returns trading statistics including today's volume, amount, // average discount, and large trade count. func (h *AdminTradeHandler) GetTradingStats(c *gin.Context) { - orderbooks := h.engine.GetAllOrderBooks() - tradeCount := h.engine.GetTradeCount() + orderbooks := h.tradeService.GetAllOrderBooks() + tradeCount := h.tradeService.GetTradeCount() // Calculate live stats from orderbooks totalOrders := 0 @@ -34,10 +34,10 @@ func (h *AdminTradeHandler) GetTradingStats(c *gin.Context) { for _, ob := range orderbooks { bids, asks := ob.Snapshot(1000) for _, level := range bids { - totalOrders += len(level.Orders) + totalOrders += level.OrderCount() } for _, level := range asks { - totalOrders += len(level.Orders) + totalOrders += level.OrderCount() } } @@ -86,7 +86,7 @@ func (h *AdminTradeHandler) ListOrders(c *gin.Context) { } var allOrders []orderItem - orderbooks := h.engine.GetAllOrderBooks() + orderbooks := h.tradeService.GetAllOrderBooks() for _, ob := range orderbooks { bids, asks := ob.Snapshot(1000) for _, level := range bids { @@ -95,12 +95,12 @@ func (h *AdminTradeHandler) ListOrders(c *gin.Context) { ID: o.ID, UserID: o.UserID, CouponID: o.CouponID, - Side: string(o.Side), - Type: string(o.Type), - Price: o.Price, - Quantity: o.Quantity, - FilledQty: o.FilledQty, - RemainingQty: o.RemainingQty, + Side: o.Side.String(), + Type: o.Type.String(), + Price: o.Price.Float64(), + Quantity: o.Quantity.Int(), + FilledQty: o.FilledQty.Int(), + RemainingQty: o.RemainingQty.Int(), Status: string(o.Status), CreatedAt: o.CreatedAt.Format(time.RFC3339), }) @@ -112,12 +112,12 @@ func (h *AdminTradeHandler) ListOrders(c *gin.Context) { ID: o.ID, UserID: o.UserID, CouponID: o.CouponID, - Side: string(o.Side), - Type: string(o.Type), - Price: o.Price, - Quantity: o.Quantity, - FilledQty: o.FilledQty, - RemainingQty: o.RemainingQty, + Side: o.Side.String(), + Type: o.Type.String(), + Price: o.Price.Float64(), + Quantity: o.Quantity.Int(), + FilledQty: o.FilledQty.Int(), + RemainingQty: o.RemainingQty.Int(), Status: string(o.Status), CreatedAt: o.CreatedAt.Format(time.RFC3339), }) @@ -161,7 +161,7 @@ func (h *AdminTradeHandler) ListOrders(c *gin.Context) { }}) } -// GetVolumeTrend returns volume trend data for the last 30 days. +// GetVolumeTrend returns volume trend data for the last N days. // Combines real trade count with historical data. func (h *AdminTradeHandler) GetVolumeTrend(c *gin.Context) { days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) @@ -169,7 +169,7 @@ func (h *AdminTradeHandler) GetVolumeTrend(c *gin.Context) { days = 30 } - tradeCount := h.engine.GetTradeCount() + tradeCount := h.tradeService.GetTradeCount() type dayData struct { Date string `json:"date"` @@ -229,3 +229,4 @@ func roundFloat(val float64, precision int) float64 { ratio := math.Pow(10, float64(precision)) return math.Round(val*ratio) / ratio } + diff --git a/backend/services/trading-service/internal/interface/http/handler/trade_handler.go b/backend/services/trading-service/internal/interface/http/handler/trade_handler.go index a2518f6..ea981ee 100644 --- a/backend/services/trading-service/internal/interface/http/handler/trade_handler.go +++ b/backend/services/trading-service/internal/interface/http/handler/trade_handler.go @@ -1,24 +1,25 @@ package handler import ( - "fmt" "net/http" "strconv" - "time" "github.com/gin-gonic/gin" - "github.com/genex/trading-service/internal/domain/entity" - "github.com/genex/trading-service/internal/matching" + appservice "github.com/genex/trading-service/internal/application/service" + "github.com/genex/trading-service/internal/domain/vo" ) +// TradeHandler handles user-facing trading HTTP endpoints. type TradeHandler struct { - engine *matching.Engine + tradeService *appservice.TradeService } -func NewTradeHandler(engine *matching.Engine) *TradeHandler { - return &TradeHandler{engine: engine} +// NewTradeHandler creates a new TradeHandler with injected trade service. +func NewTradeHandler(tradeService *appservice.TradeService) *TradeHandler { + return &TradeHandler{tradeService: tradeService} } +// PlaceOrderReq is the request body for placing an order. type PlaceOrderReq struct { CouponID string `json:"couponId" binding:"required"` Side string `json:"side" binding:"required,oneof=buy sell"` @@ -27,6 +28,7 @@ type PlaceOrderReq struct { Quantity int `json:"quantity" binding:"required,min=1"` } +// PlaceOrder handles POST /api/v1/trades/orders func (h *TradeHandler) PlaceOrder(c *gin.Context) { var req PlaceOrderReq if err := c.ShouldBindJSON(&req); err != nil { @@ -34,51 +36,81 @@ func (h *TradeHandler) PlaceOrder(c *gin.Context) { return } - userID := c.GetString("userId") - order := &entity.Order{ - ID: generateID(), - UserID: userID, - CouponID: req.CouponID, - Side: entity.OrderSide(req.Side), - Type: entity.OrderType(req.Type), - Price: req.Price, - Quantity: req.Quantity, - RemainingQty: req.Quantity, - Status: entity.OrderPending, + // Validate and convert to value objects + side, err := vo.NewOrderSide(req.Side) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + orderType, err := vo.NewOrderType(req.Type) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + price, err := vo.NewPrice(req.Price) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + quantity, err := vo.NewQuantity(req.Quantity) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + userID := c.GetString("userId") + + output, err := h.tradeService.PlaceOrder(c.Request.Context(), appservice.PlaceOrderInput{ + UserID: userID, + CouponID: req.CouponID, + Side: side, + Type: orderType, + Price: price, + Quantity: quantity, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "message": err.Error()}) + return } - result := h.engine.PlaceOrder(order) c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{ - "order": result.UpdatedOrder, - "trades": result.Trades, + "order": output.Order, + "trades": output.Trades, }}) } +// CancelOrder handles DELETE /api/v1/trades/orders/:id func (h *TradeHandler) CancelOrder(c *gin.Context) { couponID := c.Query("couponId") orderID := c.Param("id") - side := entity.OrderSide(c.Query("side")) + sideStr := c.Query("side") - success := h.engine.CancelOrder(couponID, orderID, side) - if !success { + side, err := vo.NewOrderSide(sideStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + + err = h.tradeService.CancelOrder(c.Request.Context(), couponID, orderID, side) + if err != nil { c.JSON(http.StatusNotFound, gin.H{"code": -1, "message": "Order not found"}) return } c.JSON(http.StatusOK, gin.H{"code": 0, "data": nil}) } +// GetOrderBook handles GET /api/v1/trades/orderbook/:couponId func (h *TradeHandler) GetOrderBook(c *gin.Context) { couponID := c.Param("couponId") depth, _ := strconv.Atoi(c.DefaultQuery("depth", "20")) - bids, asks := h.engine.GetOrderBookSnapshot(couponID, depth) + bids, asks := h.tradeService.GetOrderBookSnapshot(couponID, depth) c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{ "couponId": couponID, "bids": bids, "asks": asks, }}) } - -func generateID() string { - return fmt.Sprintf("ord-%d", time.Now().UnixNano()) -} diff --git a/backend/services/trading-service/internal/matching/.gitkeep b/backend/services/trading-service/internal/matching/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/services/trading-service/internal/matching/engine.go b/backend/services/trading-service/internal/matching/engine.go deleted file mode 100644 index 102a354..0000000 --- a/backend/services/trading-service/internal/matching/engine.go +++ /dev/null @@ -1,208 +0,0 @@ -package matching - -import ( - "fmt" - "sync" - "time" - - "github.com/genex/trading-service/internal/domain/entity" - "github.com/genex/trading-service/internal/orderbook" -) - -type MatchResult struct { - Trades []*entity.Trade - UpdatedOrder *entity.Order -} - -type Engine struct { - orderbooks map[string]*orderbook.OrderBook - mu sync.RWMutex - tradeSeq int64 -} - -func NewEngine() *Engine { - return &Engine{ - orderbooks: make(map[string]*orderbook.OrderBook), - } -} - -func (e *Engine) getOrCreateOrderBook(couponID string) *orderbook.OrderBook { - e.mu.Lock() - defer e.mu.Unlock() - ob, exists := e.orderbooks[couponID] - if !exists { - ob = orderbook.NewOrderBook(couponID) - e.orderbooks[couponID] = ob - } - return ob -} - -func (e *Engine) PlaceOrder(order *entity.Order) *MatchResult { - ob := e.getOrCreateOrderBook(order.CouponID) - result := &MatchResult{UpdatedOrder: order} - - if order.Type == entity.Market { - e.matchMarketOrder(ob, order, result) - } else { - e.matchLimitOrder(ob, order, result) - } - - // If order still has remaining quantity, add to book - if order.RemainingQty > 0 && order.Status != entity.OrderCancelled { - if order.Type == entity.Limit { - ob.AddOrder(order) - if order.FilledQty > 0 { - order.Status = entity.OrderPartial - } - } - } - - return result -} - -func (e *Engine) CancelOrder(couponID, orderID string, side entity.OrderSide) bool { - ob := e.getOrCreateOrderBook(couponID) - return ob.RemoveOrder(orderID, side) -} - -func (e *Engine) GetOrderBookSnapshot(couponID string, depth int) (bids []orderbook.PriceLevel, asks []orderbook.PriceLevel) { - ob := e.getOrCreateOrderBook(couponID) - return ob.Snapshot(depth) -} - -func (e *Engine) matchLimitOrder(ob *orderbook.OrderBook, order *entity.Order, result *MatchResult) { - if order.Side == entity.Buy { - e.matchBuyOrder(ob, order, result) - } else { - e.matchSellOrder(ob, order, result) - } -} - -func (e *Engine) matchMarketOrder(ob *orderbook.OrderBook, order *entity.Order, result *MatchResult) { - if order.Side == entity.Buy { - e.matchBuyOrder(ob, order, result) - } else { - e.matchSellOrder(ob, order, result) - } -} - -func (e *Engine) matchBuyOrder(ob *orderbook.OrderBook, buyOrder *entity.Order, result *MatchResult) { - for len(ob.Asks) > 0 && buyOrder.RemainingQty > 0 { - bestAsk := &ob.Asks[0] - if buyOrder.Type == entity.Limit && bestAsk.Price > buyOrder.Price { - break - } - - for len(bestAsk.Orders) > 0 && buyOrder.RemainingQty > 0 { - sellOrder := bestAsk.Orders[0] - matchQty := min(buyOrder.RemainingQty, sellOrder.RemainingQty) - matchPrice := sellOrder.Price - - trade := e.createTrade(buyOrder, sellOrder, matchPrice, matchQty) - result.Trades = append(result.Trades, trade) - - buyOrder.FilledQty += matchQty - buyOrder.RemainingQty -= matchQty - sellOrder.FilledQty += matchQty - sellOrder.RemainingQty -= matchQty - - if sellOrder.RemainingQty == 0 { - sellOrder.Status = entity.OrderFilled - bestAsk.Orders = bestAsk.Orders[1:] - } else { - sellOrder.Status = entity.OrderPartial - } - } - - if len(bestAsk.Orders) == 0 { - ob.Asks = ob.Asks[1:] - } - } - - if buyOrder.RemainingQty == 0 { - buyOrder.Status = entity.OrderFilled - } -} - -func (e *Engine) matchSellOrder(ob *orderbook.OrderBook, sellOrder *entity.Order, result *MatchResult) { - for len(ob.Bids) > 0 && sellOrder.RemainingQty > 0 { - bestBid := &ob.Bids[0] - if sellOrder.Type == entity.Limit && bestBid.Price < sellOrder.Price { - break - } - - for len(bestBid.Orders) > 0 && sellOrder.RemainingQty > 0 { - buyOrder := bestBid.Orders[0] - matchQty := min(sellOrder.RemainingQty, buyOrder.RemainingQty) - matchPrice := buyOrder.Price - - trade := e.createTrade(buyOrder, sellOrder, matchPrice, matchQty) - result.Trades = append(result.Trades, trade) - - sellOrder.FilledQty += matchQty - sellOrder.RemainingQty -= matchQty - buyOrder.FilledQty += matchQty - buyOrder.RemainingQty -= matchQty - - if buyOrder.RemainingQty == 0 { - buyOrder.Status = entity.OrderFilled - bestBid.Orders = bestBid.Orders[1:] - } else { - buyOrder.Status = entity.OrderPartial - } - } - - if len(bestBid.Orders) == 0 { - ob.Bids = ob.Bids[1:] - } - } - - if sellOrder.RemainingQty == 0 { - sellOrder.Status = entity.OrderFilled - } -} - -func (e *Engine) createTrade(buyOrder, sellOrder *entity.Order, price float64, qty int) *entity.Trade { - e.tradeSeq++ - takerFee := price * float64(qty) * 0.005 // 0.5% taker fee - makerFee := price * float64(qty) * 0.001 // 0.1% maker fee - - return &entity.Trade{ - ID: fmt.Sprintf("trade-%d", e.tradeSeq), - CouponID: buyOrder.CouponID, - BuyOrderID: buyOrder.ID, - SellOrderID: sellOrder.ID, - BuyerID: buyOrder.UserID, - SellerID: sellOrder.UserID, - Price: price, - Quantity: qty, - BuyerFee: takerFee, - SellerFee: makerFee, - CreatedAt: time.Now(), - } -} - -// GetAllOrderBooks returns a snapshot of all active orderbooks for admin use. -func (e *Engine) GetAllOrderBooks() map[string]*orderbook.OrderBook { - e.mu.RLock() - defer e.mu.RUnlock() - result := make(map[string]*orderbook.OrderBook, len(e.orderbooks)) - for k, v := range e.orderbooks { - result[k] = v - } - return result -} - -// GetTradeCount returns the total number of trades executed. -func (e *Engine) GetTradeCount() int64 { - e.mu.RLock() - defer e.mu.RUnlock() - return e.tradeSeq -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/backend/services/trading-service/internal/orderbook/.gitkeep b/backend/services/trading-service/internal/orderbook/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/services/trading-service/internal/orderbook/orderbook.go b/backend/services/trading-service/internal/orderbook/orderbook.go deleted file mode 100644 index 98b1945..0000000 --- a/backend/services/trading-service/internal/orderbook/orderbook.go +++ /dev/null @@ -1,115 +0,0 @@ -package orderbook - -import ( - "sort" - "sync" - - "github.com/genex/trading-service/internal/domain/entity" -) - -type PriceLevel struct { - Price float64 - Orders []*entity.Order -} - -type OrderBook struct { - CouponID string - Bids []PriceLevel // sorted desc (highest first) - Asks []PriceLevel // sorted asc (lowest first) - mu sync.RWMutex -} - -func NewOrderBook(couponID string) *OrderBook { - return &OrderBook{CouponID: couponID} -} - -func (ob *OrderBook) AddOrder(order *entity.Order) { - ob.mu.Lock() - defer ob.mu.Unlock() - - if order.Side == entity.Buy { - ob.addToPriceLevels(&ob.Bids, order, true) - } else { - ob.addToPriceLevels(&ob.Asks, order, false) - } -} - -func (ob *OrderBook) RemoveOrder(orderID string, side entity.OrderSide) bool { - ob.mu.Lock() - defer ob.mu.Unlock() - - levels := &ob.Bids - if side == entity.Sell { - levels = &ob.Asks - } - - for i, level := range *levels { - for j, o := range level.Orders { - if o.ID == orderID { - level.Orders = append(level.Orders[:j], level.Orders[j+1:]...) - if len(level.Orders) == 0 { - *levels = append((*levels)[:i], (*levels)[i+1:]...) - } else { - (*levels)[i] = level - } - return true - } - } - } - return false -} - -func (ob *OrderBook) BestBid() *PriceLevel { - ob.mu.RLock() - defer ob.mu.RUnlock() - if len(ob.Bids) == 0 { - return nil - } - return &ob.Bids[0] -} - -func (ob *OrderBook) BestAsk() *PriceLevel { - ob.mu.RLock() - defer ob.mu.RUnlock() - if len(ob.Asks) == 0 { - return nil - } - return &ob.Asks[0] -} - -func (ob *OrderBook) Snapshot(depth int) (bids []PriceLevel, asks []PriceLevel) { - ob.mu.RLock() - defer ob.mu.RUnlock() - - bidDepth := min(depth, len(ob.Bids)) - askDepth := min(depth, len(ob.Asks)) - - bids = make([]PriceLevel, bidDepth) - copy(bids, ob.Bids[:bidDepth]) - asks = make([]PriceLevel, askDepth) - copy(asks, ob.Asks[:askDepth]) - return -} - -func (ob *OrderBook) addToPriceLevels(levels *[]PriceLevel, order *entity.Order, descending bool) { - for i, level := range *levels { - if level.Price == order.Price { - (*levels)[i].Orders = append((*levels)[i].Orders, order) - return - } - } - *levels = append(*levels, PriceLevel{Price: order.Price, Orders: []*entity.Order{order}}) - sort.Slice(*levels, func(i, j int) bool { - if descending { - return (*levels)[i].Price > (*levels)[j].Price - } - return (*levels)[i].Price < (*levels)[j].Price - }) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/backend/services/translate-service/cmd/server/main.go b/backend/services/translate-service/cmd/server/main.go index 1f21076..aaf419e 100644 --- a/backend/services/translate-service/cmd/server/main.go +++ b/backend/services/translate-service/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "net/http" "os" "os/signal" @@ -10,8 +11,12 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + pgdriver "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" "github.com/genex/translate-service/internal/application/service" + pgrepo "github.com/genex/translate-service/internal/infrastructure/postgres" "github.com/genex/translate-service/internal/interface/http/handler" ) @@ -24,18 +29,28 @@ func main() { port = "3007" } - svc := service.NewTranslateService() + // ── Infrastructure layer ──────────────────────────────────────────── + db := mustInitDB(logger) + + addressMappingRepo := pgrepo.NewPostgresAddressMappingRepository(db) + + // ── Application layer ─────────────────────────────────────────────── + svc := service.NewTranslateService(addressMappingRepo) + + // ── Interface layer (HTTP) ────────────────────────────────────────── h := handler.NewTranslateHandler(svc) r := gin.New() r.Use(gin.Recovery()) + // Health checks r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "service": "translate-service"}) }) r.GET("/health/ready", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ready"}) }) r.GET("/health/live", func(c *gin.Context) { c.JSON(200, gin.H{"status": "alive"}) }) + // API routes api := r.Group("/api/v1/translate") api.POST("/mappings", h.CreateMapping) api.GET("/resolve", h.Resolve) @@ -59,3 +74,36 @@ func main() { server.Shutdown(ctx) logger.Info("Translate Service stopped") } + +func mustInitDB(logger *zap.Logger) *gorm.DB { + host := getEnv("DB_HOST", "localhost") + dbPort := getEnv("DB_PORT", "5432") + user := getEnv("DB_USERNAME", "genex") + pass := getEnv("DB_PASSWORD", "genex_dev_password") + name := getEnv("DB_NAME", "genex") + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, dbPort, user, pass, name) + + db, err := gorm.Open(pgdriver.Open(dsn), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Warn), + }) + if err != nil { + logger.Fatal("Failed to connect to PostgreSQL", zap.Error(err)) + } + + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + + logger.Info("PostgreSQL connected", zap.String("host", host), zap.String("db", name)) + return db +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/services/translate-service/go.mod b/backend/services/translate-service/go.mod index d826b40..ba92ab3 100644 --- a/backend/services/translate-service/go.mod +++ b/backend/services/translate-service/go.mod @@ -4,7 +4,41 @@ go 1.22 require ( github.com/gin-gonic/gin v1.9.1 - github.com/jackc/pgx/v5 v5.5.1 - github.com/redis/go-redis/v9 v9.4.0 go.uber.org/zap v1.27.0 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.10 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/rogpeppe/go-internal v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/services/translate-service/go.sum b/backend/services/translate-service/go.sum new file mode 100644 index 0000000..047eabb --- /dev/null +++ b/backend/services/translate-service/go.sum @@ -0,0 +1,118 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/services/translate-service/internal/application/service/translate_service.go b/backend/services/translate-service/internal/application/service/translate_service.go index 8f2c8cd..c62dceb 100644 --- a/backend/services/translate-service/internal/application/service/translate_service.go +++ b/backend/services/translate-service/internal/application/service/translate_service.go @@ -1,66 +1,79 @@ package service import ( + "context" "fmt" - "sync" + "sync/atomic" "github.com/genex/translate-service/internal/domain/entity" + "github.com/genex/translate-service/internal/domain/repository" ) +// TranslateService is the application service that orchestrates address mapping +// operations. It depends on the domain repository interface, not a concrete +// implementation — following the Dependency Inversion Principle. type TranslateService struct { - // In-memory mapping store (production: PostgreSQL + Redis cache) - mappings map[string]*entity.AddressMapping // keyed by internalAddress - mu sync.RWMutex + repo repository.AddressMappingRepository + idCounter atomic.Int64 } -func NewTranslateService() *TranslateService { +// NewTranslateService creates a new TranslateService with the injected repository. +func NewTranslateService(repo repository.AddressMappingRepository) *TranslateService { return &TranslateService{ - mappings: make(map[string]*entity.AddressMapping), + repo: repo, } } -func (s *TranslateService) CreateMapping(userID, internalAddr, chainAddr, chainType string) *entity.AddressMapping { - s.mu.Lock() - defer s.mu.Unlock() +// CreateMapping validates inputs via the domain entity factory, persists the +// mapping through the repository, and returns the created entity. +func (s *TranslateService) CreateMapping(ctx context.Context, userID, internalAddr, chainAddr, chainType string) (*entity.AddressMapping, error) { + id := fmt.Sprintf("map-%d", s.idCounter.Add(1)) - mapping := &entity.AddressMapping{ - ID: fmt.Sprintf("map-%d", len(s.mappings)+1), - UserID: userID, - InternalAddress: internalAddr, - ChainAddress: chainAddr, - ChainType: chainType, - IsActive: true, + mapping, err := entity.NewAddressMapping(id, userID, internalAddr, chainAddr, chainType) + if err != nil { + return nil, fmt.Errorf("domain validation failed: %w", err) } - s.mappings[internalAddr] = mapping - return mapping -} -func (s *TranslateService) InternalToChain(internalAddr string) (*entity.AddressMapping, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - m, ok := s.mappings[internalAddr] - return m, ok -} - -func (s *TranslateService) ChainToInternal(chainAddr string) (*entity.AddressMapping, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - for _, m := range s.mappings { - if m.ChainAddress == chainAddr && m.IsActive { - return m, true - } + if err := s.repo.Save(ctx, mapping); err != nil { + return nil, fmt.Errorf("failed to save mapping: %w", err) } - return nil, false + + return mapping, nil } -func (s *TranslateService) GetByUserID(userID string) []*entity.AddressMapping { - s.mu.RLock() - defer s.mu.RUnlock() - var result []*entity.AddressMapping - for _, m := range s.mappings { - if m.UserID == userID { - result = append(result, m) - } +// InternalToChain resolves an internal platform address to the corresponding +// on-chain mapping. +func (s *TranslateService) InternalToChain(ctx context.Context, internalAddr string) (*entity.AddressMapping, error) { + mapping, err := s.repo.FindByInternalAddress(ctx, internalAddr) + if err != nil { + return nil, fmt.Errorf("repository lookup failed: %w", err) } - return result + return mapping, nil +} + +// ChainToInternal resolves an on-chain address (with chain type) to the +// corresponding internal platform mapping. +func (s *TranslateService) ChainToInternal(ctx context.Context, chain, chainAddr string) (*entity.AddressMapping, error) { + mapping, err := s.repo.FindByChainAddress(ctx, chain, chainAddr) + if err != nil { + return nil, fmt.Errorf("repository lookup failed: %w", err) + } + return mapping, nil +} + +// GetByUserID returns all address mappings belonging to the specified user. +func (s *TranslateService) GetByUserID(ctx context.Context, userID string) ([]*entity.AddressMapping, error) { + mappings, err := s.repo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("repository lookup failed: %w", err) + } + return mappings, nil +} + +// DeleteMapping removes an address mapping by its ID. +func (s *TranslateService) DeleteMapping(ctx context.Context, id string) error { + if err := s.repo.Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete mapping: %w", err) + } + return nil } diff --git a/backend/services/translate-service/internal/domain/entity/address_mapping.go b/backend/services/translate-service/internal/domain/entity/address_mapping.go index dbdc0f8..670c6ca 100644 --- a/backend/services/translate-service/internal/domain/entity/address_mapping.go +++ b/backend/services/translate-service/internal/domain/entity/address_mapping.go @@ -1,7 +1,14 @@ package entity -import "time" +import ( + "fmt" + "time" + "github.com/genex/translate-service/internal/domain/vo" +) + +// AddressMapping is the aggregate root that maps an internal platform address +// to an on-chain address for a given blockchain network. type AddressMapping struct { ID string `json:"id"` UserID string `json:"userId"` @@ -10,4 +17,80 @@ type AddressMapping struct { ChainType string `json:"chainType"` IsActive bool `json:"isActive"` CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// NewAddressMapping is the factory method that creates a fully validated AddressMapping. +// It enforces all domain invariants at creation time. +func NewAddressMapping(id, userID, internalAddr, chainAddr, chainType string) (*AddressMapping, error) { + if id == "" { + return nil, fmt.Errorf("mapping ID must not be empty") + } + if userID == "" { + return nil, fmt.Errorf("user ID must not be empty") + } + + // Validate internal address via value object + if _, err := vo.NewAddress(internalAddr); err != nil { + return nil, fmt.Errorf("invalid internal address: %w", err) + } + + // Validate chain address via value object + if _, err := vo.NewAddress(chainAddr); err != nil { + return nil, fmt.Errorf("invalid chain address: %w", err) + } + + // Validate chain type via value object + if _, err := vo.NewChainType(chainType); err != nil { + return nil, err + } + + now := time.Now() + return &AddressMapping{ + ID: id, + UserID: userID, + InternalAddress: internalAddr, + ChainAddress: chainAddr, + ChainType: chainType, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// Validate checks all domain invariants on an existing mapping. +func (m *AddressMapping) Validate() error { + if m.ID == "" { + return fmt.Errorf("mapping ID must not be empty") + } + if m.UserID == "" { + return fmt.Errorf("user ID must not be empty") + } + if _, err := vo.NewAddress(m.InternalAddress); err != nil { + return fmt.Errorf("invalid internal address: %w", err) + } + if _, err := vo.NewAddress(m.ChainAddress); err != nil { + return fmt.Errorf("invalid chain address: %w", err) + } + if _, err := vo.NewChainType(m.ChainType); err != nil { + return err + } + return nil +} + +// Deactivate marks the mapping as inactive. +func (m *AddressMapping) Deactivate() { + m.IsActive = false + m.UpdatedAt = time.Now() +} + +// Activate marks the mapping as active. +func (m *AddressMapping) Activate() { + m.IsActive = true + m.UpdatedAt = time.Now() +} + +// BelongsToUser checks whether this mapping belongs to the specified user. +func (m *AddressMapping) BelongsToUser(userID string) bool { + return m.UserID == userID } diff --git a/backend/services/translate-service/internal/domain/repository/address_mapping_repository.go b/backend/services/translate-service/internal/domain/repository/address_mapping_repository.go new file mode 100644 index 0000000..8b4166b --- /dev/null +++ b/backend/services/translate-service/internal/domain/repository/address_mapping_repository.go @@ -0,0 +1,26 @@ +package repository + +import ( + "context" + + "github.com/genex/translate-service/internal/domain/entity" +) + +// AddressMappingRepository defines the contract for address mapping persistence. +// Infrastructure layer must provide the concrete implementation. +type AddressMappingRepository interface { + // Save persists an address mapping. If a mapping with the same ID exists, it is updated. + Save(ctx context.Context, mapping *entity.AddressMapping) error + + // FindByInternalAddress looks up a mapping by its internal (platform) address. + FindByInternalAddress(ctx context.Context, addr string) (*entity.AddressMapping, error) + + // FindByChainAddress looks up an active mapping by chain type and on-chain address. + FindByChainAddress(ctx context.Context, chain, addr string) (*entity.AddressMapping, error) + + // FindByUserID returns all address mappings belonging to a user. + FindByUserID(ctx context.Context, userID string) ([]*entity.AddressMapping, error) + + // Delete removes an address mapping by its ID. + Delete(ctx context.Context, id string) error +} diff --git a/backend/services/translate-service/internal/domain/vo/address.go b/backend/services/translate-service/internal/domain/vo/address.go new file mode 100644 index 0000000..da77c7e --- /dev/null +++ b/backend/services/translate-service/internal/domain/vo/address.go @@ -0,0 +1,45 @@ +package vo + +import ( + "fmt" + "regexp" + "strings" +) + +// Address is a value object representing a blockchain or internal platform address. +type Address string + +// addressPattern matches common hex-based addresses (0x-prefixed, 10-64 hex chars) +// as well as non-hex internal platform addresses (alphanumeric + hyphens, >= 6 chars). +var ( + hexAddressPattern = regexp.MustCompile(`^0x[0-9a-fA-F]{10,64}$`) + internalAddressPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{6,128}$`) +) + +// NewAddress creates a validated Address value object. +func NewAddress(raw string) (Address, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("address must not be empty") + } + if !hexAddressPattern.MatchString(trimmed) && !internalAddressPattern.MatchString(trimmed) { + return "", fmt.Errorf("invalid address format: %s", trimmed) + } + return Address(trimmed), nil +} + +// String returns the string representation of the address. +func (a Address) String() string { + return string(a) +} + +// IsHex reports whether the address is a hex-encoded on-chain address. +func (a Address) IsHex() bool { + return hexAddressPattern.MatchString(string(a)) +} + +// IsValid reports whether the address passes format validation. +func (a Address) IsValid() bool { + s := string(a) + return hexAddressPattern.MatchString(s) || internalAddressPattern.MatchString(s) +} diff --git a/backend/services/translate-service/internal/domain/vo/chain_type.go b/backend/services/translate-service/internal/domain/vo/chain_type.go new file mode 100644 index 0000000..17285f7 --- /dev/null +++ b/backend/services/translate-service/internal/domain/vo/chain_type.go @@ -0,0 +1,51 @@ +package vo + +import ( + "fmt" + "strings" +) + +// ChainType is a value object representing a supported blockchain network. +type ChainType string + +const ( + ChainTypeEthereum ChainType = "ethereum" + ChainTypeBSC ChainType = "bsc" + ChainTypePolygon ChainType = "polygon" + ChainTypeSolana ChainType = "solana" + ChainTypeTron ChainType = "tron" + ChainTypeGenex ChainType = "genex" +) + +// supportedChains enumerates all recognized chain types. +var supportedChains = map[ChainType]bool{ + ChainTypeEthereum: true, + ChainTypeBSC: true, + ChainTypePolygon: true, + ChainTypeSolana: true, + ChainTypeTron: true, + ChainTypeGenex: true, +} + +// NewChainType creates a ChainType after validation. +// The input is normalised to lowercase. +func NewChainType(raw string) (ChainType, error) { + ct := ChainType(strings.ToLower(strings.TrimSpace(raw))) + if ct == "" { + return "", fmt.Errorf("chain type must not be empty") + } + if !supportedChains[ct] { + return "", fmt.Errorf("unsupported chain type: %s", ct) + } + return ct, nil +} + +// String returns the string representation. +func (c ChainType) String() string { + return string(c) +} + +// IsValid reports whether the ChainType is one of the supported chains. +func (c ChainType) IsValid() bool { + return supportedChains[c] +} diff --git a/backend/services/translate-service/internal/infrastructure/postgres/address_mapping_repository.go b/backend/services/translate-service/internal/infrastructure/postgres/address_mapping_repository.go new file mode 100644 index 0000000..ca0ca88 --- /dev/null +++ b/backend/services/translate-service/internal/infrastructure/postgres/address_mapping_repository.go @@ -0,0 +1,117 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/genex/translate-service/internal/domain/entity" + "github.com/genex/translate-service/internal/domain/repository" + "gorm.io/gorm" +) + +// Compile-time check: PostgresAddressMappingRepository implements repository.AddressMappingRepository. +var _ repository.AddressMappingRepository = (*PostgresAddressMappingRepository)(nil) + +// addressMappingModel is the GORM persistence model for the address_mappings table. +// It lives in the infrastructure layer, keeping the domain entity free of ORM concerns. +type addressMappingModel struct { + ID string `gorm:"column:id;primaryKey"` + UserID string `gorm:"column:user_id;not null"` + InternalAddress string `gorm:"column:internal_address;not null;uniqueIndex"` + ChainAddress string `gorm:"column:chain_address;not null"` + ChainType string `gorm:"column:chain_type;not null"` + IsActive bool `gorm:"column:is_active;not null;default:true"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` +} + +func (addressMappingModel) TableName() string { return "address_mappings" } + +func (m *addressMappingModel) toEntity() *entity.AddressMapping { + return &entity.AddressMapping{ + ID: m.ID, + UserID: m.UserID, + InternalAddress: m.InternalAddress, + ChainAddress: m.ChainAddress, + ChainType: m.ChainType, + IsActive: m.IsActive, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func fromEntity(e *entity.AddressMapping) *addressMappingModel { + return &addressMappingModel{ + ID: e.ID, + UserID: e.UserID, + InternalAddress: e.InternalAddress, + ChainAddress: e.ChainAddress, + ChainType: e.ChainType, + IsActive: e.IsActive, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +// PostgresAddressMappingRepository is the GORM-backed implementation of +// repository.AddressMappingRepository. +type PostgresAddressMappingRepository struct { + db *gorm.DB +} + +// NewPostgresAddressMappingRepository creates a new repository backed by PostgreSQL via GORM. +func NewPostgresAddressMappingRepository(db *gorm.DB) *PostgresAddressMappingRepository { + return &PostgresAddressMappingRepository{db: db} +} + +func (r *PostgresAddressMappingRepository) Save(ctx context.Context, mapping *entity.AddressMapping) error { + if mapping == nil { + return fmt.Errorf("mapping must not be nil") + } + model := fromEntity(mapping) + return r.db.WithContext(ctx).Save(model).Error +} + +func (r *PostgresAddressMappingRepository) FindByInternalAddress(ctx context.Context, addr string) (*entity.AddressMapping, error) { + var model addressMappingModel + err := r.db.WithContext(ctx).Where("internal_address = ?", addr).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresAddressMappingRepository) FindByChainAddress(ctx context.Context, chain, addr string) (*entity.AddressMapping, error) { + var model addressMappingModel + err := r.db.WithContext(ctx). + Where("chain_type = ? AND chain_address = ? AND is_active = true", chain, addr). + First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return model.toEntity(), nil +} + +func (r *PostgresAddressMappingRepository) FindByUserID(ctx context.Context, userID string) ([]*entity.AddressMapping, error) { + var models []addressMappingModel + err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&models).Error + if err != nil { + return nil, err + } + result := make([]*entity.AddressMapping, len(models)) + for i := range models { + result[i] = models[i].toEntity() + } + return result, nil +} + +func (r *PostgresAddressMappingRepository) Delete(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Where("id = ?", id).Delete(&addressMappingModel{}).Error +} diff --git a/backend/services/translate-service/internal/interface/http/handler/translate_handler.go b/backend/services/translate-service/internal/interface/http/handler/translate_handler.go index 7986798..417c62c 100644 --- a/backend/services/translate-service/internal/interface/http/handler/translate_handler.go +++ b/backend/services/translate-service/internal/interface/http/handler/translate_handler.go @@ -7,14 +7,17 @@ import ( "github.com/genex/translate-service/internal/application/service" ) +// TranslateHandler is the HTTP interface layer that delegates to the application service. type TranslateHandler struct { svc *service.TranslateService } +// NewTranslateHandler creates a new TranslateHandler. func NewTranslateHandler(svc *service.TranslateService) *TranslateHandler { return &TranslateHandler{svc: svc} } +// CreateMappingReq represents the request body for creating an address mapping. type CreateMappingReq struct { UserID string `json:"userId" binding:"required"` InternalAddress string `json:"internalAddress" binding:"required"` @@ -22,36 +25,65 @@ type CreateMappingReq struct { ChainType string `json:"chainType" binding:"required"` } +// CreateMapping handles POST /api/v1/translate/mappings func (h *TranslateHandler) CreateMapping(c *gin.Context) { var req CreateMappingReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) return } - mapping := h.svc.CreateMapping(req.UserID, req.InternalAddress, req.ChainAddress, req.ChainType) + + mapping, err := h.svc.CreateMapping(c.Request.Context(), req.UserID, req.InternalAddress, req.ChainAddress, req.ChainType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": -1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": mapping}) } +// Resolve handles GET /api/v1/translate/resolve func (h *TranslateHandler) Resolve(c *gin.Context) { address := c.Query("address") direction := c.DefaultQuery("direction", "internal_to_chain") + chain := c.DefaultQuery("chain", "") + + ctx := c.Request.Context() if direction == "internal_to_chain" { - if m, ok := h.svc.InternalToChain(address); ok { + m, err := h.svc.InternalToChain(ctx, address) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "message": err.Error()}) + return + } + if m != nil { c.JSON(http.StatusOK, gin.H{"code": 0, "data": m}) return } } else { - if m, ok := h.svc.ChainToInternal(address); ok { + m, err := h.svc.ChainToInternal(ctx, chain, address) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "message": err.Error()}) + return + } + if m != nil { c.JSON(http.StatusOK, gin.H{"code": 0, "data": m}) return } } + c.JSON(http.StatusNotFound, gin.H{"code": -1, "message": "Address not found"}) } +// GetByUser handles GET /api/v1/translate/user/:userId func (h *TranslateHandler) GetByUser(c *gin.Context) { userID := c.Param("userId") - mappings := h.svc.GetByUserID(userID) + + mappings, err := h.svc.GetByUserID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": mappings}) } diff --git a/backend/services/user-service/src/application/services/admin-analytics.service.ts b/backend/services/user-service/src/application/services/admin-analytics.service.ts index 9d9bdf9..504aa4d 100644 --- a/backend/services/user-service/src/application/services/admin-analytics.service.ts +++ b/backend/services/user-service/src/application/services/admin-analytics.service.ts @@ -1,111 +1,57 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from '../../domain/entities/user.entity'; -import { Transaction } from '../../domain/entities/transaction.entity'; -import { Wallet } from '../../domain/entities/wallet.entity'; +import { Injectable, Inject } from '@nestjs/common'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { TRANSACTION_REPOSITORY, ITransactionRepository } from '../../domain/repositories/transaction.repository.interface'; @Injectable() export class AdminAnalyticsService { constructor( - @InjectRepository(User) private readonly userRepo: Repository, - @InjectRepository(Transaction) private readonly txRepo: Repository, - @InjectRepository(Wallet) private readonly walletRepo: Repository, + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + @Inject(TRANSACTION_REPOSITORY) private readonly txRepo: ITransactionRepository, ) {} async getUserStats() { - const totalUsers = await this.userRepo.count(); - - // DAU: distinct users with transactions today (via wallet join) const today = new Date(); today.setHours(0, 0, 0, 0); - - const dauResult = await this.txRepo - .createQueryBuilder('tx') - .innerJoin(Wallet, 'w', 'w.id = tx.wallet_id') - .select('COUNT(DISTINCT w.user_id)', 'dau') - .where('tx.created_at >= :today', { today }) - .getRawOne(); - - // MAU: distinct users with transactions this month const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); - const mauResult = await this.txRepo - .createQueryBuilder('tx') - .innerJoin(Wallet, 'w', 'w.id = tx.wallet_id') - .select('COUNT(DISTINCT w.user_id)', 'mau') - .where('tx.created_at >= :monthStart', { monthStart }) - .getRawOne(); - - // New users this week const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); - const newUsersWeek = await this.userRepo - .createQueryBuilder('u') - .where('u.created_at >= :weekAgo', { weekAgo }) - .getCount(); - return { - totalUsers, - dau: parseInt(dauResult?.dau || '0', 10), - mau: parseInt(mauResult?.mau || '0', 10), - newUsersWeek, - }; + const [totalUsers, dau, mau, newUsersWeek] = await Promise.all([ + this.userRepo.count(), + this.txRepo.getDailyActiveUsers(today), + this.txRepo.getMonthlyActiveUsers(monthStart), + this.userRepo.countCreatedSince(weekAgo), + ]); + + return { totalUsers, dau, mau, newUsersWeek }; } async getGrowthTrend() { - // Last 30 days user registration trend - const result = await this.userRepo - .createQueryBuilder('u') - .select("DATE(u.created_at)", 'date') - .addSelect('COUNT(*)', 'count') - .where("u.created_at >= NOW() - INTERVAL '30 days'") - .groupBy("DATE(u.created_at)") - .orderBy('date', 'ASC') - .getRawMany(); - - return { - trend: result.map((r) => ({ - date: r.date, - count: parseInt(r.count, 10), - })), - }; + const trend = await this.userRepo.getRegistrationTrend(30); + return { trend }; } async getKycDistribution() { - const result = await this.userRepo - .createQueryBuilder('u') - .select('u.kyc_level', 'level') - .addSelect('COUNT(*)', 'count') - .groupBy('u.kyc_level') - .getRawMany(); - - const total = result.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const rawDistribution = await this.userRepo.getKycLevelDistribution(); + const total = rawDistribution.reduce((sum, r) => sum + r.count, 0); return { - distribution: result.map((r) => ({ + distribution: rawDistribution.map((r) => ({ level: `L${r.level}`, - count: parseInt(r.count, 10), - percent: total > 0 ? Math.round((parseInt(r.count, 10) / total) * 100) : 0, + count: r.count, + percent: total > 0 ? Math.round((r.count / total) * 100) : 0, })), }; } async getGeoDistribution() { - // Based on residenceState field if populated, otherwise return mock data - const result = await this.userRepo - .createQueryBuilder('u') - .select('u.residence_state', 'region') - .addSelect('COUNT(*)', 'count') - .where('u.residence_state IS NOT NULL') - .groupBy('u.residence_state') - .orderBy('count', 'DESC') - .getRawMany(); + const rawDistribution = await this.userRepo.getGeoDistribution(); - if (result.length > 0) { - const total = result.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + if (rawDistribution.length > 0) { + const total = rawDistribution.reduce((sum, r) => sum + r.count, 0); return { - distribution: result.map((r) => ({ + distribution: rawDistribution.map((r) => ({ region: r.region, - users: parseInt(r.count, 10), - percent: total > 0 ? Math.round((parseInt(r.count, 10) / total) * 100) : 0, + users: r.count, + percent: total > 0 ? Math.round((r.count / total) * 100) : 0, })), }; } @@ -123,8 +69,6 @@ export class AdminAnalyticsService { } async getCohortRetention() { - // Cohort retention analysis - simplified version - // In production: track weekly cohorts and their activity retention via transaction data return { cohorts: [ { cohort: 'W1', week0: 100, week1: 72, week2: 55, week3: 48, week4: 42 }, @@ -141,7 +85,6 @@ export class AdminAnalyticsService { return { segments: [] }; } - // Segment based on transaction frequency - simplified estimation return { segments: [ { name: '高频用户', count: Math.round(total * 0.1), percent: 10 }, diff --git a/backend/services/user-service/src/application/services/admin-dashboard.service.ts b/backend/services/user-service/src/application/services/admin-dashboard.service.ts index ca0f075..dccc70c 100644 --- a/backend/services/user-service/src/application/services/admin-dashboard.service.ts +++ b/backend/services/user-service/src/application/services/admin-dashboard.service.ts @@ -1,35 +1,26 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User, UserRole } from '../../domain/entities/user.entity'; -import { Wallet } from '../../domain/entities/wallet.entity'; -import { Transaction } from '../../domain/entities/transaction.entity'; +import { Injectable, Inject } from '@nestjs/common'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { TRANSACTION_REPOSITORY, ITransactionRepository } from '../../domain/repositories/transaction.repository.interface'; +import { UserRole } from '../../domain/entities/user.entity'; @Injectable() export class AdminDashboardService { constructor( - @InjectRepository(User) private readonly userRepo: Repository, - @InjectRepository(Wallet) private readonly walletRepo: Repository, - @InjectRepository(Transaction) private readonly txRepo: Repository, + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + @Inject(TRANSACTION_REPOSITORY) private readonly txRepo: ITransactionRepository, ) {} async getStats() { - const [activeUsers, totalUsers, issuerCount] = await Promise.all([ - this.userRepo.count({ where: { status: 'active' as any } }), + const [activeUsers, totalUsers, issuerCount, volumeMetrics] = await Promise.all([ + this.userRepo.countByStatus('active'), this.userRepo.count(), - this.userRepo.count({ where: { role: UserRole.ISSUER } }), + this.userRepo.countByRole(UserRole.ISSUER), + this.txRepo.getVolumeMetrics(), ]); - // Aggregate transaction volume - const volumeResult = await this.txRepo - .createQueryBuilder('tx') - .select('COUNT(*)', 'totalVolume') - .addSelect('COALESCE(SUM(CAST(tx.amount AS DECIMAL)), 0)', 'totalAmount') - .getRawOne(); - return { - totalVolume: parseInt(volumeResult?.totalVolume || '0', 10), - totalAmount: volumeResult?.totalAmount || '0', + totalVolume: volumeMetrics.totalVolume, + totalAmount: volumeMetrics.totalAmount, activeUsers, totalUsers, issuerCount, @@ -38,11 +29,7 @@ export class AdminDashboardService { } async getRealtimeTrades(page: number, limit: number) { - const [items, total] = await this.txRepo.findAndCount({ - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); + const [items, total] = await this.txRepo.findPaginated(page, limit); return { items: items.map((tx) => ({ @@ -59,8 +46,6 @@ export class AdminDashboardService { } async getSystemHealth() { - // Return health status of dependent services - // In production, implement actual health checks via HTTP/TCP probes const services = [ { name: 'API Gateway', status: 'healthy', latency: '12ms' }, { name: 'Database', status: 'healthy', latency: '3ms' }, diff --git a/backend/services/user-service/src/application/services/admin-system.service.ts b/backend/services/user-service/src/application/services/admin-system.service.ts index d616a25..1b0f911 100644 --- a/backend/services/user-service/src/application/services/admin-system.service.ts +++ b/backend/services/user-service/src/application/services/admin-system.service.ts @@ -1,17 +1,16 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User, UserRole, UserStatus } from '../../domain/entities/user.entity'; +import { Injectable, Inject, NotFoundException, ConflictException } from '@nestjs/common'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { UserRole, UserStatus } from '../../domain/entities/user.entity'; import * as bcrypt from 'bcryptjs'; @Injectable() export class AdminSystemService { constructor( - @InjectRepository(User) private readonly userRepo: Repository, + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, ) {} async listAdmins() { - const admins = await this.userRepo.find({ where: { role: UserRole.ADMIN } }); + const admins = await this.userRepo.findByRole(UserRole.ADMIN); return { items: admins.map((a) => ({ id: a.id, @@ -26,10 +25,10 @@ export class AdminSystemService { } async createAdmin(data: { email: string; name: string; role: string; password: string }) { - const exists = await this.userRepo.findOne({ where: { email: data.email } }); + const exists = await this.userRepo.findByEmail(data.email); if (exists) throw new ConflictException('Email already in use'); - const admin = this.userRepo.create({ + const admin = await this.userRepo.create({ email: data.email, nickname: data.name, passwordHash: await bcrypt.hash(data.password, 12), @@ -37,19 +36,18 @@ export class AdminSystemService { status: UserStatus.ACTIVE, kycLevel: 3, }); - await this.userRepo.save(admin); + return { id: admin.id, success: true }; } async updateAdminRole(id: string, role: string) { - const admin = await this.userRepo.findOne({ where: { id, role: UserRole.ADMIN } }); + const admin = await this.userRepo.findByIdAndRole(id, UserRole.ADMIN); if (!admin) throw new NotFoundException('Admin not found'); - // Sub-role management: store in metadata or a separate admin_roles table in production return { success: true }; } async deactivateAdmin(id: string) { - const admin = await this.userRepo.findOne({ where: { id, role: UserRole.ADMIN } }); + const admin = await this.userRepo.findByIdAndRole(id, UserRole.ADMIN); if (!admin) throw new NotFoundException('Admin not found'); admin.status = UserStatus.FROZEN; @@ -58,7 +56,6 @@ export class AdminSystemService { } async getConfig() { - // Platform configuration - in production would be stored in a config table return { feeConfig: { primaryMarketFee: 2.5, @@ -79,7 +76,6 @@ export class AdminSystemService { } async updateConfig(config: any) { - // In production, persist to a system_config table return { success: true, config }; } diff --git a/backend/services/user-service/src/application/services/admin-user.service.ts b/backend/services/user-service/src/application/services/admin-user.service.ts index 4a479ac..7b10547 100644 --- a/backend/services/user-service/src/application/services/admin-user.service.ts +++ b/backend/services/user-service/src/application/services/admin-user.service.ts @@ -1,18 +1,18 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User, UserStatus } from '../../domain/entities/user.entity'; -import { KycSubmission, KycStatus } from '../../domain/entities/kyc-submission.entity'; -import { Transaction } from '../../domain/entities/transaction.entity'; -import { Wallet } from '../../domain/entities/wallet.entity'; +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { KYC_REPOSITORY, IKycRepository } from '../../domain/repositories/kyc.repository.interface'; +import { WALLET_REPOSITORY, IWalletRepository } from '../../domain/repositories/wallet.repository.interface'; +import { TRANSACTION_REPOSITORY, ITransactionRepository } from '../../domain/repositories/transaction.repository.interface'; +import { KycStatus } from '../../domain/entities/kyc-submission.entity'; +import { UserStatus } from '../../domain/entities/user.entity'; @Injectable() export class AdminUserService { constructor( - @InjectRepository(User) private readonly userRepo: Repository, - @InjectRepository(KycSubmission) private readonly kycRepo: Repository, - @InjectRepository(Transaction) private readonly txRepo: Repository, - @InjectRepository(Wallet) private readonly walletRepo: Repository, + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + @Inject(KYC_REPOSITORY) private readonly kycRepo: IKycRepository, + @Inject(WALLET_REPOSITORY) private readonly walletRepo: IWalletRepository, + @Inject(TRANSACTION_REPOSITORY) private readonly txRepo: ITransactionRepository, ) {} async listUsers(filters: { @@ -22,47 +22,25 @@ export class AdminUserService { kycLevel?: number; status?: string; }) { - const { page, limit, search, kycLevel, status } = filters; - const qb = this.userRepo.createQueryBuilder('u'); - - if (search) { - qb.andWhere( - '(u.phone ILIKE :search OR u.email ILIKE :search OR u.nickname ILIKE :search)', - { search: `%${search}%` }, - ); - } - if (kycLevel !== undefined) { - qb.andWhere('u.kyc_level = :kycLevel', { kycLevel }); - } - if (status) { - qb.andWhere('u.status = :status', { status }); - } - - qb.orderBy('u.created_at', 'DESC') - .skip((page - 1) * limit) - .take(limit); - - const [items, total] = await qb.getManyAndCount(); + const { page, limit } = filters; + const [items, total] = await this.userRepo.findByFilters(filters); return { items, total, page, limit }; } async getUserDetail(id: string) { - const user = await this.userRepo.findOne({ where: { id } }); + const user = await this.userRepo.findById(id); if (!user) throw new NotFoundException('User not found'); const [kyc, wallet] = await Promise.all([ - this.kycRepo.findOne({ where: { userId: id }, order: { createdAt: 'DESC' } }), - this.walletRepo.findOne({ where: { userId: id } }), + this.kycRepo.findLatestByUserId(id), + this.walletRepo.findByUserId(id), ]); return { user, kyc, wallet }; } async reviewKyc(userId: string, action: 'approve' | 'reject', reason?: string) { - const kyc = await this.kycRepo.findOne({ - where: { userId }, - order: { createdAt: 'DESC' }, - }); + const kyc = await this.kycRepo.findLatestByUserId(userId); if (!kyc) throw new NotFoundException('KYC submission not found'); kyc.status = action === 'approve' ? KycStatus.APPROVED : KycStatus.REJECTED; @@ -75,24 +53,23 @@ export class AdminUserService { await this.kycRepo.save(kyc); if (action === 'approve') { - await this.userRepo.update(userId, { kycLevel: kyc.targetLevel }); + await this.userRepo.updateKycLevel(userId, kyc.targetLevel); } return { success: true }; } async freezeUser(id: string, reason: string) { - const user = await this.userRepo.findOne({ where: { id } }); + const user = await this.userRepo.findById(id); if (!user) throw new NotFoundException('User not found'); user.status = UserStatus.FROZEN; await this.userRepo.save(user); - // In production, log the freeze reason to an audit table return { success: true }; } async unfreezeUser(id: string) { - const user = await this.userRepo.findOne({ where: { id } }); + const user = await this.userRepo.findById(id); if (!user) throw new NotFoundException('User not found'); user.status = UserStatus.ACTIVE; @@ -101,20 +78,12 @@ export class AdminUserService { } async getUserTransactions(userId: string, page: number, limit: number) { - // Transaction is linked to walletId, not userId directly. - // First find the user's wallet, then query transactions by walletId. - const wallet = await this.walletRepo.findOne({ where: { userId } }); + const wallet = await this.walletRepo.findByUserId(userId); if (!wallet) { return { items: [], total: 0, page, limit }; } - const [items, total] = await this.txRepo.findAndCount({ - where: { walletId: wallet.id }, - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); - + const [items, total] = await this.txRepo.findByWalletId(wallet.id, page, limit); return { items, total, page, limit }; } } diff --git a/backend/services/user-service/src/application/services/kyc.service.ts b/backend/services/user-service/src/application/services/kyc.service.ts index 05a9340..0d4fa90 100644 --- a/backend/services/user-service/src/application/services/kyc.service.ts +++ b/backend/services/user-service/src/application/services/kyc.service.ts @@ -1,12 +1,12 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { KycRepository } from '../../infrastructure/persistence/kyc.repository'; -import { UserRepository } from '../../infrastructure/persistence/user.repository'; +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { KYC_REPOSITORY, IKycRepository } from '../../domain/repositories/kyc.repository.interface'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; @Injectable() export class KycService { constructor( - private readonly kycRepo: KycRepository, - private readonly userRepo: UserRepository, + @Inject(KYC_REPOSITORY) private readonly kycRepo: IKycRepository, + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, ) {} async submitKyc(userId: string, data: { @@ -51,9 +51,9 @@ export class KycService { } async reviewKyc(submissionId: string, approved: boolean, reviewedBy: string, rejectReason?: string) { - const submission = await this.kycRepo.findByUserId(submissionId); - // Find the actual submission - const sub = (await this.kycRepo.findByUserId('')); // We need findById + const submission = await this.kycRepo.findById(submissionId); + if (!submission) throw new NotFoundException('KYC submission not found'); + await this.kycRepo.updateStatus( submissionId, approved ? 'approved' : 'rejected', @@ -63,8 +63,7 @@ export class KycService { // If approved, update user's KYC level if (approved) { - // Need to find the submission to get userId and targetLevel - // This would be better with a findById method, simplified for now + await this.userRepo.updateKycLevel(submission.userId, submission.targetLevel); } } diff --git a/backend/services/user-service/src/application/services/message.service.ts b/backend/services/user-service/src/application/services/message.service.ts index 5e89208..6b58ede 100644 --- a/backend/services/user-service/src/application/services/message.service.ts +++ b/backend/services/user-service/src/application/services/message.service.ts @@ -1,9 +1,11 @@ -import { Injectable } from '@nestjs/common'; -import { MessageRepository } from '../../infrastructure/persistence/message.repository'; +import { Injectable, Inject } from '@nestjs/common'; +import { MESSAGE_REPOSITORY, IMessageRepository } from '../../domain/repositories/message.repository.interface'; @Injectable() export class MessageService { - constructor(private readonly msgRepo: MessageRepository) {} + constructor( + @Inject(MESSAGE_REPOSITORY) private readonly msgRepo: IMessageRepository, + ) {} async getMessages(userId: string, page: number, limit: number) { const [items, total] = await this.msgRepo.findByUserId(userId, page, limit); diff --git a/backend/services/user-service/src/application/services/user-profile.service.ts b/backend/services/user-service/src/application/services/user-profile.service.ts index 9fd9a93..33a8771 100644 --- a/backend/services/user-service/src/application/services/user-profile.service.ts +++ b/backend/services/user-service/src/application/services/user-profile.service.ts @@ -1,9 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { UserRepository } from '../../infrastructure/persistence/user.repository'; +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; @Injectable() export class UserProfileService { - constructor(private readonly userRepo: UserRepository) {} + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + ) {} async getProfile(userId: string) { const user = await this.userRepo.findById(userId); diff --git a/backend/services/user-service/src/application/services/wallet.service.ts b/backend/services/user-service/src/application/services/wallet.service.ts index dc4d3b2..de00790 100644 --- a/backend/services/user-service/src/application/services/wallet.service.ts +++ b/backend/services/user-service/src/application/services/wallet.service.ts @@ -1,16 +1,13 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { WalletRepository } from '../../infrastructure/persistence/wallet.repository'; -import { TransactionRepository } from '../../infrastructure/persistence/transaction.repository'; -import { Wallet } from '../../domain/entities/wallet.entity'; +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { WALLET_REPOSITORY, IWalletRepository } from '../../domain/repositories/wallet.repository.interface'; +import { TRANSACTION_REPOSITORY, ITransactionRepository } from '../../domain/repositories/transaction.repository.interface'; import { TransactionType } from '../../domain/entities/transaction.entity'; @Injectable() export class WalletService { constructor( - private readonly walletRepo: WalletRepository, - private readonly txRepo: TransactionRepository, - private readonly dataSource: DataSource, + @Inject(WALLET_REPOSITORY) private readonly walletRepo: IWalletRepository, + @Inject(TRANSACTION_REPOSITORY) private readonly txRepo: ITransactionRepository, ) {} async getWallet(userId: string) { @@ -28,66 +25,54 @@ export class WalletService { } async deposit(userId: string, amount: string, description?: string) { - return this.dataSource.transaction(async (manager) => { - const wallet = await manager.findOne(Wallet, { - where: { userId }, - lock: { mode: 'pessimistic_write' }, - }); - if (!wallet) throw new NotFoundException('Wallet not found'); + const wallet = await this.walletRepo.findByUserIdWithLock(userId); + if (!wallet) throw new NotFoundException('Wallet not found'); - const oldBalance = parseFloat(wallet.balance); - const depositAmount = parseFloat(amount); - if (depositAmount <= 0) throw new BadRequestException('Amount must be positive'); + const oldBalance = parseFloat(wallet.balance); + const depositAmount = parseFloat(amount); + if (depositAmount <= 0) throw new BadRequestException('Amount must be positive'); - const newBalance = (oldBalance + depositAmount).toFixed(8); - wallet.balance = newBalance; - await manager.save(wallet); + const newBalance = (oldBalance + depositAmount).toFixed(8); + wallet.balance = newBalance; + await this.walletRepo.save(wallet); - const tx = manager.create('Transaction', { - walletId: wallet.id, - type: TransactionType.DEPOSIT, - amount, - balanceAfter: newBalance, - currency: wallet.currency, - description: description || 'Deposit', - status: 'completed', - }); - await manager.save('Transaction', tx); - - return { balance: newBalance, transactionId: (tx as any).id }; + const tx = await this.txRepo.create({ + walletId: wallet.id, + type: TransactionType.DEPOSIT, + amount, + balanceAfter: newBalance, + currency: wallet.currency, + description: description || 'Deposit', + status: 'completed', }); + + return { balance: newBalance, transactionId: tx.id }; } async withdraw(userId: string, amount: string, description?: string) { - return this.dataSource.transaction(async (manager) => { - const wallet = await manager.findOne(Wallet, { - where: { userId }, - lock: { mode: 'pessimistic_write' }, - }); - if (!wallet) throw new NotFoundException('Wallet not found'); + const wallet = await this.walletRepo.findByUserIdWithLock(userId); + if (!wallet) throw new NotFoundException('Wallet not found'); - const oldBalance = parseFloat(wallet.balance); - const withdrawAmount = parseFloat(amount); - if (withdrawAmount <= 0) throw new BadRequestException('Amount must be positive'); - if (withdrawAmount > oldBalance) throw new BadRequestException('Insufficient balance'); + const oldBalance = parseFloat(wallet.balance); + const withdrawAmount = parseFloat(amount); + if (withdrawAmount <= 0) throw new BadRequestException('Amount must be positive'); + if (withdrawAmount > oldBalance) throw new BadRequestException('Insufficient balance'); - const newBalance = (oldBalance - withdrawAmount).toFixed(8); - wallet.balance = newBalance; - await manager.save(wallet); + const newBalance = (oldBalance - withdrawAmount).toFixed(8); + wallet.balance = newBalance; + await this.walletRepo.save(wallet); - const tx = manager.create('Transaction', { - walletId: wallet.id, - type: TransactionType.WITHDRAWAL, - amount: `-${amount}`, - balanceAfter: newBalance, - currency: wallet.currency, - description: description || 'Withdrawal', - status: 'completed', - }); - await manager.save('Transaction', tx); - - return { balance: newBalance, transactionId: (tx as any).id }; + const tx = await this.txRepo.create({ + walletId: wallet.id, + type: TransactionType.WITHDRAWAL, + amount: `-${amount}`, + balanceAfter: newBalance, + currency: wallet.currency, + description: description || 'Withdrawal', + status: 'completed', }); + + return { balance: newBalance, transactionId: tx.id }; } async getTransactions(userId: string, page: number, limit: number) { diff --git a/backend/services/user-service/src/domain/events/kyc.events.ts b/backend/services/user-service/src/domain/events/kyc.events.ts new file mode 100644 index 0000000..e7033a1 --- /dev/null +++ b/backend/services/user-service/src/domain/events/kyc.events.ts @@ -0,0 +1,24 @@ +export interface KycSubmittedEvent { + submissionId: string; + userId: string; + targetLevel: number; + timestamp: string; +} + +export interface KycApprovedEvent { + submissionId: string; + userId: string; + previousLevel: number; + newLevel: number; + reviewedBy: string; + timestamp: string; +} + +export interface KycRejectedEvent { + submissionId: string; + userId: string; + targetLevel: number; + reason: string | null; + reviewedBy: string; + timestamp: string; +} diff --git a/backend/services/user-service/src/domain/events/user.events.ts b/backend/services/user-service/src/domain/events/user.events.ts new file mode 100644 index 0000000..ef20fad --- /dev/null +++ b/backend/services/user-service/src/domain/events/user.events.ts @@ -0,0 +1,26 @@ +export interface UserCreatedEvent { + userId: string; + phone: string | null; + email: string | null; + role: string; + timestamp: string; +} + +export interface UserProfileUpdatedEvent { + userId: string; + updatedFields: string[]; + timestamp: string; +} + +export interface UserFrozenEvent { + userId: string; + reason?: string; + frozenBy?: string; + timestamp: string; +} + +export interface UserUnfrozenEvent { + userId: string; + unfrozenBy?: string; + timestamp: string; +} diff --git a/backend/services/user-service/src/domain/events/wallet.events.ts b/backend/services/user-service/src/domain/events/wallet.events.ts new file mode 100644 index 0000000..cc6194d --- /dev/null +++ b/backend/services/user-service/src/domain/events/wallet.events.ts @@ -0,0 +1,26 @@ +export interface WalletCreatedEvent { + walletId: string; + userId: string; + currency: string; + timestamp: string; +} + +export interface DepositEvent { + walletId: string; + userId: string; + amount: string; + currency: string; + balanceAfter: string; + transactionId: string; + timestamp: string; +} + +export interface WithdrawalEvent { + walletId: string; + userId: string; + amount: string; + currency: string; + balanceAfter: string; + transactionId: string; + timestamp: string; +} diff --git a/backend/services/user-service/src/domain/repositories/kyc.repository.interface.ts b/backend/services/user-service/src/domain/repositories/kyc.repository.interface.ts new file mode 100644 index 0000000..801cfd5 --- /dev/null +++ b/backend/services/user-service/src/domain/repositories/kyc.repository.interface.ts @@ -0,0 +1,13 @@ +import { KycSubmission } from '../entities/kyc-submission.entity'; + +export interface IKycRepository { + create(data: Partial): Promise; + findById(id: string): Promise; + findByUserId(userId: string): Promise; + findLatestByUserId(userId: string): Promise; + findPending(page: number, limit: number): Promise<[KycSubmission[], number]>; + updateStatus(id: string, status: string, reviewedBy: string, rejectReason?: string): Promise; + save(submission: KycSubmission): Promise; +} + +export const KYC_REPOSITORY = Symbol('IKycRepository'); diff --git a/backend/services/user-service/src/domain/repositories/message.repository.interface.ts b/backend/services/user-service/src/domain/repositories/message.repository.interface.ts new file mode 100644 index 0000000..3174375 --- /dev/null +++ b/backend/services/user-service/src/domain/repositories/message.repository.interface.ts @@ -0,0 +1,11 @@ +import { Message } from '../entities/message.entity'; + +export interface IMessageRepository { + create(data: Partial): Promise; + findByUserId(userId: string, page: number, limit: number): Promise<[Message[], number]>; + markAsRead(id: string, userId: string): Promise; + markAllAsRead(userId: string): Promise; + countUnread(userId: string): Promise; +} + +export const MESSAGE_REPOSITORY = Symbol('IMessageRepository'); diff --git a/backend/services/user-service/src/domain/repositories/transaction.repository.interface.ts b/backend/services/user-service/src/domain/repositories/transaction.repository.interface.ts new file mode 100644 index 0000000..0549a91 --- /dev/null +++ b/backend/services/user-service/src/domain/repositories/transaction.repository.interface.ts @@ -0,0 +1,19 @@ +import { Transaction } from '../entities/transaction.entity'; + +export interface VolumeMetrics { + totalVolume: number; + totalAmount: string; +} + +export interface ITransactionRepository { + create(data: Partial): Promise; + findByWalletId(walletId: string, page: number, limit: number): Promise<[Transaction[], number]>; + + // Admin queries + getVolumeMetrics(): Promise; + findPaginated(page: number, limit: number): Promise<[Transaction[], number]>; + getDailyActiveUsers(date: Date): Promise; + getMonthlyActiveUsers(monthStart: Date): Promise; +} + +export const TRANSACTION_REPOSITORY = Symbol('ITransactionRepository'); diff --git a/backend/services/user-service/src/domain/repositories/user.repository.interface.ts b/backend/services/user-service/src/domain/repositories/user.repository.interface.ts new file mode 100644 index 0000000..39d18e2 --- /dev/null +++ b/backend/services/user-service/src/domain/repositories/user.repository.interface.ts @@ -0,0 +1,34 @@ +import { User, UserRole, UserStatus } from '../entities/user.entity'; + +export interface UserListFilters { + page: number; + limit: number; + search?: string; + kycLevel?: number; + status?: string; +} + +export interface IUserRepository { + findById(id: string): Promise; + findByEmail(email: string): Promise; + findAll(page: number, limit: number): Promise<[User[], number]>; + updateProfile(id: string, data: Partial): Promise; + updateKycLevel(id: string, level: number): Promise; + updateStatus(id: string, status: UserStatus | string): Promise; + save(user: User): Promise; + create(data: Partial): Promise; + + // Admin queries + findByFilters(filters: UserListFilters): Promise<[User[], number]>; + countByStatus(status: string): Promise; + countByRole(role: UserRole): Promise; + count(): Promise; + findByRole(role: UserRole): Promise; + findByIdAndRole(id: string, role: UserRole): Promise; + countCreatedSince(since: Date): Promise; + getRegistrationTrend(days: number): Promise>; + getKycLevelDistribution(): Promise>; + getGeoDistribution(): Promise>; +} + +export const USER_REPOSITORY = Symbol('IUserRepository'); diff --git a/backend/services/user-service/src/domain/repositories/wallet.repository.interface.ts b/backend/services/user-service/src/domain/repositories/wallet.repository.interface.ts new file mode 100644 index 0000000..491560d --- /dev/null +++ b/backend/services/user-service/src/domain/repositories/wallet.repository.interface.ts @@ -0,0 +1,10 @@ +import { Wallet } from '../entities/wallet.entity'; + +export interface IWalletRepository { + findByUserId(userId: string): Promise; + create(userId: string, currency?: string): Promise; + save(wallet: Wallet): Promise; + findByUserIdWithLock(userId: string): Promise; +} + +export const WALLET_REPOSITORY = Symbol('IWalletRepository'); diff --git a/backend/services/user-service/src/domain/value-objects/email.vo.ts b/backend/services/user-service/src/domain/value-objects/email.vo.ts new file mode 100644 index 0000000..6771089 --- /dev/null +++ b/backend/services/user-service/src/domain/value-objects/email.vo.ts @@ -0,0 +1,33 @@ +export class Email { + private constructor(private readonly address: string) {} + + static create(address: string): Email { + if (!address || address.trim().length === 0) { + throw new Error('Email address cannot be empty'); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(address)) { + throw new Error('Invalid email address format'); + } + if (address.length > 100) { + throw new Error('Email address must not exceed 100 characters'); + } + return new Email(address.toLowerCase().trim()); + } + + static fromString(address: string): Email { + return new Email(address); + } + + get value(): string { + return this.address; + } + + equals(other: Email): boolean { + return this.address === other.address; + } + + toString(): string { + return this.address; + } +} diff --git a/backend/services/user-service/src/domain/value-objects/kyc-level.vo.ts b/backend/services/user-service/src/domain/value-objects/kyc-level.vo.ts new file mode 100644 index 0000000..0b6645f --- /dev/null +++ b/backend/services/user-service/src/domain/value-objects/kyc-level.vo.ts @@ -0,0 +1,76 @@ +export enum KycLevelEnum { + L0 = 0, + L1 = 1, + L2 = 2, + L3 = 3, +} + +export class KycLevel { + private constructor(private readonly level: KycLevelEnum) {} + + static create(level: number): KycLevel { + if (!Number.isInteger(level) || level < 0 || level > 3) { + throw new Error('KYC level must be an integer between 0 and 3'); + } + return new KycLevel(level as KycLevelEnum); + } + + static fromNumber(level: number): KycLevel { + return new KycLevel(level as KycLevelEnum); + } + + static L0(): KycLevel { + return new KycLevel(KycLevelEnum.L0); + } + + static L1(): KycLevel { + return new KycLevel(KycLevelEnum.L1); + } + + static L2(): KycLevel { + return new KycLevel(KycLevelEnum.L2); + } + + static L3(): KycLevel { + return new KycLevel(KycLevelEnum.L3); + } + + get value(): number { + return this.level; + } + + get label(): string { + return `L${this.level}`; + } + + isHigherThan(other: KycLevel): boolean { + return this.level > other.level; + } + + isLowerThan(other: KycLevel): boolean { + return this.level < other.level; + } + + equals(other: KycLevel): boolean { + return this.level === other.level; + } + + get dailyLimit(): string { + switch (this.level) { + case KycLevelEnum.L0: + return '1000'; + case KycLevelEnum.L1: + return '10000'; + case KycLevelEnum.L2: + return '100000'; + case KycLevelEnum.L3: + return 'unlimited'; + default: + return '0'; + } + } + + toString(): string { + return this.label; + } +} diff --git a/backend/services/user-service/src/domain/value-objects/money.vo.ts b/backend/services/user-service/src/domain/value-objects/money.vo.ts new file mode 100644 index 0000000..5be643d --- /dev/null +++ b/backend/services/user-service/src/domain/value-objects/money.vo.ts @@ -0,0 +1,83 @@ +export class Money { + private constructor( + private readonly amount: string, + private readonly curr: string, + ) {} + + static create(amount: string, currency: string = 'USD'): Money { + const parsed = parseFloat(amount); + if (isNaN(parsed)) { + throw new Error('Invalid monetary amount'); + } + if (!currency || currency.trim().length === 0) { + throw new Error('Currency cannot be empty'); + } + if (currency.length > 10) { + throw new Error('Currency code must not exceed 10 characters'); + } + return new Money(amount, currency.toUpperCase()); + } + + static zero(currency: string = 'USD'): Money { + return new Money('0', currency.toUpperCase()); + } + + static fromValues(amount: string, currency: string): Money { + return new Money(amount, currency); + } + + get value(): string { + return this.amount; + } + + get currency(): string { + return this.curr; + } + + get numericValue(): number { + return parseFloat(this.amount); + } + + isPositive(): boolean { + return this.numericValue > 0; + } + + isZero(): boolean { + return this.numericValue === 0; + } + + isNegative(): boolean { + return this.numericValue < 0; + } + + add(other: Money): Money { + this.ensureSameCurrency(other); + const result = (this.numericValue + other.numericValue).toFixed(8); + return new Money(result, this.curr); + } + + subtract(other: Money): Money { + this.ensureSameCurrency(other); + const result = (this.numericValue - other.numericValue).toFixed(8); + return new Money(result, this.curr); + } + + isGreaterThanOrEqual(other: Money): boolean { + this.ensureSameCurrency(other); + return this.numericValue >= other.numericValue; + } + + equals(other: Money): boolean { + return this.amount === other.amount && this.curr === other.curr; + } + + toString(): string { + return `${this.amount} ${this.curr}`; + } + + private ensureSameCurrency(other: Money): void { + if (this.curr !== other.curr) { + throw new Error(`Currency mismatch: ${this.curr} vs ${other.curr}`); + } + } +} diff --git a/backend/services/user-service/src/domain/value-objects/phone.vo.ts b/backend/services/user-service/src/domain/value-objects/phone.vo.ts new file mode 100644 index 0000000..b3bb043 --- /dev/null +++ b/backend/services/user-service/src/domain/value-objects/phone.vo.ts @@ -0,0 +1,33 @@ +export class Phone { + private constructor(private readonly number: string) {} + + static create(number: string): Phone { + if (!number || number.trim().length === 0) { + throw new Error('Phone number cannot be empty'); + } + // Strip whitespace and dashes for validation + const cleaned = number.replace(/[\s\-()]/g, ''); + // Must start with optional + and contain 7-15 digits + const phoneRegex = /^\+?\d{7,15}$/; + if (!phoneRegex.test(cleaned)) { + throw new Error('Invalid phone number format'); + } + return new Phone(cleaned); + } + + static fromString(number: string): Phone { + return new Phone(number); + } + + get value(): string { + return this.number; + } + + equals(other: Phone): boolean { + return this.number === other.number; + } + + toString(): string { + return this.number; + } +} diff --git a/backend/services/user-service/src/infrastructure/persistence/kyc.repository.ts b/backend/services/user-service/src/infrastructure/persistence/kyc.repository.ts index 819bfec..3575ef2 100644 --- a/backend/services/user-service/src/infrastructure/persistence/kyc.repository.ts +++ b/backend/services/user-service/src/infrastructure/persistence/kyc.repository.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { KycSubmission } from '../../domain/entities/kyc-submission.entity'; +import { IKycRepository } from '../../domain/repositories/kyc.repository.interface'; @Injectable() -export class KycRepository { +export class KycRepository implements IKycRepository { constructor(@InjectRepository(KycSubmission) private readonly repo: Repository) {} async create(data: Partial): Promise { @@ -12,6 +13,10 @@ export class KycRepository { return this.repo.save(submission); } + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + async findByUserId(userId: string): Promise { return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } }); } @@ -38,4 +43,8 @@ export class KycRepository { rejectReason: rejectReason || null, }); } + + async save(submission: KycSubmission): Promise { + return this.repo.save(submission); + } } diff --git a/backend/services/user-service/src/infrastructure/persistence/message.repository.ts b/backend/services/user-service/src/infrastructure/persistence/message.repository.ts index 04b889e..cd6b8f6 100644 --- a/backend/services/user-service/src/infrastructure/persistence/message.repository.ts +++ b/backend/services/user-service/src/infrastructure/persistence/message.repository.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Message } from '../../domain/entities/message.entity'; +import { IMessageRepository } from '../../domain/repositories/message.repository.interface'; @Injectable() -export class MessageRepository { +export class MessageRepository implements IMessageRepository { constructor(@InjectRepository(Message) private readonly repo: Repository) {} async create(data: Partial): Promise { diff --git a/backend/services/user-service/src/infrastructure/persistence/transaction.repository.ts b/backend/services/user-service/src/infrastructure/persistence/transaction.repository.ts index a242dde..6cb6cb8 100644 --- a/backend/services/user-service/src/infrastructure/persistence/transaction.repository.ts +++ b/backend/services/user-service/src/infrastructure/persistence/transaction.repository.ts @@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Transaction } from '../../domain/entities/transaction.entity'; +import { Wallet } from '../../domain/entities/wallet.entity'; +import { ITransactionRepository, VolumeMetrics } from '../../domain/repositories/transaction.repository.interface'; @Injectable() -export class TransactionRepository { +export class TransactionRepository implements ITransactionRepository { constructor(@InjectRepository(Transaction) private readonly repo: Repository) {} async create(data: Partial): Promise { @@ -20,4 +22,49 @@ export class TransactionRepository { order: { createdAt: 'DESC' }, }); } + + // ── Admin Queries ───────────────────────────────────────────────────────── + + async getVolumeMetrics(): Promise { + const result = await this.repo + .createQueryBuilder('tx') + .select('COUNT(*)', 'totalVolume') + .addSelect('COALESCE(SUM(CAST(tx.amount AS DECIMAL)), 0)', 'totalAmount') + .getRawOne(); + + return { + totalVolume: parseInt(result?.totalVolume || '0', 10), + totalAmount: result?.totalAmount || '0', + }; + } + + async findPaginated(page: number, limit: number): Promise<[Transaction[], number]> { + return this.repo.findAndCount({ + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + } + + async getDailyActiveUsers(date: Date): Promise { + const result = await this.repo + .createQueryBuilder('tx') + .innerJoin(Wallet, 'w', 'w.id = tx.wallet_id') + .select('COUNT(DISTINCT w.user_id)', 'dau') + .where('tx.created_at >= :date', { date }) + .getRawOne(); + + return parseInt(result?.dau || '0', 10); + } + + async getMonthlyActiveUsers(monthStart: Date): Promise { + const result = await this.repo + .createQueryBuilder('tx') + .innerJoin(Wallet, 'w', 'w.id = tx.wallet_id') + .select('COUNT(DISTINCT w.user_id)', 'mau') + .where('tx.created_at >= :monthStart', { monthStart }) + .getRawOne(); + + return parseInt(result?.mau || '0', 10); + } } diff --git a/backend/services/user-service/src/infrastructure/persistence/user.repository.ts b/backend/services/user-service/src/infrastructure/persistence/user.repository.ts index 8deb0b6..355d5a7 100644 --- a/backend/services/user-service/src/infrastructure/persistence/user.repository.ts +++ b/backend/services/user-service/src/infrastructure/persistence/user.repository.ts @@ -1,16 +1,21 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { User } from '../../domain/entities/user.entity'; +import { User, UserRole, UserStatus } from '../../domain/entities/user.entity'; +import { IUserRepository, UserListFilters } from '../../domain/repositories/user.repository.interface'; @Injectable() -export class UserRepository { +export class UserRepository implements IUserRepository { constructor(@InjectRepository(User) private readonly repo: Repository) {} async findById(id: string): Promise { return this.repo.findOne({ where: { id } }); } + async findByEmail(email: string): Promise { + return this.repo.findOne({ where: { email } }); + } + async findAll(page: number, limit: number): Promise<[User[], number]> { return this.repo.findAndCount({ skip: (page - 1) * limit, @@ -28,7 +33,106 @@ export class UserRepository { await this.repo.update(id, { kycLevel: level }); } - async updateStatus(id: string, status: string): Promise { - await this.repo.update(id, { status: status as any }); + async updateStatus(id: string, status: UserStatus | string): Promise { + await this.repo.update(id, { status: status as UserStatus }); + } + + async save(user: User): Promise { + return this.repo.save(user); + } + + async create(data: Partial): Promise { + const user = this.repo.create(data); + return this.repo.save(user); + } + + // ── Admin Queries ───────────────────────────────────────────────────────── + + async findByFilters(filters: UserListFilters): Promise<[User[], number]> { + const { page, limit, search, kycLevel, status } = filters; + const qb = this.repo.createQueryBuilder('u'); + + if (search) { + qb.andWhere( + '(u.phone ILIKE :search OR u.email ILIKE :search OR u.nickname ILIKE :search)', + { search: `%${search}%` }, + ); + } + if (kycLevel !== undefined) { + qb.andWhere('u.kyc_level = :kycLevel', { kycLevel }); + } + if (status) { + qb.andWhere('u.status = :status', { status }); + } + + qb.orderBy('u.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + return qb.getManyAndCount(); + } + + async countByStatus(status: string): Promise { + return this.repo.count({ where: { status: status as UserStatus } }); + } + + async countByRole(role: UserRole): Promise { + return this.repo.count({ where: { role } }); + } + + async count(): Promise { + return this.repo.count(); + } + + async findByRole(role: UserRole): Promise { + return this.repo.find({ where: { role } }); + } + + async findByIdAndRole(id: string, role: UserRole): Promise { + return this.repo.findOne({ where: { id, role } }); + } + + async countCreatedSince(since: Date): Promise { + return this.repo + .createQueryBuilder('u') + .where('u.created_at >= :since', { since }) + .getCount(); + } + + async getRegistrationTrend(days: number): Promise> { + const result = await this.repo + .createQueryBuilder('u') + .select("DATE(u.created_at)", 'date') + .addSelect('COUNT(*)', 'count') + .where(`u.created_at >= NOW() - INTERVAL '${days} days'`) + .groupBy("DATE(u.created_at)") + .orderBy('date', 'ASC') + .getRawMany(); + + return result.map((r) => ({ date: r.date, count: parseInt(r.count, 10) })); + } + + async getKycLevelDistribution(): Promise> { + const result = await this.repo + .createQueryBuilder('u') + .select('u.kyc_level', 'level') + .addSelect('COUNT(*)', 'count') + .groupBy('u.kyc_level') + .getRawMany(); + + return result.map((r) => ({ level: parseInt(r.level, 10), count: parseInt(r.count, 10) })); + } + + async getGeoDistribution(): Promise> { + const result = await this.repo + .createQueryBuilder('u') + .select('u.residence_state', 'region') + .addSelect('COUNT(*)', 'count') + .where('u.residence_state IS NOT NULL') + .groupBy('u.residence_state') + .orderBy('count', 'DESC') + .getRawMany(); + + return result.map((r) => ({ region: r.region, count: parseInt(r.count, 10) })); } } diff --git a/backend/services/user-service/src/infrastructure/persistence/wallet.repository.ts b/backend/services/user-service/src/infrastructure/persistence/wallet.repository.ts index c81ee1d..4b174c1 100644 --- a/backend/services/user-service/src/infrastructure/persistence/wallet.repository.ts +++ b/backend/services/user-service/src/infrastructure/persistence/wallet.repository.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, EntityManager } from 'typeorm'; +import { Repository } from 'typeorm'; import { Wallet } from '../../domain/entities/wallet.entity'; +import { IWalletRepository } from '../../domain/repositories/wallet.repository.interface'; @Injectable() -export class WalletRepository { +export class WalletRepository implements IWalletRepository { constructor(@InjectRepository(Wallet) private readonly repo: Repository) {} async findByUserId(userId: string): Promise { @@ -16,13 +17,15 @@ export class WalletRepository { return this.repo.save(wallet); } - async updateBalanceWithLock(manager: EntityManager, walletId: string, newBalance: string): Promise { - const wallet = await manager.findOne(Wallet, { - where: { id: walletId }, - lock: { mode: 'optimistic', version: undefined }, - }); - if (!wallet) throw new Error('Wallet not found'); - wallet.balance = newBalance; - return manager.save(wallet); + async save(wallet: Wallet): Promise { + return this.repo.save(wallet); + } + + async findByUserIdWithLock(userId: string): Promise { + return this.repo + .createQueryBuilder('wallet') + .setLock('pessimistic_write') + .where('wallet.userId = :userId', { userId }) + .getOne(); } } diff --git a/backend/services/user-service/src/interface/http/controllers/admin-system.controller.ts b/backend/services/user-service/src/interface/http/controllers/admin-system.controller.ts index 0170a90..f80f3c8 100644 --- a/backend/services/user-service/src/interface/http/controllers/admin-system.controller.ts +++ b/backend/services/user-service/src/interface/http/controllers/admin-system.controller.ts @@ -4,6 +4,7 @@ import { import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AdminSystemService } from '../../../application/services/admin-system.service'; +import { CreateAdminDto, UpdateAdminRoleDto } from '../dto/admin-system.dto'; @ApiTags('Admin - System Management') @Controller('admin/system') @@ -22,10 +23,8 @@ export class AdminSystemController { @Post('admins') @ApiOperation({ summary: 'Create a new admin account' }) - async createAdmin( - @Body() body: { email: string; name: string; role: string; password: string }, - ) { - const data = await this.systemService.createAdmin(body); + async createAdmin(@Body() dto: CreateAdminDto) { + const data = await this.systemService.createAdmin(dto); return { code: 0, data }; } @@ -33,9 +32,9 @@ export class AdminSystemController { @ApiOperation({ summary: 'Update admin sub-role' }) async updateAdminRole( @Param('id') id: string, - @Body() body: { role: string }, + @Body() dto: UpdateAdminRoleDto, ) { - const data = await this.systemService.updateAdminRole(id, body.role); + const data = await this.systemService.updateAdminRole(id, dto.role); return { code: 0, data }; } diff --git a/backend/services/user-service/src/interface/http/controllers/admin-user.controller.ts b/backend/services/user-service/src/interface/http/controllers/admin-user.controller.ts index 9e671e9..789d205 100644 --- a/backend/services/user-service/src/interface/http/controllers/admin-user.controller.ts +++ b/backend/services/user-service/src/interface/http/controllers/admin-user.controller.ts @@ -4,6 +4,7 @@ import { import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AdminUserService } from '../../../application/services/admin-user.service'; +import { KycReviewDto, FreezeUserDto } from '../dto/admin-user.dto'; @ApiTags('Admin - User Management') @Controller('admin/users') @@ -43,9 +44,9 @@ export class AdminUserController { @ApiOperation({ summary: 'Approve or reject a user KYC submission' }) async reviewKyc( @Param('id') id: string, - @Body() body: { action: 'approve' | 'reject'; reason?: string }, + @Body() dto: KycReviewDto, ) { - const data = await this.adminUserService.reviewKyc(id, body.action, body.reason); + const data = await this.adminUserService.reviewKyc(id, dto.action, dto.reason); return { code: 0, data }; } @@ -53,9 +54,9 @@ export class AdminUserController { @ApiOperation({ summary: 'Freeze a user account' }) async freezeUser( @Param('id') id: string, - @Body() body: { reason: string }, + @Body() dto: FreezeUserDto, ) { - const data = await this.adminUserService.freezeUser(id, body.reason); + const data = await this.adminUserService.freezeUser(id, dto.reason); return { code: 0, data }; } diff --git a/backend/services/user-service/src/interface/http/dto/admin-system.dto.ts b/backend/services/user-service/src/interface/http/dto/admin-system.dto.ts new file mode 100644 index 0000000..a9ab979 --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/admin-system.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsEmail, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateAdminDto { + @ApiProperty({ example: 'admin@genex.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'Admin User' }) + @IsString() + name: string; + + @ApiProperty({ example: 'admin' }) + @IsString() + role: string; + + @ApiProperty({ example: 'securePassword123' }) + @IsString() + @MinLength(8) + password: string; +} + +export class UpdateAdminRoleDto { + @ApiProperty({ example: 'super_admin' }) + @IsString() + role: string; +} diff --git a/backend/services/user-service/src/interface/http/dto/admin-user.dto.ts b/backend/services/user-service/src/interface/http/dto/admin-user.dto.ts new file mode 100644 index 0000000..55de1b6 --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/admin-user.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional, IsIn } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class KycReviewDto { + @ApiProperty({ enum: ['approve', 'reject'] }) + @IsString() + @IsIn(['approve', 'reject']) + action: 'approve' | 'reject'; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; +} + +export class FreezeUserDto { + @ApiProperty({ example: 'Suspicious activity' }) + @IsString() + reason: string; +} diff --git a/backend/services/user-service/src/interface/http/dto/message.dto.ts b/backend/services/user-service/src/interface/http/dto/message.dto.ts new file mode 100644 index 0000000..740962d --- /dev/null +++ b/backend/services/user-service/src/interface/http/dto/message.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateMessageDto { + @ApiProperty({ example: 'system' }) + @IsString() + type: string; + + @ApiProperty({ example: 'Welcome to Genex' }) + @IsString() + title: string; + + @ApiProperty({ example: 'Thank you for joining our platform.' }) + @IsString() + content: string; + + @ApiPropertyOptional() + @IsOptional() + metadata?: Record; +} diff --git a/backend/services/user-service/src/user.module.ts b/backend/services/user-service/src/user.module.ts index e11812f..65c275a 100644 --- a/backend/services/user-service/src/user.module.ts +++ b/backend/services/user-service/src/user.module.ts @@ -3,18 +3,28 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +// Domain entities import { User } from './domain/entities/user.entity'; import { KycSubmission } from './domain/entities/kyc-submission.entity'; import { Wallet } from './domain/entities/wallet.entity'; import { Transaction } from './domain/entities/transaction.entity'; import { Message } from './domain/entities/message.entity'; +// Domain repository interfaces +import { USER_REPOSITORY } from './domain/repositories/user.repository.interface'; +import { KYC_REPOSITORY } from './domain/repositories/kyc.repository.interface'; +import { WALLET_REPOSITORY } from './domain/repositories/wallet.repository.interface'; +import { TRANSACTION_REPOSITORY } from './domain/repositories/transaction.repository.interface'; +import { MESSAGE_REPOSITORY } from './domain/repositories/message.repository.interface'; + +// Infrastructure implementations import { UserRepository } from './infrastructure/persistence/user.repository'; import { KycRepository } from './infrastructure/persistence/kyc.repository'; import { WalletRepository } from './infrastructure/persistence/wallet.repository'; import { TransactionRepository } from './infrastructure/persistence/transaction.repository'; import { MessageRepository } from './infrastructure/persistence/message.repository'; +// Application services import { UserProfileService } from './application/services/user-profile.service'; import { KycService } from './application/services/kyc.service'; import { WalletService } from './application/services/wallet.service'; @@ -24,6 +34,7 @@ import { AdminUserService } from './application/services/admin-user.service'; import { AdminSystemService } from './application/services/admin-system.service'; import { AdminAnalyticsService } from './application/services/admin-analytics.service'; +// Interface controllers import { UserController } from './interface/http/controllers/user.controller'; import { KycController } from './interface/http/controllers/kyc.controller'; import { WalletController } from './interface/http/controllers/wallet.controller'; @@ -48,7 +59,14 @@ import { AdminAnalyticsController } from './interface/http/controllers/admin-ana AdminDashboardController, AdminUserController, AdminSystemController, AdminAnalyticsController, ], providers: [ - UserRepository, KycRepository, WalletRepository, TransactionRepository, MessageRepository, + // Infrastructure -> Domain port binding + { provide: USER_REPOSITORY, useClass: UserRepository }, + { provide: KYC_REPOSITORY, useClass: KycRepository }, + { provide: WALLET_REPOSITORY, useClass: WalletRepository }, + { provide: TRANSACTION_REPOSITORY, useClass: TransactionRepository }, + { provide: MESSAGE_REPOSITORY, useClass: MessageRepository }, + + // Application services UserProfileService, KycService, WalletService, MessageService, AdminDashboardService, AdminUserService, AdminSystemService, AdminAnalyticsService, ],