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,303 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
type EntitlementRow = {
tier: string
feature: string
limitValue: number | null
mode: 'limited' | 'unlimited' | 'unavailable'
}
type BillingAdminData = {
entitlements: EntitlementRow[]
billingConfig: Record<string, string>
usageOverview: {
period: string
lastSyncedAt: string | null
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
features: string[]
tiers: string[]
}
function getEntitlement(
entitlements: EntitlementRow[],
tier: string,
feature: string,
): EntitlementRow | undefined {
return entitlements.find((e) => e.tier === tier && e.feature === feature)
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [savingCell, setSavingCell] = useState<string | null>(null)
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
try {
const data: Record<string, string> = {
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
}
await updateBillingConfig(data)
toast.success(t('admin.billing.configSaved'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('admin.billing.configFailed'))
} finally {
setIsSavingBilling(false)
}
}
const handleEntitlementChange = async (
feature: string,
mode: 'unavailable' | 'unlimited' | 'limited',
limitValue?: number,
) => {
const key = `${activeTier}:${feature}`
setSavingCell(key)
try {
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
toast.success(t('admin.billing.limitSaved'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
} finally {
setSavingCell(null)
}
}
return (
<div className="space-y-8">
<div>
<h1 className="font-memento-serif text-2xl font-semibold">{t('admin.billing.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<CreditCard className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.stripeConfigTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="BILLING_ENABLED"
checked={billingEnabled}
onCheckedChange={(c) => setBillingEnabled(!!c)}
/>
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
</form>
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Settings2 className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-wrap gap-2">
{initialData.tiers.map((tier) => (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
activeTier === tier
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
>
{tier}
</button>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
<th className="py-2">{t('admin.billing.actions')}</th>
</tr>
</thead>
<tbody>
{initialData.features.map((feature) => {
const row = getEntitlement(initialData.entitlements, activeTier, feature)
const mode = row?.mode ?? 'unavailable'
const cellKey = `${activeTier}:${feature}`
return (
<EntitlementRowEditor
key={`${activeTier}:${feature}`}
feature={feature}
mode={mode}
limitValue={row?.limitValue ?? null}
isSaving={savingCell === cellKey}
onSave={handleEntitlementChange}
t={t}
/>
)
})}
</tbody>
</table>
</div>
</div>
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Gauge className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.usageTitle')}</h2>
<p className="text-sm text-muted-foreground">
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
: ` · ${t('admin.billing.notSynced')}`}
</p>
</div>
</div>
<div className="p-6 grid gap-6 lg:grid-cols-2">
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.byFeature')}</h3>
{initialData.usageOverview.byFeature.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.billing.noUsageData')}</p>
) : (
<ul className="space-y-2 text-sm">
{initialData.usageOverview.byFeature.map((row) => (
<li key={row.feature} className="flex justify-between gap-4 border-b border-border/50 pb-2">
<span className="font-mono text-xs">{row.feature}</span>
<span className="text-muted-foreground">{row.requests} req · {row.tokens} tok</span>
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.topUsers')}</h3>
{initialData.usageOverview.topUsers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.billing.noUsageData')}</p>
) : (
<ul className="space-y-2 text-sm">
{initialData.usageOverview.topUsers.map((row) => (
<li key={row.userId} className="flex justify-between gap-4 border-b border-border/50 pb-2">
<span className="truncate">{row.email}</span>
<span className="text-muted-foreground shrink-0">{row.requests} req</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
)
}
function EntitlementRowEditor({
feature,
mode: initialMode,
limitValue: initialLimit,
isSaving,
onSave,
t,
}: {
feature: string
mode: 'unavailable' | 'unlimited' | 'limited'
limitValue: number | null
isSaving: boolean
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
t: (key: string, params?: Record<string, string | number>) => string
}) {
const [mode, setMode] = useState(initialMode)
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
useEffect(() => {
setMode(initialMode)
setLimit(initialLimit != null ? String(initialLimit) : '50')
}, [initialMode, initialLimit])
return (
<tr className="border-b border-border/30">
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
<td className="py-3 pr-4">
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
<option value="limited">{t('admin.billing.modeLimited')}</option>
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
</select>
</td>
<td className="py-3 pr-4">
{mode === 'limited' ? (
<Input
type="number"
min={0}
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="h-9 w-24 text-xs"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving}
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
>
{isSaving ? '…' : t('admin.billing.saveLimit')}
</Button>
</td>
</tr>
)
}

View File

@@ -0,0 +1,9 @@
import { getBillingAdminData } from '@/app/actions/admin-billing'
import { BillingAdminClient } from './billing-admin-client'
export const dynamic = 'force-dynamic'
export default async function AdminBillingPage() {
const data = await getBillingAdminData()
return <BillingAdminClient initialData={data} />
}

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 {

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -48,7 +48,7 @@ export async function POST(req: NextRequest) {
// Quota check — feature key depends on generation type
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
try {
await checkEntitlementOrThrow(userId, featureKey)
await reserveUsageOrThrow(userId, featureKey)
} catch (e) {
if (e instanceof QuotaExceededError) {
return NextResponse.json({ error: e.message }, { status: 402 })

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
export async function POST(request: NextRequest) {
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
})()
}
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
exercises: createdNotes.map(n => ({ id: n.id, title: n.title })),

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { mathFromTextService } from '@/lib/ai/services/math-from-text.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -29,7 +29,6 @@ export async function POST(request: NextRequest) {
}
const latex = await mathFromTextService.generate(description)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({ latex })
} catch (error: any) {

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -120,7 +120,6 @@ export async function POST(request: NextRequest) {
})()
}
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
notebookId: notebook.id,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { syncNoteLabels } from '@/app/actions/notes'
export async function POST(request: NextRequest) {
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -48,7 +48,6 @@ export async function POST(request: NextRequest) {
const result = await notebookOrganizerService.analyze(notesForAnalysis)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json(result)
} catch (error: any) {

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
@@ -90,7 +90,7 @@ export async function POST(request: NextRequest) {
// Quota check (reuse reformulate quota)
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -114,7 +114,6 @@ export async function POST(request: NextRequest) {
const fullPrompt = `${persona.systemPrompt}\n\n---\nNOTE À ANALYSER :\n${plainText}`
const result = await provider.generateText(fullPrompt)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
personaId: persona.id,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { getAISettings } from '@/app/actions/ai-settings'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
// Check quota
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -75,8 +75,6 @@ export async function POST(request: NextRequest) {
// Use the ParagraphRefactorService
const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language, writePrompt)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
originalText: result.original,
reformulatedText: result.refactored,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { studyPlannerService } from '@/lib/ai/services/study-planner.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -61,7 +61,6 @@ export async function POST(request: NextRequest) {
}
}
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json(plan)
} catch (error: any) {

View File

@@ -4,8 +4,7 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export const maxDuration = 30
@@ -56,7 +55,7 @@ export async function POST(req: Request) {
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
console.log('[suggest-charts] BYOK:', willUseByok)
if (!willUseByok) {
await checkEntitlementOrThrow(userId, 'suggest_charts')
await reserveUsageOrThrow(userId, 'suggest_charts')
console.log('[suggest-charts] Quota OK')
}
} catch (err) {
@@ -243,9 +242,6 @@ Response format (COPY this structure):
parsed.hasData = false
}
// Track usage
await trackFeatureUsage(userId, 'suggest_charts', 'suggest-charts', 1)
return Response.json(parsed satisfies SuggestChartsResponse)
} catch (error) {

View File

@@ -4,7 +4,7 @@ import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user';
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements';
import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements';
import { hasUserAiConsent } from '@/lib/consent/server-consent';
import { getAISettings } from '@/app/actions/ai-settings';

View File

@@ -3,7 +3,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
@@ -43,18 +43,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ suggestions: [] })
}
try {
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id);
if (!willUseByok) {
await checkEntitlementOrThrow(session.user.id, 'auto_title');
}
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 });
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err);
}
const body = await req.json()
const { content: rawContent } = requestSchema.parse(body)
@@ -72,6 +60,17 @@ export async function POST(req: NextRequest) {
}
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
if (!willUseByok) {
try {
await reserveUsageOrThrow(session.user.id, 'auto_title')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
}
}
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
const isPersian = /[\u0600-\u06FF]/.test(content)
@@ -130,13 +129,12 @@ CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
const { result: titles, usedByok } = await runLaneWithBillingUser(
const { result: titles } = await runLaneWithBillingUser(
'tags',
config,
session.user.id,
(provider) => provider.generateTitles(titlePrompt),
)
if (!usedByok) incrementUsageAsync(session.user.id, 'auto_title')
// Créer les suggestions
const suggestions = titles.map((t: any) => ({

View File

@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
const userEmail = session.user.email;
try {
const priceId = resolvePriceId(tier, interval);
const priceId = await resolvePriceId(tier, interval);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier, getDynamicPrices } from '@/lib/billing/stripe-prices';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
export const dynamic = 'force-dynamic';
@@ -28,7 +28,7 @@ export async function GET(req: NextRequest) {
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = priceIdToTier(priceId) || (checkoutSession.metadata?.tier as any) || 'PRO';
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
@@ -75,6 +75,7 @@ export async function GET(req: NextRequest) {
const effectiveTier = await getEffectiveTier(userId);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
return NextResponse.json({
tier,
@@ -85,6 +86,7 @@ export async function GET(req: NextRequest) {
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
prices,
billingEnabled,
});
} catch (error) {
console.error('[billing/status]', error);

View File

@@ -6,9 +6,8 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
import { getSystemConfig } from '@/lib/config'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import {
checkEntitlementOrThrow,
reserveUsageOrThrow,
QuotaExceededError,
incrementUsageAsync,
} from '@/lib/entitlements'
import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
@@ -221,7 +220,7 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
if (!willUseByok) {
await checkEntitlementOrThrow(userId, 'brainstorm_create')
await reserveUsageOrThrow(userId, 'brainstorm_create')
}
const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds)
@@ -233,7 +232,6 @@ export async function POST(request: NextRequest) {
userId,
(provider) => provider.generateText(prompt),
)
if (!usedByok) incrementUsageAsync(userId, 'brainstorm_create')
let ideas: any[]
try {

View File

@@ -9,7 +9,7 @@ import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { ByokUnavailableError } from '@/lib/byok'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises'
@@ -66,7 +66,7 @@ export async function POST(req: Request) {
const sysConfigEarly = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
if (!willUseByok) {
await checkEntitlementOrThrow(userId, 'chat')
await reserveUsageOrThrow(userId, 'chat')
}
} catch (err) {
if (err instanceof QuotaExceededError) {
@@ -483,7 +483,6 @@ Focus ONLY on this note unless asked otherwise.`
})
if (!usedByok) {
trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0)
incrementUsageAsync(userId, 'chat')
}
logAuditEvent({
userId,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getAISettings } from '@/app/actions/ai-settings'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'ai_flashcard')
await reserveUsageOrThrow(session.user.id, 'ai_flashcard')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -70,7 +70,6 @@ export async function POST(request: NextRequest) {
language: note.language || undefined,
})
incrementUsageAsync(session.user.id, 'ai_flashcard')
return NextResponse.json({ cards, noteId: note.id, style })
} catch (error) {

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMobileUserId } from '@/lib/mobile-auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
const MODE_MAP: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar'> = {
improve: 'improveStyle',
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
if (!validation.valid) return NextResponse.json({ error: validation.error }, { status: 400 })
try {
await checkEntitlementOrThrow(userId, 'reformulate')
await reserveUsageOrThrow(userId, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 })
@@ -35,7 +35,6 @@ export async function POST(req: NextRequest) {
}
const result = await paragraphRefactorService.refactor(text, refactorMode, 'markdown', undefined)
incrementUsageAsync(userId, 'reformulate')
return NextResponse.json({ improved: result.refactored, original: result.original })
}

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMobileUserId } from '@/lib/mobile-auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(req: NextRequest) {
const userId = getMobileUserId(req)
@@ -14,25 +14,27 @@ export async function POST(req: NextRequest) {
const wordCount = content.split(/\s+/).length
if (wordCount < 5) return NextResponse.json({ error: 'Contenu trop court (min 5 mots)' }, { status: 400 })
try {
await checkEntitlementOrThrow(userId, 'auto_title')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 })
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, userId)
if (!willUseByok) {
try {
await reserveUsageOrThrow(userId, 'auto_title')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json({ error: 'quota_exceeded' }, { status: 402 })
}
throw err
}
throw err
}
const config = await getSystemConfig()
const prompt = `Génère 3 titres concis pour ce texte. Réponds UNIQUEMENT avec un tableau JSON: [{"title":"titre1"},{"title":"titre2"},{"title":"titre3"}]\n\nTexte: ${content.slice(0, 400)}`
const { result: titles, usedByok } = await runLaneWithBillingUser(
const { result: titles } = await runLaneWithBillingUser(
'tags',
config,
userId,
(provider) => provider.generateTitles(prompt),
)
if (!usedByok) incrementUsageAsync(userId, 'auto_title')
return NextResponse.json({ suggestions: (titles ?? []).map((t: any) => t.title ?? t) })
}

View File

@@ -3,7 +3,7 @@ import prisma from '@/lib/prisma'
import { getMobileUserId } from '@/lib/mobile-auth'
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(req: NextRequest) {
const userId = getMobileUserId(req)
@@ -29,7 +29,7 @@ export async function POST(req: NextRequest) {
}
try {
await checkEntitlementOrThrow(userId, 'ai_flashcard')
await reserveUsageOrThrow(userId, 'ai_flashcard')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json({ error: err.currentQuota === 0 ? 'Fonctionnalité non disponible sur votre abonnement' : 'Quota IA atteint' }, { status: 402 })
@@ -78,7 +78,6 @@ export async function POST(req: NextRequest) {
})
await prisma.flashcardDeck.update({ where: { id: deckId }, data: { updatedAt: new Date() } })
incrementUsageAsync(userId, 'ai_flashcard')
return NextResponse.json({ deckId, count: cards.length, cards })
}