fix: images, couverture, slash, agents, wizard, Stripe et admin

- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
This commit is contained in:
Antigravity
2026-07-16 16:58:07 +00:00
parent 4fe31ebc99
commit 45297da333
27 changed files with 1302 additions and 412 deletions

View File

@@ -39,10 +39,18 @@ export async function POST(request: NextRequest) {
{ level, count, language: language || 'fr' }
)
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
const resolvedName = (
result.notebookName?.trim()
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|| result.notes[0]?.title?.trim()
|| topic.trim().slice(0, 60)
).slice(0, 80)
// 1. Create notebook
const notebook = await prisma.notebook.create({
data: {
name: notebookName || topic,
name: resolvedName,
icon: notebookIcon || '📚',
userId: session.user.id,
order: 0,

View File

@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { resolvePriceId } from '@/lib/billing/stripe-prices';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const bodySchema = z.object({
tier: z.enum(['PRO', 'BUSINESS']),
interval: z.enum(['month', 'year']),
/** Prefer hosted redirect when embedded checkout is unavailable */
mode: z.enum(['hosted', 'embedded']).optional(),
});
export async function POST(req: NextRequest) {
@@ -16,17 +18,46 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
}
const secret = process.env.STRIPE_SECRET_KEY;
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
);
}
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { tier, interval } = parsed.data;
const preferredMode = parsed.data.mode ?? 'hosted';
const userId = session.user.id;
const userEmail = session.user.email;
try {
const priceId = await resolvePriceId(tier, interval);
let priceId: string;
try {
priceId = await resolvePriceId(tier, interval);
} catch (e) {
console.error('[billing/create-checkout] price resolve failed:', e);
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
@@ -41,8 +72,7 @@ export async function POST(req: NextRequest) {
metadata: { userId },
});
customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
@@ -52,8 +82,8 @@ export async function POST(req: NextRequest) {
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
}
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
});
}
@@ -61,29 +91,51 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const sessionParams = {
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
const embedded = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
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 } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
if (embedded.client_secret) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
});
}
} catch (embeddedErr) {
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
}
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription' as const,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page',
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
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 } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
});
if (checkoutSession.client_secret) {
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
});
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url });
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
} catch (error) {
console.error('[billing/create-checkout]', error);
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
try {
const now = new Date()
// Due = nextRun in the past; also backfill agents whose nextRun was never set
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
const dueAgents = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { not: 'manual' },
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: { lte: now },
},
select: {
@@ -40,13 +42,44 @@ export async function POST(request: NextRequest) {
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
orderBy: { nextRun: 'asc' },
})
if (dueAgents.length === 0) {
return NextResponse.json({ success: true, executed: 0 })
// One-time repair: set nextRun for enabled scheduled agents stuck with null
const unscheduled = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: null,
},
select: {
id: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
},
take: 50,
})
for (const a of unscheduled) {
const nextRun = calculateNextRun({
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
})
await prisma.agent.update({ where: { id: a.id }, data: { nextRun } })
}
if (dueAgents.length === 0) {
return NextResponse.json({
success: true,
executed: 0,
repairedSchedules: unscheduled.length,
})
}
const results: { id: string; success: boolean; error?: string }[] = []
@@ -56,7 +89,6 @@ export async function POST(request: NextRequest) {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId)
// Calculate and set next run
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,
@@ -64,7 +96,6 @@ export async function POST(request: NextRequest) {
timezone: agent.timezone,
})
await prisma.agent.update({
where: { id: agent.id },
data: { nextRun },
@@ -75,7 +106,6 @@ export async function POST(request: NextRequest) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
// Still schedule next run even on failure
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,

View File

@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth } from '@/auth'
import { writeUploadMeta } from '@/lib/upload-access'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -14,14 +15,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
)
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
@@ -33,7 +32,6 @@ export async function POST(request: NextRequest) {
}
const buffer = Buffer.from(await file.arrayBuffer())
// Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes)
let ext = path.extname(file.name).toLowerCase()
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
const mimeToExt: Record<string, string> = {
@@ -44,23 +42,28 @@ export async function POST(request: NextRequest) {
}
ext = mimeToExt[file.type] || '.png'
}
const filename = `${randomUUID()}${ext}`
// Ensure directory exists (data/uploads is outside public/, served via API route)
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
// Ownership in path: /uploads/notes/{userId}/{filename}
const relativeUnderNotes = `${userId}/${filename}`
const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId)
await mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, filename)
await writeFile(filePath, buffer)
await writeUploadMeta(relativeUnderNotes, userId)
return NextResponse.json({
success: true,
url: `/uploads/notes/${filename}`
const url = `/uploads/notes/${relativeUnderNotes}`
return NextResponse.json({
success: true,
url,
})
} catch (error) {
console.error('[upload]', error)
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
{ status: 500 },
)
}
}

View File

@@ -21,16 +21,30 @@ export async function GET(
const session = await auth()
const { path: segments } = await params
// Only serve from uploads/notes/ subdirectory
if (segments[0] !== 'notes') {
// Only serve from uploads/notes/ (flat or userId/file)
if (!segments.length || segments[0] !== 'notes') {
return new NextResponse('Not found', { status: 404 })
}
// Reject path traversal
if (segments.some((s) => s === '..' || s.includes('\0'))) {
return new NextResponse('Forbidden', { status: 403 })
}
const filename = segments[segments.length - 1]
const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id)
// Never serve meta sidecars as images
if (filename.endsWith('.meta.json')) {
return new NextResponse('Not found', { status: 404 })
}
const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id)
if (!allowed) {
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
status: session?.user?.id ? 403 : 401,
headers: {
// Do not let browsers / CDNs cache a denial (would stick a broken image)
'Cache-Control': 'no-store',
},
})
}
@@ -40,9 +54,8 @@ export async function GET(
return new NextResponse('Unsupported file type', { status: 400 })
}
// Prevent path traversal
const safePath = path.join(UPLOAD_DIR, ...segments)
if (!safePath.startsWith(UPLOAD_DIR)) {
if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) {
return new NextResponse('Forbidden', { status: 403 })
}
@@ -57,7 +70,8 @@ export async function GET(
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
// private: only for authenticated session fetches; still long cache after auth ok
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Length': String(buffer.length),
},
})