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)
216 lines
5.8 KiB
TypeScript
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()
|
|
}
|