forked from wangziqi/gongxue-base
feat: add platform admin permissions
This commit is contained in:
@@ -17,6 +17,7 @@ export interface SessionIdentity {
|
||||
sessionExpiresAt: string;
|
||||
authSource: 'app_session' | 'supabase_jwt';
|
||||
authUserId: string | null;
|
||||
platformPermissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RequestAuthState {
|
||||
@@ -50,7 +51,8 @@ export async function findUserBySessionToken(token: string) {
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt",
|
||||
'app_session'::text as "authSource",
|
||||
u.auth_user_id as "authUserId"
|
||||
u.auth_user_id as "authUserId",
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
@@ -135,7 +137,8 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
$1::text as "sessionId",
|
||||
$3::timestamptz as "sessionExpiresAt",
|
||||
'supabase_jwt'::text as "authSource",
|
||||
u.auth_user_id as "authUserId"
|
||||
u.auth_user_id as "authUserId",
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from public.platform_users u
|
||||
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
|
||||
where u.auth_user_id = $1::uuid
|
||||
@@ -167,7 +170,8 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
$1::text as "sessionId",
|
||||
$3::timestamptz as "sessionExpiresAt",
|
||||
'supabase_jwt'::text as "authSource",
|
||||
u.auth_user_id as "authUserId"
|
||||
u.auth_user_id as "authUserId",
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from public.platform_users u
|
||||
join public.tenant_memberships tm on tm.user_id = u.id
|
||||
where u.auth_user_id = $1::uuid
|
||||
|
||||
@@ -147,9 +147,26 @@ export function optionalStringArray(body: JsonObject, key: string): string[] {
|
||||
return value.map(item => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function requirePlatformAdmin(ctx: RequestContext) {
|
||||
function platformPermissionAllowed(permissions: Record<string, unknown> | null | undefined, permission: string) {
|
||||
if (!permission) return true;
|
||||
if (!permissions || typeof permissions !== 'object') return false;
|
||||
if (permissions['*'] === true) return true;
|
||||
if (permissions[permission] === true) return true;
|
||||
|
||||
const parts = permission.split(':');
|
||||
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
||||
const wildcard = `${parts.slice(0, i).join(':')}:*`;
|
||||
if (permissions[wildcard] === true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function requirePlatformAdmin(ctx: RequestContext, permission = '') {
|
||||
const auth = await hydrateRequestAuth(ctx);
|
||||
if (auth.session?.primaryRole === 'platform_admin') return;
|
||||
if (auth.session?.primaryRole === 'platform_admin') {
|
||||
if (!permission || platformPermissionAllowed(auth.session.platformPermissions, permission)) return;
|
||||
throw new HttpError(403, `Platform permission ${permission} is required`, 'PLATFORM_PERMISSION_REQUIRED');
|
||||
}
|
||||
if (auth.bearerToken) {
|
||||
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
platformDunningNotificationChannelsRoute,
|
||||
platformDunningNotificationEventsRoute,
|
||||
platformOverviewRoute,
|
||||
platformPermissionsRoute,
|
||||
platformPlansRoute,
|
||||
platformQuestionBanksRoute,
|
||||
processOverdueInvoicesRoute,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from './routes.js';
|
||||
|
||||
export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/permissions', platformPermissionsRoute],
|
||||
['GET', '/api/platform-admin/overview', platformOverviewRoute],
|
||||
['GET', '/api/platform-admin/plans', platformPlansRoute],
|
||||
['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute],
|
||||
|
||||
@@ -58,6 +58,28 @@ 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']);
|
||||
|
||||
const PLATFORM_PERMISSION_CATALOG = [
|
||||
{ key: 'platform:overview:read', group: 'overview', label: '平台概览' },
|
||||
{ key: 'platform:tenant:read', group: 'tenant', label: '查看租户' },
|
||||
{ key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' },
|
||||
{ key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' },
|
||||
{ key: 'platform:tenant:billing_profile', group: 'tenant', label: '维护租户账务资料' },
|
||||
{ key: 'platform:plan:read', group: 'billing', label: '查看 SaaS 套餐' },
|
||||
{ key: 'platform:billing:read', group: 'billing', label: '查看平台账务' },
|
||||
{ key: 'platform:billing:write', group: 'billing', label: '创建订阅/账单' },
|
||||
{ key: 'platform:billing:payment', group: 'billing', label: '确认服务费收款' },
|
||||
{ key: 'platform:billing:dunning', group: 'billing', label: '处理逾期催缴' },
|
||||
{ key: 'platform:billing:notification', group: 'billing', label: '维护催缴通知' },
|
||||
{ key: 'platform:usage:read', group: 'usage', label: '查看租户用量' },
|
||||
{ key: 'platform:usage:write', group: 'usage', label: '记录租户用量' },
|
||||
{ key: 'platform:audit:read', group: 'audit', label: '查看平台审计' },
|
||||
{ key: 'platform:audit:export', group: 'audit', label: '导出平台审计' },
|
||||
{ key: 'platform:audit:alert', group: 'audit', label: '处理平台审计告警' },
|
||||
{ key: 'platform:audit:notification', group: 'audit', label: '维护审计告警通知' },
|
||||
{ key: 'platform:question_bank:read', group: 'question_bank', label: '查看公共题库' },
|
||||
{ key: 'platform:question_bank:grant', group: 'question_bank', label: '授权公共题库' },
|
||||
] as const;
|
||||
|
||||
function csvEscape(value: unknown) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
@@ -476,8 +498,38 @@ function grantStatusFrom(value: string) {
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
function platformPermissionAllowed(permissions: Record<string, unknown>, permission: string) {
|
||||
if (permissions['*'] === true) return true;
|
||||
if (permissions[permission] === true) return true;
|
||||
const parts = permission.split(':');
|
||||
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
||||
if (permissions[`${parts.slice(0, i).join(':')}:*`] === true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function platformPermissionsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
const session = currentSessionFromContext(ctx);
|
||||
const permissions = session?.platformPermissions && typeof session.platformPermissions === 'object'
|
||||
? session.platformPermissions
|
||||
: { '*': true };
|
||||
|
||||
return {
|
||||
item: {
|
||||
userId: session?.id || null,
|
||||
primaryRole: session?.primaryRole || 'platform_admin_key',
|
||||
permissions,
|
||||
effective: Object.fromEntries(
|
||||
PLATFORM_PERMISSION_CATALOG.map(item => [item.key, platformPermissionAllowed(permissions, item.key)]),
|
||||
),
|
||||
catalog: PLATFORM_PERMISSION_CATALOG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx, 'platform:overview:read');
|
||||
|
||||
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
|
||||
queryOne<{
|
||||
@@ -554,7 +606,7 @@ export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformPlansRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:plan:read');
|
||||
|
||||
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
|
||||
const items = await query(
|
||||
@@ -574,7 +626,7 @@ export async function platformPlansRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformQuestionBanksRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:question_bank:read');
|
||||
|
||||
const q = listQuery(ctx, 'q');
|
||||
const regionId = listQuery(ctx, 'regionId');
|
||||
@@ -617,7 +669,7 @@ export async function platformQuestionBanksRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function questionBankGrantsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:question_bank:read');
|
||||
|
||||
const questionBankId = listQuery(ctx, 'questionBankId');
|
||||
const status = listQuery(ctx, 'status');
|
||||
@@ -649,7 +701,7 @@ export async function questionBankGrantsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:question_bank:grant');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const questionBankId = requiredString(body, 'sourceQuestionBankId');
|
||||
@@ -777,7 +829,7 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:tenant:read');
|
||||
|
||||
const status = listQuery(ctx, 'status');
|
||||
const billingStatus = listQuery(ctx, 'billingStatus');
|
||||
@@ -825,7 +877,7 @@ export async function tenantsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantDetailRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:tenant:read');
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
|
||||
@@ -907,7 +959,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformAuditLogsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:read');
|
||||
|
||||
const auditQuery = auditLogQueryParams(ctx, 100, 500);
|
||||
const items = await loadPlatformAuditLogs(auditQuery);
|
||||
@@ -916,7 +968,7 @@ export async function platformAuditLogsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformAuditLogsExportRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:export');
|
||||
|
||||
const format = auditExportFormat(listQuery(ctx, 'format'));
|
||||
const auditQuery = auditLogQueryParams(ctx, 1000, 5000);
|
||||
@@ -986,7 +1038,7 @@ export async function platformAuditLogsExportRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:alert');
|
||||
|
||||
const enabled = listQuery(ctx, 'enabled');
|
||||
if (enabled && !['true', 'false'].includes(enabled)) {
|
||||
@@ -1010,7 +1062,7 @@ export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformAuditAlertsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:alert');
|
||||
|
||||
const tenantId = listQuery(ctx, 'tenantId');
|
||||
const status = listQuery(ctx, 'status');
|
||||
@@ -1077,7 +1129,7 @@ export async function platformAuditAlertsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:alert');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const alertId = requiredString(body, 'alertId');
|
||||
@@ -1127,7 +1179,7 @@ export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformAuditNotificationChannelsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:notification');
|
||||
|
||||
const enabled = listQuery(ctx, 'enabled');
|
||||
const provider = listQuery(ctx, 'provider');
|
||||
@@ -1162,7 +1214,7 @@ export async function platformAuditNotificationChannelsRoute(ctx: RequestContext
|
||||
}
|
||||
|
||||
export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:notification');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
|
||||
@@ -1270,7 +1322,7 @@ export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestCo
|
||||
}
|
||||
|
||||
export async function platformAuditNotificationEventsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:audit:notification');
|
||||
|
||||
const channelId = listQuery(ctx, 'channelId');
|
||||
const alertId = listQuery(ctx, 'alertId');
|
||||
@@ -1329,7 +1381,7 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext)
|
||||
}
|
||||
|
||||
export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:notification');
|
||||
|
||||
const enabled = listQuery(ctx, 'enabled');
|
||||
const provider = listQuery(ctx, 'provider');
|
||||
@@ -1362,7 +1414,7 @@ export async function platformDunningNotificationChannelsRoute(ctx: RequestConte
|
||||
}
|
||||
|
||||
export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:notification');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
|
||||
@@ -1471,7 +1523,7 @@ export async function upsertPlatformDunningNotificationChannelRoute(ctx: Request
|
||||
}
|
||||
|
||||
export async function platformDunningNotificationEventsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:notification');
|
||||
|
||||
const channelId = listQuery(ctx, 'channelId');
|
||||
const reminderId = listQuery(ctx, 'reminderId');
|
||||
@@ -1540,7 +1592,7 @@ export async function platformDunningNotificationEventsRoute(ctx: RequestContext
|
||||
}
|
||||
|
||||
export async function createTenantRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:tenant:write');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const slug = normalizeSlug(requiredString(body, 'slug'));
|
||||
@@ -1679,7 +1731,7 @@ export async function createTenantRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:tenant:status');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -1715,7 +1767,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:tenant:billing_profile');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -1781,7 +1833,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function createSubscriptionRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -1844,7 +1896,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantInvoicesRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:read');
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const status = listQuery(ctx, 'status');
|
||||
@@ -1966,7 +2018,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
}
|
||||
|
||||
export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -1992,7 +2044,7 @@ export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:payment');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -2088,7 +2140,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function processOverdueInvoicesRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:dunning');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const dryRun = booleanFrom(body.dryRun, false);
|
||||
@@ -2124,7 +2176,7 @@ export async function processOverdueInvoicesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function invoiceRemindersRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:dunning');
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const invoiceId = ctx.url.searchParams.get('invoiceId') || '';
|
||||
@@ -2160,7 +2212,7 @@ export async function invoiceRemindersRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function recordUsageRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:usage:write');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -2187,7 +2239,7 @@ export async function recordUsageRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantUsageRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:usage:read');
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
@@ -2274,7 +2326,7 @@ async function subscriptionInvoiceCandidateQuery(params: {
|
||||
}
|
||||
|
||||
export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:read');
|
||||
|
||||
const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds');
|
||||
const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds');
|
||||
@@ -2292,7 +2344,7 @@ export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -2382,7 +2434,7 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte
|
||||
}
|
||||
|
||||
export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx, 'platform:billing:write');
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds');
|
||||
|
||||
Reference in New Issue
Block a user