import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/prisma' import { auth } from '@/auth' import { z } from 'zod' import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user' import { getSystemConfig } from '@/lib/config' import { embeddingService } from '@/lib/ai/services/embedding.service' import { checkSessionEntitlementOrThrow, QuotaExceededError, } from '@/lib/entitlements' import { billingOwnerFromSession, verifyParticipant, resolveAiContextUserId, sanitizeNotesForGuest, captureSnapshot, logActivity, } from '@/lib/brainstorm-collab' import { emitToSession } from '@/lib/socket-emit' const expandSchema = z.object({ ideaId: z.string().min(1), locale: z.string().optional(), }) interface ParentNoteRef { noteId: string | null relation: string explanation: string noteTitle?: string | null noteSnippet?: string } // [UPDATE - SÉCURITÉ] allowedNoteIds : null = hôte (accès total), string[] = invité (IDs publics uniquement) async function getParentContext( ideaId: string, hostUserId: string, allowedNoteIds: string[] | null ): Promise<{ notes: ParentNoteRef[]; noteIds: string[] }> { const refs = await prisma.brainstormNoteRef.findMany({ where: { ideaId }, include: { note: { select: { id: true, title: true, content: true } } }, }) const noteIds = refs.map(r => r.noteId).filter(Boolean) as string[] const notes: ParentNoteRef[] = refs.map(r => ({ noteId: r.noteId, relation: r.relation, explanation: r.explanation, noteTitle: r.note?.title || null, noteSnippet: (r.note?.content || '').slice(0, 200), })) // [UPDATE - SÉCURITÉ] Enrichissement vectoriel uniquement pour l'hôte const isGuest = allowedNoteIds !== null if (!isGuest && noteIds.length < 3) { try { const idea = await prisma.brainstormIdea.findUnique({ where: { id: ideaId } }) if (idea) { const embedding = await embeddingService.generateEmbedding(`${idea.title} ${idea.description}`) const vectorStr = embeddingService.toVectorString(embedding.embedding) const excludeList = noteIds.length > 0 ? noteIds.map(id => `'${id}'`).join(',') : "''" const extra = await prisma.$queryRawUnsafe( `SELECT n.id, n.title FROM "NoteEmbedding" e JOIN "Note" n ON n.id = e."noteId" WHERE n."userId" = $1 AND n."trashedAt" IS NULL AND n.id NOT IN (${excludeList}) ORDER BY e.embedding::vector <=> $2::vector LIMIT 5`, hostUserId, vectorStr ) as any[] for (const n of extra) { if (!noteIds.includes(n.id)) { noteIds.push(n.id) notes.push({ noteId: n.id, relation: 'extends', explanation: `Related to your note "${n.title}"`, noteTitle: n.title }) } } } } catch {} } // [UPDATE - SÉCURITÉ] Filtrer selon les permissions invité if (isGuest) { const allowedSet = new Set(allowedNoteIds) return { notes: notes.filter(n => n.noteId === null || allowedSet.has(n.noteId!)), noteIds: noteIds.filter(id => allowedSet.has(id)), } } return { notes, noteIds } } function buildExpandPromptV2( parentTitle: string, parentDesc: string, seedIdea: string, parentRefs: ParentNoteRef[], extraNotes: { id: string; title: string; snippet: string }[], locale?: string ): string { let notesSection = '' const allNotes = [ ...parentRefs.filter(r => r.noteId).map(r => ({ id: r.noteId!, title: r.noteTitle || 'Untitled', snippet: r.noteSnippet || '', relation: r.relation, })), ...extraNotes, ] if (allNotes.length > 0) { notesSection = `\nUSER'S NOTES (context from parent idea and knowledge base):\n` notesSection += allNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.snippet || 'See note for details'}`).join('\n') } return `You are a creative brainstorming assistant. The user wants to DEEPEN a specific idea from a brainstorming session. Generate 3 waves of sub-ideas that CROSS with the user's existing notes. ORIGINAL SESSION SEED: ${seedIdea} PARENT IDEA TO EXPAND: ${parentTitle}: ${parentDesc} ${notesSection} GENERATION RULES: WAVE 1 — VARIATIONS (3 sub-ideas): - Direct expansions, details, or implementations of the parent idea - At least 1 should build on a note referenced above WAVE 2 — ANALOGIES (3 sub-ideas): - Cross-domain parallels from other fields - At least 1 should transpose a pattern from the user's notes WAVE 3 — DISRUPTIONS (3 sub-ideas): - Radical inversions or challenges to the parent idea - At least 1 should synthesize or oppose a note concept RESPOND ONLY with a valid JSON array of 9 objects: { "wave": number (1, 2, or 3), "title": string (short, 2-6 words), "description": string (1-2 sentences, specific and actionable), "connectionToSeed": string (how it relates to the parent idea), "noveltyScore": number (1-10), "noteRefs": [ { "noteId": string (must match an ID provided above, or null), "relation": "derived_from" | "opposes" | "extends" | "synthesizes" | "transposes", "explanation": string } ] } CRITICAL: Each idea MUST have at least 1 noteRef when notes are provided. LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.` } export async function POST( request: NextRequest, { params }: { params: Promise<{ sessionId: string }> } ) { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } try { const { sessionId } = await params const body = await request.json() const { ideaId, locale } = expandSchema.parse(body) // [UPDATE - SÉCURITÉ] Vérification du rôle participant (couvre hôte + invités éditeurs) const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor') if (!isParticipant) { return NextResponse.json({ error: 'No edit permission' }, { status: 403 }) } const brainstormSession = await prisma.brainstormSession.findFirst({ where: { id: sessionId }, include: { ideas: true }, }) if (!brainstormSession) { return NextResponse.json({ error: 'Session not found' }, { status: 404 }) } const { billingOwnerId, isGuestActor } = billingOwnerFromSession( brainstormSession.userId, session.user.id, ) // Story 3.5: per-provider BYOK bypass const earlyConfig = await getSystemConfig() const { usedByok: willUseByok } = await willUseByokForLane('tags', earlyConfig, billingOwnerId) if (!willUseByok) { await checkSessionEntitlementOrThrow( billingOwnerId, session.user.id, isGuestActor, 'brainstorm_expand', ) } const parentIdea = (brainstormSession.ideas || []).find(i => i.id === ideaId) if (!parentIdea) { return NextResponse.json({ error: 'Idea not found' }, { status: 404 }) } // [UPDATE - SÉCURITÉ] Résoudre le périmètre de notes autorisé selon le rôle const { isGuest, publicNoteIds, aiUserId } = await resolveAiContextUserId(sessionId, session.user.id) const { notes: parentRefs, noteIds } = await getParentContext(ideaId, aiUserId, isGuest ? (publicNoteIds ?? []) : null) let extraNotes: { id: string; title: string; snippet: string }[] = [] if (noteIds.length > 0) { const dbNotes = await prisma.note.findMany({ where: { id: { in: noteIds }, trashedAt: null }, select: { id: true, title: true, content: true }, }) const rawNotes = dbNotes.map(n => ({ id: n.id, title: n.title || 'Untitled', summary: (n.content || '').slice(0, 200) })) // [UPDATE - SÉCURITÉ] Sanitize le contenu si invité const sanitized = isGuest ? sanitizeNotesForGuest(rawNotes) : rawNotes extraNotes = sanitized.map(n => ({ id: n.id, title: n.title, snippet: n.summary })) } const config = await getSystemConfig() const prompt = buildExpandPromptV2( parentIdea.title, parentIdea.description, brainstormSession.seedIdea, parentRefs, extraNotes, locale ) const { result: llmResponse } = await runLaneWithBillingUser( 'tags', config, billingOwnerId, (provider) => provider.generateText(prompt), ) let newIdeas: any[] try { const cleaned = llmResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim() newIdeas = JSON.parse(cleaned) if (!Array.isArray(newIdeas)) throw new Error('Not an array') } catch { const pickNote = (idx: number) => { const n = extraNotes[idx % extraNotes.length] return n ? { noteId: n.id, relation: 'extends' as const, explanation: `Related to "${n.title}"` } : { noteId: null, relation: 'extends' as const, explanation: 'Purely generative' } } newIdeas = [ { wave: 1, title: 'Sub-variation A', description: 'Direct expansion.', connectionToSeed: 'Expansion', noveltyScore: 4, noteRefs: [pickNote(0)] }, { wave: 1, title: 'Sub-variation B', description: 'Another angle.', connectionToSeed: 'Detail', noveltyScore: 5, noteRefs: [pickNote(1)] }, { wave: 1, title: 'Sub-variation C', description: 'Implementation detail.', connectionToSeed: 'Implementation', noveltyScore: 3, noteRefs: [pickNote(2)] }, { wave: 2, title: 'Sub-analogy A', description: 'Cross-domain parallel.', connectionToSeed: 'Analogy', noveltyScore: 7, noteRefs: [pickNote(0)] }, { wave: 2, title: 'Sub-analogy B', description: 'From another field.', connectionToSeed: 'Parallel', noveltyScore: 6, noteRefs: [pickNote(1)] }, { wave: 2, title: 'Sub-analogy C', description: 'Inspired by nature.', connectionToSeed: 'Bio-inspired', noveltyScore: 7, noteRefs: [pickNote(2)] }, { wave: 3, title: 'Sub-disruption A', description: 'Challenge assumption.', connectionToSeed: 'Inversion', noveltyScore: 9, noteRefs: [{ ...pickNote(0), relation: 'opposes' as const }] }, { wave: 3, title: 'Sub-disruption B', description: 'Remove constraint.', connectionToSeed: 'Removal', noveltyScore: 8, noteRefs: [{ ...pickNote(1), relation: 'synthesizes' as const }] }, { wave: 3, title: 'Sub-disruption C', description: 'Radical reframe.', connectionToSeed: 'Reframe', noveltyScore: 10, noteRefs: [pickNote(2)] }, ] } const validNoteIds = new Set(extraNotes.map(n => n.id)) for (let idx = 0; idx < newIdeas.length; idx++) { const idea = newIdeas[idx] const angle = (idx % 3) * (2 * Math.PI / 3) + (idea.wave - 1) * 0.5 const baseRadius = 150 const parentRadius = (parentIdea.positionX && parentIdea.positionY) ? Math.sqrt(parentIdea.positionX ** 2 + parentIdea.positionY ** 2) : 0 const radius = parentRadius + idea.wave * baseRadius const baseAngle = (parentIdea.positionX && parentIdea.positionY) ? Math.atan2(parentIdea.positionY, parentIdea.positionX) : 0 const created = await prisma.brainstormIdea.create({ data: { sessionId, waveNumber: idea.wave || Math.floor(idx / 3) + 1, title: idea.title || `Idea ${idx + 1}`, description: idea.description || '', connectionToSeed: idea.connectionToSeed || null, noveltyScore: idea.noveltyScore || null, parentIdeaId: ideaId, positionX: Math.cos(baseAngle + angle) * radius, positionY: Math.sin(baseAngle + angle) * radius, }, }) if (idea.noteRefs && Array.isArray(idea.noteRefs)) { for (const ref of idea.noteRefs) { const noteId = ref.noteId && validNoteIds.has(ref.noteId) ? ref.noteId : null await prisma.brainstormNoteRef.create({ data: { ideaId: created.id, noteId, relation: ref.relation || 'extends', explanation: ref.explanation || '', }, }) } } } const updatedSession = await prisma.brainstormSession.findUnique({ where: { id: sessionId }, include: { ideas: { orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }], include: { noteRefs: { include: { note: { select: { id: true, title: true } }, }, }, }, }, }, }) const cIds = [...new Set((updatedSession?.ideas || []).map((i: any) => i.createdBy).filter(Boolean))] if (cIds.length > 0) { const crs = await prisma.user.findMany({ where: { id: { in: cIds } }, select: { id: true, name: true, image: true } }) const cm = new Map(crs.map((c: any) => [c.id, c])) for (const idea of updatedSession?.ideas || []) { (idea as any).creator = (idea as any).createdBy ? cm.get((idea as any).createdBy) || null : null } } await logActivity(sessionId, 'wave_generated', session.user.id, { count: newIdeas.length, parentIdeaId: ideaId }) await emitToSession(sessionId, 'activity:new', { action: 'wave_generated', userId: session.user.id, userName: session.user.name || 'Guest', details: { count: newIdeas.length, parentIdeaId: ideaId } }) // Trigger refetch for all participants await emitToSession(sessionId, 'idea:added', {}) await captureSnapshot(sessionId, `Wave expanded: ${parentIdea.title}`).catch(() => {}) return NextResponse.json({ success: true, data: updatedSession }) } catch (error: any) { if (error instanceof QuotaExceededError) { return NextResponse.json(error.toJSON(), { status: 402 }) } if (error instanceof z.ZodError) { return NextResponse.json({ error: error.issues }, { status: 400 }) } console.error('Error expanding idea:', error) return NextResponse.json( { error: error.message || 'Failed to expand idea' }, { status: 500 } ) } }