Files
Momento/memento-note/app/actions/admin-billing.ts
Antigravity ee70e74bf5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s
fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
1. replaceAll (Find & Replace) — une seule transaction ProseMirror
   au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés.

2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs
   qui laissaient un nœud fantôme invisible dans le document.

3. Conversion Markdown → richtext — breaks: true dans marked.parse()
   Les simple newlines sont maintenant convertis en <br>.
   + préserve les blocs custom (toggle, callout, math, columns,
   outline, link-preview) en commentaires HTML lors de l'export MD.

4. emitNoteChange exercices — shape corrigée (type:'created' attend
   un objet Note, pas noteId/notebookId séparés).

5. Raccourcis clavier sans conflit :
   Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier)
   Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets)
   Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
2026-06-20 15:48:18 +00:00

216 lines
5.8 KiB
TypeScript

'use server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { SubscriptionTier } from '@prisma/client'
import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils'
import {
getAllEntitlementsForAdmin,
invalidateEntitlementCache,
type SubscriptionTier as TierType,
} from '@/lib/plan-entitlements'
import { logAuditEventAsync } from '@/lib/audit-log'
import { revalidatePath } from 'next/cache'
const BILLING_CONFIG_KEYS = [
'BILLING_ENABLED',
'STRIPE_PRICE_PRO_MONTHLY',
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
] as const
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as { role?: string }).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
function assertValidFeature(feature: string) {
if (!(VALID_FEATURES as readonly string[]).includes(feature)) {
throw new Error(`Invalid feature: ${feature}`)
}
}
function assertValidTier(tier: string): asserts tier is TierType {
if (!TIERS.includes(tier as TierType)) {
throw new Error(`Invalid tier: ${tier}`)
}
}
export async function getBillingAdminData() {
await checkAdmin()
const { getSystemConfig } = await import('@/lib/config')
const config = await getSystemConfig()
const entitlements = await getAllEntitlementsForAdmin()
const usageOverview = await getUsageOverviewInternal()
const billingConfig = Object.fromEntries(
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
)
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
}
export async function updatePlanEntitlement(
tier: string,
feature: string,
mode: 'unavailable' | 'unlimited' | 'limited',
limitValue?: number,
) {
const session = await checkAdmin()
assertValidTier(tier)
assertValidFeature(feature)
if (mode === 'limited') {
if (limitValue === undefined || !Number.isFinite(limitValue) || limitValue < 0) {
throw new Error('Limit must be a non-negative number')
}
}
if (mode === 'unavailable') {
await prisma.planEntitlement.deleteMany({
where: { tier: tier as SubscriptionTier, feature },
})
} else {
await prisma.planEntitlement.upsert({
where: {
tier_feature: {
tier: tier as SubscriptionTier,
feature,
},
},
update: {
limitValue: mode === 'unlimited' ? null : Math.round(limitValue!),
},
create: {
tier: tier as SubscriptionTier,
feature,
limitValue: mode === 'unlimited' ? null : Math.round(limitValue!),
},
})
}
invalidateEntitlementCache()
await logAuditEventAsync({
userId: session.user?.id,
action: 'PLAN_ENTITLEMENT_UPDATED',
resource: `${tier}:${feature}`,
metadata: { tier, feature, mode, limitValue: mode === 'limited' ? limitValue : mode },
})
revalidatePath('/admin/billing')
return { success: true }
}
export async function updateBillingConfig(data: Record<string, string>) {
const session = await checkAdmin()
const filtered = Object.fromEntries(
Object.entries(data).filter(([key, value]) =>
(BILLING_CONFIG_KEYS as readonly string[]).includes(key)
&& value !== ''
&& !value.includes('sk_')
&& !value.includes('whsec_'),
),
)
if (filtered.BILLING_ENABLED === 'true') {
const required = [
'STRIPE_PRICE_PRO_MONTHLY',
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
] as const
for (const key of required) {
if (!filtered[key] && !process.env[key]) {
throw new Error(`Missing ${key} when billing is enabled`)
}
}
}
const operations = Object.entries(filtered).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
}),
)
await prisma.$transaction(operations)
await logAuditEventAsync({
userId: session.user?.id,
action: 'BILLING_CONFIG_UPDATED',
resource: 'billing',
metadata: { keys: Object.keys(filtered) },
})
revalidatePath('/admin/billing')
revalidatePath('/settings/billing')
return { success: true }
}
async function getUsageOverviewInternal() {
const period = getCurrentPeriodKey()
const periodStart = new Date(`${period}-01`)
const aggregated = await prisma.usageLog.groupBy({
by: ['feature'],
where: { periodStart },
_sum: { requestsCount: true, tokensUsed: true },
_count: { userId: true },
})
const lastSync = await prisma.usageLog.findFirst({
where: { periodStart },
orderBy: { syncedAt: 'desc' },
select: { syncedAt: true },
})
const topUsers = await prisma.usageLog.groupBy({
by: ['userId'],
where: { periodStart },
_sum: { requestsCount: true },
orderBy: { _sum: { requestsCount: 'desc' } },
take: 10,
})
const userIds = topUsers.map((u) => u.userId)
const users = userIds.length
? await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, email: true, name: true },
})
: []
const userMap = Object.fromEntries(users.map((u) => [u.id, u]))
return {
period,
lastSyncedAt: lastSync?.syncedAt?.toISOString() ?? null,
byFeature: aggregated.map((row) => ({
feature: row.feature,
requests: row._sum.requestsCount ?? 0,
tokens: row._sum.tokensUsed ?? 0,
users: row._count.userId,
})),
topUsers: topUsers.map((row) => ({
userId: row.userId,
email: userMap[row.userId]?.email ?? row.userId,
name: userMap[row.userId]?.name ?? null,
requests: row._sum.requestsCount ?? 0,
})),
}
}
export async function getUsageOverview() {
await checkAdmin()
return getUsageOverviewInternal()
}