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)
This commit is contained in:
215
memento-note/app/actions/admin-billing.ts
Normal file
215
memento-note/app/actions/admin-billing.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
'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()
|
||||
}
|
||||
Reference in New Issue
Block a user