feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -17,14 +17,14 @@ export const maxDuration = 60
const sectionPlanSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().optional(),
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().nullish(),
})
const requestSchema = z.object({
/** undefined = legacy deterministic full-page (fallback path) */
action: z.enum(['plan', 'section']).optional(),
content: z.string().min(40),
content: z.string().min(40).max(500_000),
lang: z.string().optional(),
noteId: z.string().optional(),
notebookId: z.string().optional(),
@@ -44,7 +44,10 @@ export async function POST(req: NextRequest) {
return aiConsentForbiddenResponse()
}
const body = await req.json()
const body = await req.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = requestSchema.parse(body)
const wordCount = parsed.content
.replace(/<[^>]+>/g, ' ')
@@ -61,11 +64,12 @@ export async function POST(req: NextRequest) {
const provider = getSlidesProvider(config)
const lang = parsed.lang || 'fr'
// ── LLM plan (billed: page = 20 crédits, spec §8.5) ──────────────────
// ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ──
if (parsed.action === 'plan') {
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 5,
})
} catch (err) {
if (err instanceof QuotaExceededError) {
@@ -97,7 +101,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
}
// ── LLM single section (already billed at plan time) ────────────────
// ── LLM single section (billed 3/20 crédits per section — no free LLM) ──
if (parsed.action === 'section') {
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
return NextResponse.json(
@@ -105,6 +109,26 @@ export async function POST(req: NextRequest) {
{ status: 400 }
)
}
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 3,
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
}
const result = await generatePageSection({
content: parsed.content,
lang,
@@ -171,7 +195,16 @@ export async function POST(req: NextRequest) {
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
return NextResponse.json(
{
error: 'Requête invalide',
issues: error.issues.slice(0, 10).map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
{ status: 400 }
)
}
const message =
error instanceof Error ? error.message : 'Erreur génération interactive page'

View File

@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import {
shouldOfferSubscriptionTrial,
SUBSCRIPTION_TRIAL_DAYS,
} from '@/lib/billing/trial';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const offerTrial = await shouldOfferSubscriptionTrial(userId);
const subscriptionData: {
metadata: { userId: string; tier: string };
trial_period_days?: number;
} = {
metadata: { userId, tier },
};
if (offerTrial) {
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
}
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
}
} catch (embeddedErr) {
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
});
@@ -132,7 +148,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
} catch (error) {
console.error('[billing/create-checkout]', error);
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';

View File

@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
export const dynamic = 'force-dynamic';
@@ -23,47 +25,16 @@ export async function GET(req: NextRequest) {
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
: (checkoutSession.subscription as { id: string }).id;
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
sub.items?.data?.[0]?.current_period_start ??
sub.start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
sub.current_period_end ??
sub.items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
await prisma.subscription.upsert({
where: { userId },
update: {
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
create: {
userId,
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
},
});
const sub = await stripe.subscriptions.retrieve(subId);
const syncUserId =
(checkoutSession.metadata?.userId as string | undefined) ??
(sub.metadata?.userId as string | undefined) ??
userId;
if (syncUserId === userId) {
await syncSubscriptionFromStripe(sub, userId);
}
}
} catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err);
@@ -112,6 +83,8 @@ export async function GET(req: NextRequest) {
}
}
const trialEligible = await shouldOfferSubscriptionTrial(userId);
return NextResponse.json({
tier,
effectiveTier,
@@ -120,6 +93,9 @@ export async function GET(req: NextRequest) {
currentPeriodStart: subscription?.currentPeriodStart ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
trialEligible,
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
prices,
creditPacks,
billingEnabled,

View File

@@ -12,6 +12,8 @@ import {
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
break;
}
case 'customer.subscription.trial_will_end': {
const subscription = event.data.object as Stripe.Subscription;
const userId = await resolveUserIdFromStripeEvent(subscription);
if (!userId) {
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
break;
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
name: true,
aiSettings: { select: { preferredLanguage: true } },
},
});
if (!user?.email) break;
await syncSubscriptionFromStripe(subscription, userId);
const trialEndsAt = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
const appUrl =
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
'https://memento-note.com';
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
const mailResult = await sendTrialEndingReminder({
to: user.email,
name: user.name,
trialEndsAt,
billingUrl: `${appUrl}/settings/billing`,
locale: preferred === 'auto' ? 'en' : preferred,
});
if (!mailResult.success) {
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
}
break;
}
default:
break;
}

