feat: complete reporting and filing workflows
This commit is contained in:
@@ -69,6 +69,8 @@ export interface DownstreamRecoveryStatusQuery {
|
||||
state?: string;
|
||||
failureCategory?: string;
|
||||
keyword?: string;
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -78,6 +80,13 @@ export interface MessageSegmentAuditQuery {
|
||||
messageRecordId?: string;
|
||||
}
|
||||
|
||||
export interface SignatureQualityQuery {
|
||||
date?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -274,7 +283,8 @@ export class OperationsService {
|
||||
ON billing."tenantId" = tenant.id
|
||||
AND billing."createdAt" >= ${businessDay.startAt}
|
||||
AND billing."createdAt" < ${businessDay.endAt}
|
||||
WHERE (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
WHERE tenant.status <> 'deleted'
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
@@ -675,6 +685,228 @@ export class OperationsService {
|
||||
return { date: day.key, summary, channels, signatures, applications };
|
||||
}
|
||||
|
||||
async signatureQuality(query: SignatureQualityQuery) {
|
||||
const day = qualityBusinessDay(query.date);
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
|
||||
const keyword = query.keyword?.trim() || null;
|
||||
const keywordPattern = keyword ? `%${keyword}%` : null;
|
||||
const summaries = await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
applicationNames: string | null;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
rowCount: number;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
message."applicationId" AS application_id,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE base.status = 'submit_failed'
|
||||
OR base.submit_status IN ('rejected', 'timeout')
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
|
||||
COUNT(*) OVER()::integer AS "rowCount"
|
||||
FROM base
|
||||
JOIN "SmsSignature" signature ON signature.id = base.signature_id
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
|
||||
WHERE (
|
||||
${keyword}::text IS NULL
|
||||
OR signature.name ILIKE ${keywordPattern}
|
||||
OR tenant.name ILIKE ${keywordPattern}
|
||||
OR application.name ILIKE ${keywordPattern}
|
||||
)
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name
|
||||
ORDER BY total DESC, signature.name
|
||||
LIMIT ${pageSize}
|
||||
OFFSET ${(page - 1) * pageSize}
|
||||
`);
|
||||
const signatureIds = summaries.map((item) => item.signatureId);
|
||||
const breakdowns = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
submit."submitStatus" AS submit_status,
|
||||
receipt."deliveredAt" AS delivered_at,
|
||||
failed_receipt."failedAt" AS failed_at,
|
||||
COALESCE(segment_summary.segment_count, 0) AS segment_count,
|
||||
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
|
||||
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
|
||||
CASE
|
||||
WHEN segment_summary.segment_count > 0
|
||||
AND segment_summary.delivered_count = segment_summary.segment_count
|
||||
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
WHEN segment_summary.segment_count = 0
|
||||
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS segment_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
|
||||
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
|
||||
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
WHERE segment."submitRecordId" = submit.id
|
||||
) segment_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN submit_status <> 'accepted' THEN 'submit_failed'
|
||||
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
|
||||
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
|
||||
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
|
||||
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
|
||||
ELSE 'unknown'
|
||||
END AS delivery_status
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
signature_id AS "signatureId",
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
carrier,
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE delivery_status = 'success')
|
||||
* 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM classified
|
||||
GROUP BY signature_id, channel_id, carrier
|
||||
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier
|
||||
`);
|
||||
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
||||
const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId);
|
||||
return {
|
||||
...summary,
|
||||
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
|
||||
breakdowns: signatureBreakdowns,
|
||||
};
|
||||
});
|
||||
return {
|
||||
date: day.key,
|
||||
items,
|
||||
total: summaries[0]?.rowCount ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
|
||||
@@ -1420,11 +1652,14 @@ function parseDateBoundary(value?: string, endOfDay = false) {
|
||||
}
|
||||
|
||||
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false);
|
||||
const updatedAtTo = parseDateBoundary(query.updatedAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
state: query.state && query.state !== 'all' ? query.state : undefined,
|
||||
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
|
||||
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ account: { contains: query.keyword } },
|
||||
{ gatewayInstanceId: { contains: query.keyword } },
|
||||
|
||||
Reference in New Issue
Block a user