fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s

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:
Antigravity
2026-06-20 15:48:18 +00:00
parent 5b13a88b72
commit ee70e74bf5
51 changed files with 1483 additions and 252 deletions

View 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()
}

View File

@@ -140,7 +140,7 @@ export async function updateUserRole(userId: string, newRole: string) {
}
export async function updateUserSubscription(userId: string, tier: string) {
await checkAdmin()
const session = await checkAdmin()
const validTiers: string[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
if (!validTiers.includes(tier)) {
@@ -148,6 +148,9 @@ export async function updateUserSubscription(userId: string, tier: string) {
}
try {
const existing = await prisma.subscription.findUnique({ where: { userId } })
const oldTier = existing?.tier ?? 'BASIC'
const now = new Date()
const periodEnd = new Date(now)
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
@@ -168,6 +171,15 @@ export async function updateUserSubscription(userId: string, tier: string) {
currentPeriodEnd: periodEnd,
},
})
const { logAuditEventAsync } = await import('@/lib/audit-log')
await logAuditEventAsync({
userId: session.user?.id,
action: 'SUBSCRIPTION_OVERRIDE',
resource: userId,
metadata: { oldTier, newTier: tier, targetUserId: userId },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {

View File

@@ -4,7 +4,7 @@ import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
import { checkEntitlementOrThrow, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow } from '@/lib/entitlements'
import { toolRegistry } from '@/lib/ai/tools/registry'
// S'assurer que l'outil est importé pour s'enregistrer dans le registre
@@ -72,7 +72,7 @@ export async function generateDiagramFromText(text: string): Promise<{ success:
try {
// 1. Vérification et déduction des quotas
await checkEntitlementOrThrow(userId, 'excalidraw_generate')
await reserveUsageOrThrow(userId, 'excalidraw_generate')
// 2. Instancier le modèle de chat IA
const systemConfig = await getSystemConfig()
@@ -106,9 +106,6 @@ export async function generateDiagramFromText(text: string): Promise<{ success:
return { success: false, error: result.error || "La création du canevas a échoué." }
}
// 6. Incrémenter le quota
await incrementUsageAsync(userId, 'excalidraw_generate')
return { success: true, canvasId: result.canvasId }
} catch (err: any) {

View File

@@ -11,7 +11,7 @@ import { embeddingService } from '@/lib/ai/services/embedding.service'
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow } from '@/lib/entitlements'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { getAISettings } from '@/app/actions/ai-settings'
import {
@@ -521,7 +521,7 @@ export async function createNote(data: {
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
// Incrémenter le quota une seule fois par sauvegarde où des labels IA sont appliqués
incrementUsageAsync(userId, 'auto_tag')
await reserveUsageOrThrow(userId, 'auto_tag')
if (!data.skipRevalidation) {
revalidatePath('/home')
}

View File

@@ -2,7 +2,7 @@
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
import { auth } from '@/auth'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export interface SemanticSearchResponse {
results: SearchResult[]
@@ -25,12 +25,11 @@ export async function semanticSearch(
const session = await auth();
if (session?.user?.id) {
try {
await checkEntitlementOrThrow(session.user.id, 'semantic_search');
await reserveUsageOrThrow(session.user.id, 'semantic_search');
} catch (err) {
if (err instanceof QuotaExceededError) throw err;
console.error('[semantic-search] Quota check error (fail-open):', err);
}
incrementUsageAsync(session.user.id, 'semantic_search');
}
try {