feat: add operations acceptance phase
This commit is contained in:
@@ -7,6 +7,7 @@ import { ChannelsModule } from './channels/channels.module';
|
||||
import { DictionariesModule } from './dictionaries/dictionaries.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { OperationsModule } from './operations/operations.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RiskReviewModule } from './risk-review/risk-review.module';
|
||||
import { SendChainModule } from './send-chain/send-chain.module';
|
||||
@@ -32,6 +33,7 @@ import { UsersModule } from './users/users.module';
|
||||
ChannelsModule,
|
||||
RiskReviewModule,
|
||||
SendChainModule,
|
||||
OperationsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@ApiTags('operations')
|
||||
@Controller('admin/operations')
|
||||
export class AdminOperationsController {
|
||||
constructor(private readonly operations: OperationsService) {}
|
||||
|
||||
@Get('monitor')
|
||||
monitor(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.monitor({ tenantId, channelId });
|
||||
}
|
||||
|
||||
@Get('task-progress')
|
||||
taskProgress(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.operations.listBatchTasks({ tenantId, status });
|
||||
}
|
||||
|
||||
@Get('messages')
|
||||
listMessages(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('taskId') taskId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, phoneNumber, status });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@Query('tenantId') tenantId?: string) {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) {
|
||||
return this.operations.statistics({ tenantId, groupBy });
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
auditLogs(@Query('tenantId') tenantId?: string, @Query('userId') userId?: string) {
|
||||
return this.operations.auditLogs({ tenantId, userId });
|
||||
}
|
||||
|
||||
@Get('audit-summary')
|
||||
auditSummary(@Query('tenantId') tenantId?: string) {
|
||||
return this.operations.auditSummary({ tenantId });
|
||||
}
|
||||
|
||||
@Get('trace')
|
||||
trace(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('taskId') taskId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('messageId') messageId?: string,
|
||||
) {
|
||||
return this.operations.trace({ tenantId, applicationId, channelId, taskId, phoneNumber, messageId });
|
||||
}
|
||||
|
||||
@Get('reconciliation')
|
||||
reconciliation(@Query('tenantId') tenantId?: string, @Query('taskId') taskId?: string) {
|
||||
return this.operations.reconciliation({ tenantId, taskId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@ApiTags('client-operations')
|
||||
@Controller('client/operations')
|
||||
export class ClientOperationsController {
|
||||
constructor(private readonly operations: OperationsService) {}
|
||||
|
||||
@Get('batch-tasks')
|
||||
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||
return this.operations.listBatchTasks({ tenantId, status });
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listTaskMessages(@Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
return this.operations.listMessages({ taskId, phoneNumber });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AdminOperationsController } from './admin-operations.controller';
|
||||
import { ClientOperationsController } from './client-operations.controller';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminOperationsController, ClientOperationsController],
|
||||
providers: [OperationsService],
|
||||
exports: [OperationsService],
|
||||
})
|
||||
export class OperationsModule {}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface MessageQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
taskId?: string;
|
||||
phoneNumber?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface TraceQuery extends MessageQuery {
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listBatchTasks(query: { tenantId?: string; status?: string }) {
|
||||
return this.prisma.smsBatchTask.findMany({
|
||||
where: { tenantId: query.tenantId, status: query.status },
|
||||
include: { apiRequests: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
listMessages(query: MessageQuery) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
include: { submitRecords: true, receiptRecords: true },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
}
|
||||
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
}
|
||||
|
||||
async monitor(query: { tenantId?: string; channelId?: string }) {
|
||||
const where = messageWhere(query);
|
||||
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }),
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: { submitRecords: true, receiptRecords: true },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.smsReceiptRecord.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
|
||||
]);
|
||||
return {
|
||||
byStatus,
|
||||
recentMessages,
|
||||
recentReceipts,
|
||||
recentUplinks: recentUplinks.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: messageWhereClause,
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.accountTransaction.aggregate({
|
||||
where: { tenantId: query.tenantId },
|
||||
_sum: { amountCents: true, smsUnits: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
taskCount,
|
||||
messageStatus: messageGroups,
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
transactions: transactionAggregate,
|
||||
};
|
||||
}
|
||||
|
||||
async statistics(query: { tenantId?: string; groupBy?: string }) {
|
||||
const groupBy = normalizeGroupBy(query.groupBy);
|
||||
if (groupBy === 'tenantId') {
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['tenantId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
if (groupBy === 'applicationId') {
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['applicationId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
return this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['channelId'],
|
||||
where: messageWhere({ tenantId: query.tenantId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
});
|
||||
}
|
||||
|
||||
auditLogs(query: { tenantId?: string; userId?: string }) {
|
||||
return this.prisma.operationLog.findMany({
|
||||
where: { tenantId: query.tenantId, userId: query.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
}
|
||||
|
||||
auditSummary(query: { tenantId?: string }) {
|
||||
return this.prisma.operationLog.groupBy({
|
||||
by: ['action', 'resource'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { action: 'desc' } },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async trace(query: TraceQuery) {
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
...messageWhere(query),
|
||||
messageId: query.messageId,
|
||||
},
|
||||
include: {
|
||||
batchTask: { include: { apiRequests: true } },
|
||||
submitRecords: { include: { session: true } },
|
||||
receiptRecords: true,
|
||||
},
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const messageIds = messages.map((message) => message.messageId);
|
||||
const [billingRecords, uplinks] = await Promise.all([
|
||||
this.prisma.smsBillingRecord.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
taskId: query.taskId,
|
||||
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return { messages, billingRecords, uplinks };
|
||||
}
|
||||
|
||||
async reconciliation(query: { tenantId?: string; taskId?: string }) {
|
||||
const [messages, billing, transactions] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.aggregate({
|
||||
where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }),
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId, taskId: query.taskId },
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.accountTransaction.aggregate({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined,
|
||||
relatedId: query.taskId,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, smsUnits: true },
|
||||
}),
|
||||
]);
|
||||
const messageAmount = messages._sum.amountCents ?? 0;
|
||||
const billingAmount = billing._sum.amountCents ?? 0;
|
||||
const transactionAmount = transactions._sum.amountCents ?? 0;
|
||||
return {
|
||||
messages,
|
||||
billing,
|
||||
transactions,
|
||||
diff: {
|
||||
messageVsBillingAmountCents: messageAmount - billingAmount,
|
||||
billingVsTransactionAmountCents: billingAmount + transactionAmount,
|
||||
messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
batchTaskId: query.taskId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
status: query.status,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGroupBy(groupBy?: string) {
|
||||
if (groupBy === 'tenant' || groupBy === 'tenantId') {
|
||||
return 'tenantId';
|
||||
}
|
||||
if (groupBy === 'application' || groupBy === 'applicationId') {
|
||||
return 'applicationId';
|
||||
}
|
||||
return 'channelId';
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user