View File

@@ -59,6 +59,7 @@ export async function GET() {
const [
recentNotes,
inboxCount,
inboxPreview,
dueFlashcards,
upcomingReminders,
unviewedInsights,
@@ -83,6 +84,13 @@ export async function GET() {
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.note.findMany({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 3,
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
@@ -177,6 +185,12 @@ export async function GET() {
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
inboxPreview: inboxPreview.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,

View File

@@ -7,8 +7,7 @@ import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
import { validateInteractivePage } from '@/lib/interactive-page'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
@@ -93,11 +92,61 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
}
}
/** All human-facing text of a PageSpecV1 — fed to moderation. */
function collectPageText(page: PageSpecV1): string[] {
const out: string[] = [
page.hero.kicker,
page.hero.title,
page.hero.subtitle ?? '',
page.hero.meta ?? '',
page.footer ?? '',
]
if (page.overview) {
out.push(page.overview.lead)
for (const c of page.overview.cards) out.push(c.badge, c.title, c.body)
}
for (const section of page.sections) {
out.push(section.title)
for (const b of section.blocks) {
if (b.type === 'prose') out.push(b.md)
else if (b.type === 'formula') out.push(b.caption ?? '')
else if (b.type === 'callout') out.push(b.title, b.md)
else if (b.type === 'demo') {
out.push(b.caption ?? '', b.demo.disclaimer ?? '')
for (const act of b.demo.acts) {
out.push(act.title)
for (const st of act.steps) out.push(st.speak)
}
for (const panel of b.demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) out.push(n.label ?? '')
}
}
} else if (b.type === 'chart') {
out.push(b.caption ?? '')
for (const s of b.payload.series) out.push(s.label ?? '')
} else if (b.type === 'stats') {
for (const it of b.items) out.push(it.value, it.label)
} else if (b.type === 'table') {
out.push(b.caption ?? '', ...b.columns, ...b.rows.flat())
} else if (b.type === 'image') {
out.push(b.alt, b.caption ?? '')
} else if (b.type === 'sim') {
out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '')
}
}
}
return out.filter((s) => s && s.trim())
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
noteId?: string
action?: string
@@ -123,9 +172,21 @@ export async function POST(request: NextRequest) {
return aiConsentForbiddenResponse()
}
let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null
let validatedPage: PageValidationResult | null = null
if (pageSpec) {
// Client-provided page (from the preview dialog): must validate as-is.
// Never silently substitute a different page than the one previewed.
const checked = validateInteractivePage(pageSpec)
if (!checked.ok) {
return NextResponse.json(
{ error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) },
{ status: 422 }
)
}
validatedPage = checked
}
if (!validatedPage?.ok) {
if (!validatedPage) {
// Deterministic generate (no LLM quota)
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
@@ -144,22 +205,12 @@ export async function POST(request: NextRequest) {
{ status: 422 }
)
}
validatedPage = { ok: true, page: generated.page }
validatedPage = { ok: true as const, page: generated.page }
}
// Guaranteed valid PageSpec after generate-or-validate above
if (!validatedPage?.ok) {
return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
}
const textForModeration = [
validatedPage.page.hero.title,
validatedPage.page.hero.subtitle,
validatedPage.page.overview?.lead,
...validatedPage.page.sections.map((s) => s.title),
]
.filter(Boolean)
.join('\n')
// Moderate ALL human-facing text of the page (prose, callouts, demo
// narration, tables, captions) — not just titles.
const textForModeration = collectPageText(validatedPage.page).join('\n')
const moderation = await moderateWithFallback(
note.title || '',