feat: 全12服务DDD重构 + 公告定向推送系统 (移植自rwadurian)
## 一、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 <noreply@anthropic.com>
This commit is contained in:
parent
66781d47b3
commit
acaec56849
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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<AppVersion>,
|
||||
@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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ParsedPackageInfo>;
|
||||
}
|
||||
|
|
@ -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<AppVersion | null>;
|
||||
findLatestEnabled(platform: Platform): Promise<AppVersion | null>;
|
||||
findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null>;
|
||||
findByFilters(platform?: Platform, includeDisabled?: boolean): Promise<AppVersion[]>;
|
||||
create(data: Partial<AppVersion>): AppVersion;
|
||||
save(entity: AppVersion): Promise<AppVersion>;
|
||||
updatePartial(id: string, data: Partial<AppVersion>): Promise<void>;
|
||||
deleteById(id: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -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<ParsedPackageInfo> {
|
||||
|
|
|
|||
|
|
@ -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<AppVersion>,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<AppVersion | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findLatestEnabled(platform: Platform): Promise<AppVersion | null> {
|
||||
return this.repo.findOne({
|
||||
where: { platform, isEnabled: true },
|
||||
order: { versionCode: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByPlatformAndCode(platform: Platform, versionCode: number): Promise<AppVersion | null> {
|
||||
return this.repo.findOne({
|
||||
where: { platform, versionCode },
|
||||
});
|
||||
}
|
||||
|
||||
async findByFilters(platform?: Platform, includeDisabled = false): Promise<AppVersion[]> {
|
||||
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>): AppVersion {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(entity: AppVersion): Promise<AppVersion> {
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async updatePartial(id: string, data: Partial<AppVersion>): Promise<void> {
|
||||
await this.repo.update(id, data);
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<string>('DB_HOST', 'localhost'),
|
||||
port: config.get<number>('DB_PORT', 5432),
|
||||
username: config.get<string>('DB_USER', 'genex'),
|
||||
password: config.get<string>('DB_PASS', 'genex'),
|
||||
database: config.get<string>('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<string>('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 {}
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
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<string, Record<string, any>> = 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<AgentStats> {
|
||||
async getStats(): Promise<AgentStatsResponse> {
|
||||
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<TopQuestion[]> {
|
||||
async getTopQuestions(limit = 10): Promise<AgentTopQuestion[]> {
|
||||
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<AiModuleInfo[]> {
|
||||
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<AgentSessionsResponse> {
|
||||
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<SatisfactionMetrics> {
|
||||
async getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics> {
|
||||
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<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,34 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface AnomalyCheckRequest {
|
||||
userId: string;
|
||||
transactionType: string;
|
||||
amount: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<AnomalyDetectResponseDto> {
|
||||
const agentReq: AgentAnomalyRequest = {
|
||||
userId: req.userId,
|
||||
transactionType: req.transactionType,
|
||||
amount: req.amount,
|
||||
metadata: req.metadata,
|
||||
};
|
||||
|
||||
async check(req: AnomalyCheckRequest): Promise<AnomalyCheckResponse> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,43 +1,33 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ChatRequest {
|
||||
userId: string;
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<ChatResponseDto> {
|
||||
const agentReq: AgentChatRequest = {
|
||||
userId: req.userId,
|
||||
message: req.message,
|
||||
sessionId: req.sessionId,
|
||||
context: req.context,
|
||||
};
|
||||
|
||||
async chat(req: ChatRequest): Promise<ChatResponse> {
|
||||
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<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
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<CreditScoreResponseDto> {
|
||||
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<CreditScoreResponse> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
}
|
||||
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<PricingResponseDto> {
|
||||
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<PricingSuggestionResponse> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
// ── Anomaly Detection ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentAnomalyRequest {
|
||||
userId: string;
|
||||
transactionType: string;
|
||||
amount: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
trend: { period: string; rating: number }[];
|
||||
}
|
||||
|
||||
// ── Interface ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IAiAgentClient {
|
||||
/** Send a chat message to the AI agent. */
|
||||
chat(req: AgentChatRequest): Promise<AgentChatResponse>;
|
||||
|
||||
/** Request a credit score from the AI agent. */
|
||||
creditScore(req: AgentCreditScoreRequest): Promise<AgentCreditScoreResponse>;
|
||||
|
||||
/** Request a pricing suggestion from the AI agent. */
|
||||
pricing(req: AgentPricingRequest): Promise<AgentPricingResponse>;
|
||||
|
||||
/** Request anomaly detection from the AI agent. */
|
||||
anomalyDetect(req: AgentAnomalyRequest): Promise<AgentAnomalyResponse>;
|
||||
|
||||
/** Get aggregate agent stats (admin). */
|
||||
getStats(): Promise<AgentStatsResponse>;
|
||||
|
||||
/** Get top questions (admin). */
|
||||
getTopQuestions(limit: number): Promise<AgentTopQuestion[]>;
|
||||
|
||||
/** Get sessions (admin). */
|
||||
getSessions(page: number, limit: number): Promise<AgentSessionsResponse>;
|
||||
|
||||
/** Get satisfaction metrics (admin). */
|
||||
getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics>;
|
||||
|
||||
/** Propagate module config to external agent. */
|
||||
configureModule(moduleId: string, config: Record<string, any>): Promise<void>;
|
||||
|
||||
/** Check external agent health. */
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { AiConversation } from '../entities/ai-conversation.entity';
|
||||
|
||||
export interface IConversationRepository {
|
||||
findById(id: string): Promise<AiConversation | null>;
|
||||
findBySessionId(sessionId: string): Promise<AiConversation | null>;
|
||||
findByUserId(userId: string, skip: number, take: number): Promise<[AiConversation[], number]>;
|
||||
save(conversation: AiConversation): Promise<AiConversation>;
|
||||
countByUserId(userId: string): Promise<number>;
|
||||
}
|
||||
|
||||
export const CONVERSATION_REPOSITORY = Symbol('IConversationRepository');
|
||||
|
|
@ -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<string, number>;
|
||||
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<string, number>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
}): 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<string, number>;
|
||||
} {
|
||||
return {
|
||||
suggestedPrice: this.suggestedPrice,
|
||||
confidence: this.confidence,
|
||||
factors: { ...this.factors },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PricingFactors {
|
||||
timeDecay: number;
|
||||
redemptionCredit: number;
|
||||
liquidityPremium: number;
|
||||
}
|
||||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
// ── Anomaly Detection ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentAnomalyRequest {
|
||||
userId: string;
|
||||
transactionType: string;
|
||||
amount: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
trend: { period: string; rating: number }[];
|
||||
}
|
||||
|
||||
// ── Interface ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IAiAgentClient {
|
||||
/** Send a chat message to the AI agent. */
|
||||
chat(req: AgentChatRequest): Promise<AgentChatResponse>;
|
||||
|
||||
/** Request a credit score from the AI agent. */
|
||||
creditScore(req: AgentCreditScoreRequest): Promise<AgentCreditScoreResponse>;
|
||||
|
||||
/** Request a pricing suggestion from the AI agent. */
|
||||
pricing(req: AgentPricingRequest): Promise<AgentPricingResponse>;
|
||||
|
||||
/** Request anomaly detection from the AI agent. */
|
||||
anomalyDetect(req: AgentAnomalyRequest): Promise<AgentAnomalyResponse>;
|
||||
|
||||
/** Get aggregate agent stats (admin). */
|
||||
getStats(): Promise<AgentStatsResponse>;
|
||||
|
||||
/** Get top questions (admin). */
|
||||
getTopQuestions(limit: number): Promise<AgentTopQuestion[]>;
|
||||
|
||||
/** Get sessions (admin). */
|
||||
getSessions(page: number, limit: number): Promise<AgentSessionsResponse>;
|
||||
|
||||
/** Get satisfaction metrics (admin). */
|
||||
getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics>;
|
||||
|
||||
/** Propagate module config to external agent. */
|
||||
configureModule(moduleId: string, config: Record<string, any>): Promise<void>;
|
||||
|
||||
/** Check external agent health. */
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -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<string>('AI_AGENT_CLUSTER_URL', 'http://localhost:8000');
|
||||
this.apiKey = this.config.get<string>('AI_AGENT_API_KEY', '');
|
||||
this.timeout = this.config.get<number>('AI_AGENT_TIMEOUT', 30000);
|
||||
}
|
||||
|
||||
// ── Core AI capabilities ──────────────────────────────────────────────────
|
||||
|
||||
async chat(req: AgentChatRequest): Promise<AgentChatResponse> {
|
||||
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<AgentCreditScoreResponse> {
|
||||
return this.post('/api/v1/credit/score', req);
|
||||
}
|
||||
|
||||
async pricing(req: AgentPricingRequest): Promise<AgentPricingResponse> {
|
||||
return this.post('/api/v1/pricing/suggest', req);
|
||||
}
|
||||
|
||||
async anomalyDetect(req: AgentAnomalyRequest): Promise<AgentAnomalyResponse> {
|
||||
return this.post('/api/v1/anomaly/check', req);
|
||||
}
|
||||
|
||||
// ── Admin endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
async getStats(): Promise<AgentStatsResponse> {
|
||||
return this.get('/api/v1/admin/stats');
|
||||
}
|
||||
|
||||
async getTopQuestions(limit: number): Promise<AgentTopQuestion[]> {
|
||||
return this.get(`/api/v1/admin/top-questions?limit=${limit}`);
|
||||
}
|
||||
|
||||
async getSessions(page: number, limit: number): Promise<AgentSessionsResponse> {
|
||||
return this.get(`/api/v1/admin/sessions?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
async getSatisfactionMetrics(): Promise<AgentSatisfactionMetrics> {
|
||||
return this.get('/api/v1/admin/satisfaction');
|
||||
}
|
||||
|
||||
async configureModule(moduleId: string, config: Record<string, any>): Promise<void> {
|
||||
await this.post(`/api/v1/admin/modules/${moduleId}/config`, config);
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<Response> {
|
||||
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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AiConversation>,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<AiConversation | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findBySessionId(sessionId: string): Promise<AiConversation | null> {
|
||||
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<AiConversation> {
|
||||
return this.repo.save(conversation);
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
return this.repo.count({ where: { userId } });
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Improvement recommendations', type: [String] })
|
||||
recommendations?: string[];
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Settlement>,
|
||||
@InjectRepository(JournalEntry) private readonly journalRepo: Repository<JournalEntry>,
|
||||
@InjectRepository(Refund) private readonly refundRepo: Repository<Refund>,
|
||||
@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<FinanceSummary> {
|
||||
// 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<RevenueTrendItem[]> {
|
||||
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<string, RevenueTrendItem>();
|
||||
|
|
@ -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<Settlement> {
|
||||
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<Settlement> {
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Report>,
|
||||
@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<Report> {
|
||||
async generateReport(
|
||||
dto: { type: ReportType; period?: string },
|
||||
generatedBy: string,
|
||||
): Promise<Report> {
|
||||
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<Report> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<BreakageRecord>) {}
|
||||
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<BreakageRecord> {
|
||||
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<BreakageRecord[]> {
|
||||
return this.breakageRepo.findByIssuerId(issuerId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Refund>) {}
|
||||
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<Refund> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Settlement>,
|
||||
@InjectRepository(JournalEntry) private readonly journalRepo: Repository<JournalEntry>,
|
||||
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<Settlement> {
|
||||
// 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<Settlement> = {
|
||||
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<Settlement> {
|
||||
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<Settlement | null> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { BreakageRecord } from '../entities/breakage-record.entity';
|
||||
|
||||
export const BREAKAGE_REPOSITORY = Symbol('IBreakageRepository');
|
||||
|
||||
export interface IBreakageRepository {
|
||||
save(record: BreakageRecord): Promise<BreakageRecord>;
|
||||
create(data: Partial<BreakageRecord>): BreakageRecord;
|
||||
findByIssuerId(issuerId: string): Promise<BreakageRecord[]>;
|
||||
}
|
||||
|
|
@ -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<JournalEntry>;
|
||||
create(data: Partial<JournalEntry>): JournalEntry;
|
||||
/** Aggregate: sum of amounts by entry type */
|
||||
getSumByType(type: JournalType): Promise<string>;
|
||||
/** QueryBuilder-based: monthly revenue by type (last 12 months) */
|
||||
getMonthlyRevenueByType(type: JournalType): Promise<Array<{ month: string; revenue: string }>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Refund, RefundStatus } from '../entities/refund.entity';
|
||||
|
||||
export const REFUND_REPOSITORY = Symbol('IRefundRepository');
|
||||
|
||||
export interface IRefundRepository {
|
||||
findById(id: string): Promise<Refund | null>;
|
||||
findAndCount(options: {
|
||||
status?: RefundStatus;
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<[Refund[], number]>;
|
||||
create(data: Partial<Refund>): Promise<Refund>;
|
||||
update(id: string, data: Partial<Refund>): Promise<void>;
|
||||
/** Aggregate: total refund count and amount */
|
||||
getRefundStats(): Promise<{ count: number; total: string }>;
|
||||
/** Aggregate: completed refund total */
|
||||
getCompletedRefundTotal(): Promise<string>;
|
||||
/** QueryBuilder-based: monthly refund counts (last 12 months) */
|
||||
getMonthlyRefundCounts(): Promise<Array<{ month: string; refunds: number }>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Report, ReportType } from '../entities/report.entity';
|
||||
|
||||
export const REPORT_REPOSITORY = Symbol('IReportRepository');
|
||||
|
||||
export interface IReportRepository {
|
||||
findById(id: string): Promise<Report | null>;
|
||||
findAndCount(options: {
|
||||
type?: ReportType;
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<[Report[], number]>;
|
||||
create(data: Partial<Report>): Report;
|
||||
save(report: Report): Promise<Report>;
|
||||
}
|
||||
|
|
@ -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<Settlement | null>;
|
||||
findByTradeId(tradeId: string): Promise<Settlement | null>;
|
||||
findAndCount(options: {
|
||||
status?: SettlementStatus;
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<[Settlement[], number]>;
|
||||
save(settlement: Settlement): Promise<Settlement>;
|
||||
createSettlementWithJournalEntries(
|
||||
settlement: Partial<Settlement>,
|
||||
journalEntries: Partial<JournalEntry>[],
|
||||
): Promise<Settlement>;
|
||||
/** QueryBuilder-based: stats by status */
|
||||
getStatsByStatus(status: SettlementStatus): Promise<{ count: number; total: string }>;
|
||||
/** QueryBuilder-based: monthly settlement counts (last 12 months) */
|
||||
getMonthlySettlementCounts(): Promise<Array<{ month: string; settlements: number }>>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BreakageRecord>,
|
||||
) {}
|
||||
|
||||
async save(record: BreakageRecord): Promise<BreakageRecord> {
|
||||
return this.repo.save(record);
|
||||
}
|
||||
|
||||
create(data: Partial<BreakageRecord>): BreakageRecord {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async findByIssuerId(issuerId: string): Promise<BreakageRecord[]> {
|
||||
return this.repo.find({
|
||||
where: { issuerId },
|
||||
order: { calculatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<JournalEntry>,
|
||||
) {}
|
||||
|
||||
async save(entry: JournalEntry): Promise<JournalEntry> {
|
||||
return this.repo.save(entry);
|
||||
}
|
||||
|
||||
create(data: Partial<JournalEntry>): JournalEntry {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async getSumByType(type: JournalType): Promise<string> {
|
||||
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<Array<{ month: string; revenue: string }>> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Refund>,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Refund | null> {
|
||||
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<Refund>): Promise<Refund> {
|
||||
const refund = this.repo.create(data);
|
||||
return this.repo.save(refund);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Refund>): Promise<void> {
|
||||
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<string> {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Report>,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Report | null> {
|
||||
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>): Report {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(report: Report): Promise<Report> {
|
||||
return this.repo.save(report);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Settlement>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Settlement | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByTradeId(tradeId: string): Promise<Settlement | null> {
|
||||
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<Settlement> {
|
||||
return this.repo.save(settlement);
|
||||
}
|
||||
|
||||
async createSettlementWithJournalEntries(
|
||||
settlementData: Partial<Settlement>,
|
||||
journalEntriesData: Partial<JournalEntry>[],
|
||||
): Promise<Settlement> {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './pagination.dto';
|
||||
export * from './settlement.dto';
|
||||
export * from './refund.dto';
|
||||
export * from './report.dto';
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<SarReport>,
|
||||
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
|
||||
@InjectRepository(AmlAlert) private readonly alertRepo: Repository<AmlAlert>,
|
||||
@InjectRepository(TravelRuleRecord) private readonly travelRepo: Repository<TravelRuleRecord>,
|
||||
@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 };
|
||||
|
|
|
|||
|
|
@ -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<Dispute>,
|
||||
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<InsuranceClaim>,
|
||||
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AmlAlert>,
|
||||
@InjectRepository(OfacScreening) private readonly ofacRepo: Repository<OfacScreening>,
|
||||
@InjectRepository(SarReport) private readonly sarRepo: Repository<SarReport>,
|
||||
@InjectRepository(AuditLog) private readonly auditRepo: Repository<AuditLog>,
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AmlAlert>) {}
|
||||
|
||||
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<AmlAlert> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OfacScreening>) {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SarReport>) {}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TravelRuleRecord>) {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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<string, any> | null;
|
||||
}
|
||||
|
||||
export interface IAuditLoggerService {
|
||||
log(params: AuditLogParams): Promise<void>;
|
||||
}
|
||||
|
|
@ -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>): AmlAlert;
|
||||
save(alert: AmlAlert): Promise<AmlAlert>;
|
||||
findById(id: string): Promise<AmlAlert | null>;
|
||||
findAndCount(options: {
|
||||
where?: Record<string, any>;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
order?: Record<string, 'ASC' | 'DESC'>;
|
||||
}): Promise<[AmlAlert[], number]>;
|
||||
find(options: {
|
||||
where?: Record<string, any> | Record<string, any>[];
|
||||
order?: Record<string, 'ASC' | 'DESC'>;
|
||||
take?: number;
|
||||
}): Promise<AmlAlert[]>;
|
||||
count(options?: { where?: Record<string, any> | Record<string, any>[] }): Promise<number>;
|
||||
update(id: string, data: Partial<AmlAlert>): Promise<void>;
|
||||
|
||||
/** Count alerts with risk_score >= threshold and not in resolved/dismissed status */
|
||||
countHighRiskActive(threshold: number): Promise<number>;
|
||||
|
||||
/** 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<number>;
|
||||
}
|
||||
|
|
@ -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>): AuditLog;
|
||||
save(log: AuditLog): Promise<AuditLog>;
|
||||
|
||||
/** Paginated list with optional filters */
|
||||
findPaginated(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters?: {
|
||||
action?: string;
|
||||
adminId?: string;
|
||||
resource?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
): Promise<[AuditLog[], number]>;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Dispute } from '../entities/dispute.entity';
|
||||
|
||||
export const DISPUTE_REPOSITORY = Symbol('IDisputeRepository');
|
||||
|
||||
export interface IDisputeRepository {
|
||||
create(data: Partial<Dispute>): Dispute;
|
||||
save(dispute: Dispute): Promise<Dispute>;
|
||||
findById(id: string): Promise<Dispute | null>;
|
||||
|
||||
/** Paginated list with optional status and type filters */
|
||||
findPaginated(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters?: { status?: string; type?: string },
|
||||
): Promise<[Dispute[], number]>;
|
||||
}
|
||||
|
|
@ -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>): InsuranceClaim;
|
||||
save(claim: InsuranceClaim): Promise<InsuranceClaim>;
|
||||
findById(id: string): Promise<InsuranceClaim | null>;
|
||||
count(options?: { where?: Record<string, any> }): Promise<number>;
|
||||
|
||||
/** 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<string>;
|
||||
}
|
||||
|
|
@ -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>): OfacScreening;
|
||||
save(screening: OfacScreening): Promise<OfacScreening>;
|
||||
findByUserId(userId: string): Promise<OfacScreening[]>;
|
||||
count(options?: { where?: Record<string, any> }): Promise<number>;
|
||||
|
||||
/** Find OFAC matches (blacklisted), paginated */
|
||||
findMatchesPaginated(page: number, limit: number): Promise<[OfacScreening[], number]>;
|
||||
}
|
||||
|
|
@ -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>): SarReport;
|
||||
save(report: SarReport): Promise<SarReport>;
|
||||
findById(id: string): Promise<SarReport | null>;
|
||||
update(id: string, data: Partial<SarReport>): Promise<void>;
|
||||
count(options?: { where?: Record<string, any> }): Promise<number>;
|
||||
|
||||
/** Paginated list with optional status filter */
|
||||
findPaginated(
|
||||
page: number,
|
||||
limit: number,
|
||||
filters?: { status?: string },
|
||||
): Promise<[SarReport[], number]>;
|
||||
}
|
||||
|
|
@ -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>): TravelRuleRecord;
|
||||
save(record: TravelRuleRecord): Promise<TravelRuleRecord>;
|
||||
findByTransactionId(transactionId: string): Promise<TravelRuleRecord | null>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AmlAlert>,
|
||||
) {}
|
||||
|
||||
create(data: Partial<AmlAlert>): AmlAlert {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(alert: AmlAlert): Promise<AmlAlert> {
|
||||
return this.repo.save(alert);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AmlAlert | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAndCount(options: {
|
||||
where?: Record<string, any>;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
order?: Record<string, 'ASC' | 'DESC'>;
|
||||
}): Promise<[AmlAlert[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async find(options: {
|
||||
where?: Record<string, any> | Record<string, any>[];
|
||||
order?: Record<string, 'ASC' | 'DESC'>;
|
||||
take?: number;
|
||||
}): Promise<AmlAlert[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
async count(options?: { where?: Record<string, any> | Record<string, any>[] }): Promise<number> {
|
||||
return this.repo.count(options);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<AmlAlert>): Promise<void> {
|
||||
await this.repo.update(id, data);
|
||||
}
|
||||
|
||||
async countHighRiskActive(threshold: number): Promise<number> {
|
||||
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<number> {
|
||||
return this.repo
|
||||
.createQueryBuilder('a')
|
||||
.where('a.risk_score >= :score', { score: threshold })
|
||||
.getCount();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AuditLog>,
|
||||
) {}
|
||||
|
||||
create(data: Partial<AuditLog>): AuditLog {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(log: AuditLog): Promise<AuditLog> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Dispute>,
|
||||
) {}
|
||||
|
||||
create(data: Partial<Dispute>): Dispute {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(dispute: Dispute): Promise<Dispute> {
|
||||
return this.repo.save(dispute);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Dispute | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InsuranceClaim>,
|
||||
) {}
|
||||
|
||||
create(data: Partial<InsuranceClaim>): InsuranceClaim {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(claim: InsuranceClaim): Promise<InsuranceClaim> {
|
||||
return this.repo.save(claim);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<InsuranceClaim | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async count(options?: { where?: Record<string, any> }): Promise<number> {
|
||||
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<string> {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder('claim')
|
||||
.select('COALESCE(SUM(claim.amount), 0)', 'total')
|
||||
.where('claim.status = :status', { status })
|
||||
.getRawOne();
|
||||
return result?.total || '0';
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OfacScreening>,
|
||||
) {}
|
||||
|
||||
create(data: Partial<OfacScreening>): OfacScreening {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(screening: OfacScreening): Promise<OfacScreening> {
|
||||
return this.repo.save(screening);
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<OfacScreening[]> {
|
||||
return this.repo.find({ where: { userId }, order: { screenedAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async count(options?: { where?: Record<string, any> }): Promise<number> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SarReport>,
|
||||
) {}
|
||||
|
||||
create(data: Partial<SarReport>): SarReport {
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async save(report: SarReport): Promise<SarReport> {
|
||||
return this.repo.save(report);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SarReport | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<SarReport>): Promise<void> {
|
||||
await this.repo.update(id, data);
|
||||
}
|
||||
|
||||
async count(options?: { where?: Record<string, any> }): Promise<number> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue