forked from wangziqi/gongxue-base
feat: add platform dunning notifications
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
platformAuditNotificationEventsRoute,
|
||||
platformAuditLogsExportRoute,
|
||||
platformAuditLogsRoute,
|
||||
platformDunningNotificationChannelsRoute,
|
||||
platformDunningNotificationEventsRoute,
|
||||
platformOverviewRoute,
|
||||
platformPlansRoute,
|
||||
platformQuestionBanksRoute,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
updatePlatformAuditAlertStatusRoute,
|
||||
updateTenantStatusRoute,
|
||||
upsertPlatformAuditNotificationChannelRoute,
|
||||
upsertPlatformDunningNotificationChannelRoute,
|
||||
upsertQuestionBankGrantRoute,
|
||||
upsertBillingProfileRoute,
|
||||
} from './routes.js';
|
||||
@@ -50,6 +53,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/audit-notification-channels', platformAuditNotificationChannelsRoute],
|
||||
['PUT', '/api/platform-admin/audit-notification-channels', upsertPlatformAuditNotificationChannelRoute],
|
||||
['GET', '/api/platform-admin/audit-notification-events', platformAuditNotificationEventsRoute],
|
||||
['GET', '/api/platform-admin/dunning-notification-channels', platformDunningNotificationChannelsRoute],
|
||||
['PUT', '/api/platform-admin/dunning-notification-channels', upsertPlatformDunningNotificationChannelRoute],
|
||||
['GET', '/api/platform-admin/dunning-notification-events', platformDunningNotificationEventsRoute],
|
||||
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
|
||||
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
|
||||
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
|
||||
|
||||
@@ -55,6 +55,8 @@ const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved
|
||||
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
|
||||
const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'feishu', 'wecom']);
|
||||
const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
||||
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
|
||||
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
|
||||
|
||||
function csvEscape(value: unknown) {
|
||||
if (value === null || value === undefined) return '';
|
||||
@@ -169,6 +171,36 @@ function platformAuditNotificationChannelCode(value: string) {
|
||||
return code;
|
||||
}
|
||||
|
||||
function platformDunningReminderTypes(value: unknown) {
|
||||
const types = Array.isArray(value)
|
||||
? value.map(item => String(item).trim()).filter(Boolean)
|
||||
: ['overdue', 'final_notice'];
|
||||
if (types.length === 0 || types.length > 4) {
|
||||
throw new HttpError(400, 'reminderTypes is invalid', 'INVALID_REMINDER_TYPES');
|
||||
}
|
||||
for (const type of types) {
|
||||
if (!PLATFORM_DUNNING_REMINDER_TYPES.has(type)) {
|
||||
throw new HttpError(400, 'reminderTypes contains invalid type', 'INVALID_REMINDER_TYPES');
|
||||
}
|
||||
}
|
||||
return [...new Set(types)];
|
||||
}
|
||||
|
||||
function platformDunningReminderChannels(value: unknown) {
|
||||
const channels = Array.isArray(value)
|
||||
? value.map(item => String(item).trim()).filter(Boolean)
|
||||
: ['internal'];
|
||||
if (channels.length === 0 || channels.length > 6) {
|
||||
throw new HttpError(400, 'reminderChannels is invalid', 'INVALID_REMINDER_CHANNELS');
|
||||
}
|
||||
for (const channel of channels) {
|
||||
if (!PLATFORM_DUNNING_REMINDER_CHANNELS.has(channel)) {
|
||||
throw new HttpError(400, 'reminderChannels contains invalid channel', 'INVALID_REMINDER_CHANNELS');
|
||||
}
|
||||
}
|
||||
return [...new Set(channels)];
|
||||
}
|
||||
|
||||
function platformSecretRef(scope: string, key: string) {
|
||||
return `app_private.platform_secrets:${scope}:${key}`;
|
||||
}
|
||||
@@ -1296,6 +1328,217 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext)
|
||||
};
|
||||
}
|
||||
|
||||
export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const enabled = listQuery(ctx, 'enabled');
|
||||
const provider = listQuery(ctx, 'provider');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
if (enabled && !['true', 'false'].includes(enabled)) {
|
||||
throw new HttpError(400, 'enabled must be true or false', 'INVALID_BOOLEAN');
|
||||
}
|
||||
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
|
||||
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
|
||||
}
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
`
|
||||
select id, channel_code as "channelCode", name, description, enabled,
|
||||
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
|
||||
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
|
||||
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
|
||||
timeout_sec as "timeoutSec", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.platform_dunning_notification_channels
|
||||
where ($1::text = '' or enabled = ($1 = 'true'))
|
||||
and ($2::text = '' or provider = $2)
|
||||
order by enabled desc, min_reminder_level asc, channel_code asc
|
||||
limit $3
|
||||
`,
|
||||
[enabled, provider, limit],
|
||||
);
|
||||
|
||||
return { items: items.map(channelResponse) };
|
||||
}
|
||||
|
||||
export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
|
||||
const name = requiredString(body, 'name');
|
||||
const provider = platformAuditNotificationProviderFrom(optionalString(body, 'provider'));
|
||||
const webhookUrl = validatePlatformWebhookUrl(requiredString(body, 'webhookUrl'));
|
||||
const reminderTypes = platformDunningReminderTypes(body.reminderTypes);
|
||||
const reminderChannels = platformDunningReminderChannels(body.reminderChannels);
|
||||
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds', 200);
|
||||
const minReminderLevel = numberBetween(body.minReminderLevel, 1, 1, 20);
|
||||
const timeoutSec = numberBetween(body.timeoutSec, 10, 1, 60);
|
||||
const description = optionalString(body, 'description') || null;
|
||||
const metadata = objectValue(body.metadata);
|
||||
const secretKey = `platform_dunning_${channelCode}`;
|
||||
let secretRef = optionalString(body, 'secretRef') || null;
|
||||
const secret = typeof body.secret === 'string' && body.secret.trim() ? body.secret.trim() : '';
|
||||
if (secret) secretRef = platformSecretRef('webhook', secretKey);
|
||||
if (secretRef && !parsePlatformSecretRef(secretRef)) {
|
||||
throw new HttpError(400, 'secretRef is invalid', 'INVALID_SECRET_REF');
|
||||
}
|
||||
|
||||
const item = await transaction(async client => {
|
||||
if (secret) {
|
||||
await client.query(
|
||||
`
|
||||
insert into app_private.platform_secrets (
|
||||
secret_scope, secret_key, secret_value, provider, last_rotated_at
|
||||
)
|
||||
values ('webhook', $1, $2, $3, now())
|
||||
on conflict (secret_scope, secret_key)
|
||||
do update set secret_value = excluded.secret_value,
|
||||
provider = excluded.provider,
|
||||
last_rotated_at = now(),
|
||||
updated_at = now()
|
||||
`,
|
||||
[secretKey, secret, provider],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
insert into public.platform_dunning_notification_channels (
|
||||
channel_code, name, description, enabled, provider, webhook_url,
|
||||
secret_ref, reminder_types, reminder_channels, min_reminder_level,
|
||||
tenant_ids, timeout_sec, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8::text[], $9::text[], $10,
|
||||
$11::uuid[], $12, $13::jsonb
|
||||
)
|
||||
on conflict (channel_code)
|
||||
do update set name = excluded.name,
|
||||
description = excluded.description,
|
||||
enabled = excluded.enabled,
|
||||
provider = excluded.provider,
|
||||
webhook_url = excluded.webhook_url,
|
||||
secret_ref = excluded.secret_ref,
|
||||
reminder_types = excluded.reminder_types,
|
||||
reminder_channels = excluded.reminder_channels,
|
||||
min_reminder_level = excluded.min_reminder_level,
|
||||
tenant_ids = excluded.tenant_ids,
|
||||
timeout_sec = excluded.timeout_sec,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, channel_code as "channelCode", name, description, enabled,
|
||||
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
|
||||
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
|
||||
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
|
||||
timeout_sec as "timeoutSec", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
channelCode,
|
||||
name,
|
||||
description,
|
||||
body.enabled !== false,
|
||||
provider,
|
||||
webhookUrl,
|
||||
secretRef,
|
||||
reminderTypes,
|
||||
reminderChannels,
|
||||
minReminderLevel,
|
||||
tenantIds,
|
||||
timeoutSec,
|
||||
JSON.stringify(metadata),
|
||||
],
|
||||
);
|
||||
const saved = result.rows[0];
|
||||
await recordPlatformAudit(client, ctx, 'platform.invoice.dunning_notification_channel_upserted', 'platform_dunning_notification_channel', String(saved.id), {
|
||||
channelCode,
|
||||
provider,
|
||||
enabled: body.enabled !== false,
|
||||
reminderTypes,
|
||||
reminderChannels,
|
||||
minReminderLevel,
|
||||
tenantIdCount: tenantIds.length,
|
||||
secretRefSet: Boolean(secretRef),
|
||||
secretRotated: Boolean(secret),
|
||||
webhook: safeWebhookInfo(webhookUrl),
|
||||
});
|
||||
return saved;
|
||||
});
|
||||
|
||||
return { item: channelResponse(item) };
|
||||
}
|
||||
|
||||
export async function platformDunningNotificationEventsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const channelId = listQuery(ctx, 'channelId');
|
||||
const reminderId = listQuery(ctx, 'reminderId');
|
||||
const invoiceId = listQuery(ctx, 'invoiceId');
|
||||
const tenantId = listQuery(ctx, 'tenantId');
|
||||
const status = listQuery(ctx, 'status');
|
||||
const provider = listQuery(ctx, 'provider');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
if (channelId && !UUID_RE.test(channelId)) throw new HttpError(400, 'channelId is invalid', 'INVALID_UUID');
|
||||
if (reminderId && !UUID_RE.test(reminderId)) throw new HttpError(400, 'reminderId is invalid', 'INVALID_UUID');
|
||||
if (invoiceId && !UUID_RE.test(invoiceId)) throw new HttpError(400, 'invoiceId is invalid', 'INVALID_UUID');
|
||||
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
|
||||
if (status && !PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES.has(status)) {
|
||||
throw new HttpError(400, 'status is invalid', 'INVALID_NOTIFICATION_EVENT_STATUS');
|
||||
}
|
||||
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
|
||||
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
|
||||
}
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
`
|
||||
select e.id, e.channel_id as "channelId", c.channel_code as "channelCode",
|
||||
c.name as "channelName", e.reminder_id as "reminderId",
|
||||
e.invoice_id as "invoiceId", i.invoice_no as "invoiceNo",
|
||||
e.tenant_id as "tenantId", t.slug::text as "tenantSlug",
|
||||
t.name as "tenantName", e.provider, e.status, e.attempts,
|
||||
e.scheduled_at as "scheduledAt", e.next_attempt_at as "nextAttemptAt",
|
||||
e.last_attempt_at as "lastAttemptAt", e.sent_at as "sentAt",
|
||||
e.last_error as "lastError", e.last_http_code as "lastHttpCode",
|
||||
e.last_response_summary as "lastResponseSummary",
|
||||
e.request_payload as "requestPayload", e.metadata,
|
||||
r.reminder_type as "reminderType", r.channel as "reminderChannel",
|
||||
r.reminder_level as "reminderLevel", r.reminder_date as "reminderDate",
|
||||
r.due_date as "dueDate", r.balance_cents_snapshot as "balanceCentsSnapshot",
|
||||
r.message as "reminderMessage", i.status as "invoiceStatus",
|
||||
i.balance_cents as "invoiceBalanceCents",
|
||||
i.total_cents as "invoiceTotalCents",
|
||||
e.created_at as "createdAt", e.updated_at as "updatedAt"
|
||||
from public.platform_dunning_notification_events e
|
||||
join public.platform_dunning_notification_channels c on c.id = e.channel_id
|
||||
join public.tenant_invoice_reminders r on r.id = e.reminder_id
|
||||
join public.tenant_invoices i on i.id = e.invoice_id
|
||||
join public.tenants t on t.id = e.tenant_id
|
||||
where ($1::uuid is null or e.channel_id = $1::uuid)
|
||||
and ($2::uuid is null or e.reminder_id = $2::uuid)
|
||||
and ($3::uuid is null or e.invoice_id = $3::uuid)
|
||||
and ($4::uuid is null or e.tenant_id = $4::uuid)
|
||||
and ($5::text = '' or e.status = $5)
|
||||
and ($6::text = '' or e.provider = $6)
|
||||
order by
|
||||
case e.status when 'pending' then 1 when 'retrying' then 2 when 'processing' then 3 when 'failed' then 4 else 5 end,
|
||||
e.created_at desc
|
||||
limit $7
|
||||
`,
|
||||
[channelId || null, reminderId || null, invoiceId || null, tenantId || null, status, provider, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map(item => ({
|
||||
...item,
|
||||
requestPayload: redactAuditAlertValue(item.requestPayload),
|
||||
lastError: truncate(item.lastError),
|
||||
lastResponseSummary: truncate(item.lastResponseSummary),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTenantRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user