All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
273 lines
9.8 KiB
TypeScript
273 lines
9.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import prisma from '@/lib/prisma'
|
|
import { auth } from '@/auth'
|
|
import { z } from 'zod'
|
|
import {
|
|
billingOwnerFromSession,
|
|
verifyParticipant,
|
|
logActivity,
|
|
resolveAiContextUserId,
|
|
captureSnapshot,
|
|
} from '@/lib/brainstorm-collab'
|
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
|
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
|
import { getSystemConfig } from '@/lib/config'
|
|
import {
|
|
reserveUsageOrThrow,
|
|
QuotaExceededError,
|
|
} from '@/lib/entitlements'
|
|
import { emitToSession } from '@/lib/socket-emit'
|
|
|
|
const manualSchema = z.object({
|
|
title: z.string().min(1),
|
|
description: z.string().optional(),
|
|
parentIdeaId: z.string().optional(),
|
|
locale: z.string().optional(),
|
|
})
|
|
|
|
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 { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
|
|
|
|
if (!isParticipant) {
|
|
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
|
|
}
|
|
|
|
const body = await request.json()
|
|
const { title, description, parentIdeaId, locale } = manualSchema.parse(body)
|
|
|
|
const brainstormSession = await prisma.brainstormSession.findFirst({
|
|
where: { id: sessionId },
|
|
})
|
|
|
|
if (!brainstormSession) {
|
|
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
|
}
|
|
|
|
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
|
|
brainstormSession.userId,
|
|
session.user.id,
|
|
)
|
|
|
|
let wave = 1
|
|
let parentIdea: any = null
|
|
if (parentIdeaId) {
|
|
parentIdea = await prisma.brainstormIdea.findFirst({
|
|
where: { id: parentIdeaId, sessionId },
|
|
})
|
|
if (parentIdea) {
|
|
wave = Math.min((parentIdea.waveNumber || 1) + 1, 3)
|
|
}
|
|
}
|
|
|
|
const existingIdeas = await prisma.brainstormIdea.count({
|
|
where: { sessionId, waveNumber: wave },
|
|
})
|
|
const angle = (existingIdeas % 4) * (2 * Math.PI / 4) + Math.random() * 0.5
|
|
const radius = wave * 200 + Math.random() * 50
|
|
|
|
const idea = await prisma.brainstormIdea.create({
|
|
data: {
|
|
sessionId,
|
|
waveNumber: wave,
|
|
title,
|
|
description: description || '',
|
|
connectionToSeed: parentIdea
|
|
? `Manual response to "${(parentIdea.title || '').substring(0, 40)}"`
|
|
: 'Manual idea added by participant',
|
|
noveltyScore: null,
|
|
parentIdeaId: parentIdeaId || null,
|
|
createdBy: session.user.id,
|
|
createdByType: 'human',
|
|
positionX: Math.cos(angle) * radius,
|
|
positionY: Math.sin(angle) * radius,
|
|
},
|
|
})
|
|
|
|
// Story 3.5: per-provider BYOK bypass for enrich
|
|
let enrichBlocked = false
|
|
try {
|
|
const config = await getSystemConfig()
|
|
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, billingOwnerId)
|
|
if (!willUseByok) {
|
|
await reserveUsageOrThrow(billingOwnerId, 'brainstorm_enrich')
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof QuotaExceededError) {
|
|
enrichBlocked = true
|
|
} else {
|
|
console.error('[manual-idea] reserveUsage error:', err)
|
|
}
|
|
}
|
|
|
|
// [UPDATE - SÉCURITÉ] Recherche vectorielle guest-safe (skipped when host quota exhausted)
|
|
let relatedNoteIds: string[] = []
|
|
if (!enrichBlocked) {
|
|
try {
|
|
const { isGuest, publicNoteIds, aiUserId } = await resolveAiContextUserId(sessionId, session.user.id)
|
|
|
|
if (isGuest && (!publicNoteIds || publicNoteIds.length === 0)) {
|
|
// Invité sans notes publiques → skip la recherche vectorielle
|
|
relatedNoteIds = []
|
|
} else {
|
|
const embedding = await embeddingService.generateEmbedding(`${title} ${description || ''}`)
|
|
const vectorStr = embeddingService.toVectorString(embedding.embedding)
|
|
|
|
let results: any[]
|
|
if (isGuest && publicNoteIds && publicNoteIds.length > 0) {
|
|
// Invité : restreindre aux notes publiques uniquement
|
|
const idList = publicNoteIds.map(id => `'${id}'`).join(',')
|
|
results = await prisma.$queryRawUnsafe(
|
|
`SELECT n.id
|
|
FROM "NoteEmbedding" e
|
|
JOIN "Note" n ON n.id = e."noteId"
|
|
WHERE n.id IN (${idList}) AND n."trashedAt" IS NULL
|
|
ORDER BY e.embedding <=> $1::vector
|
|
LIMIT 3`,
|
|
vectorStr
|
|
) as any[]
|
|
} else {
|
|
// Hôte : accès complet
|
|
results = await prisma.$queryRawUnsafe(
|
|
`SELECT n.id
|
|
FROM "NoteEmbedding" e
|
|
JOIN "Note" n ON n.id = e."noteId"
|
|
WHERE n."userId" = $1 AND n."trashedAt" IS NULL
|
|
ORDER BY e.embedding <=> $2::vector
|
|
LIMIT 3`,
|
|
aiUserId, vectorStr
|
|
) as any[]
|
|
}
|
|
relatedNoteIds = results.map((r: any) => r.id)
|
|
}
|
|
} catch (vectorErr) {
|
|
console.error('[manual-idea] vector context search failed:', vectorErr)
|
|
}
|
|
}
|
|
|
|
if (relatedNoteIds.length > 0) {
|
|
const notes = await prisma.note.findMany({
|
|
where: { id: { in: relatedNoteIds } },
|
|
select: { id: true, title: true },
|
|
})
|
|
for (const note of notes) {
|
|
await prisma.brainstormNoteRef.create({
|
|
data: {
|
|
ideaId: idea.id,
|
|
noteId: note.id,
|
|
relation: 'extends',
|
|
explanation: `Manual idea — related to your note "${note.title}"`,
|
|
},
|
|
})
|
|
}
|
|
await prisma.brainstormIdea.update({
|
|
where: { id: idea.id },
|
|
data: { relatedNoteIds: JSON.stringify(relatedNoteIds) },
|
|
})
|
|
}
|
|
|
|
await logActivity(sessionId, 'manual_idea', session.user.id, { ideaTitle: title, ideaId: idea.id })
|
|
|
|
await captureSnapshot(sessionId, `Manual idea: ${title}`).catch(() => {})
|
|
|
|
// [UPDATE - TEMPS RÉEL] Retourner immédiatement, enrichissement IA en arrière-plan
|
|
// Le client est notifié via Socket : idea:ai_processing → idea:ai_completed | idea:ai_failed
|
|
const immediateResponse = NextResponse.json(
|
|
{ success: true, data: { ideaId: idea.id, title, status: 'ai_processing' } },
|
|
{ status: 201 }
|
|
)
|
|
|
|
// Capturer les valeurs avant la closure asynchrone (session.user peut être undefined plus tard)
|
|
const requestingUserId = session.user!.id
|
|
|
|
const enrichAsync = async () => {
|
|
if (enrichBlocked) {
|
|
await emitToSession(sessionId, 'idea:ai_failed', {
|
|
ideaId: idea.id,
|
|
reason: 'quota_exceeded',
|
|
isGuestActor,
|
|
billingOwnerId,
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Notifier le room que l'IA traite ce nœud (bordure pulsante violet)
|
|
await emitToSession(sessionId, 'idea:ai_processing', {
|
|
ideaId: idea.id,
|
|
triggeredBy: requestingUserId,
|
|
})
|
|
|
|
const config = await getSystemConfig()
|
|
const lang = locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'zh' ? 'Chinese' : locale === 'ar' ? 'Arabic' : "the user's language"
|
|
|
|
const enrichPrompt = `You are an idea enrichment assistant. Given a user's raw brainstorm idea and context, produce a JSON object with:
|
|
- "enrichedTitle": a polished, concise version of the title (max 60 chars)
|
|
- "enrichedDescription": an expanded 2-3 sentence description that develops the idea further
|
|
- "connectionToSeed": a 1-sentence explanation of how this idea connects to the seed topic
|
|
- "noveltyScore": a number 1-10 rating how novel/original this idea is
|
|
|
|
IMPORTANT: You MUST write ALL text in ${lang}.
|
|
|
|
Seed topic: "${brainstormSession.seedIdea}"
|
|
${parentIdea ? `Parent idea this responds to: "${parentIdea.title}"` : ''}
|
|
User's raw idea title: "${title}"
|
|
User's raw description: "${description || 'none provided'}"
|
|
|
|
Respond ONLY with the JSON object, no markdown.`
|
|
|
|
const { result: raw } = await runLaneWithBillingUser(
|
|
'tags',
|
|
config,
|
|
billingOwnerId,
|
|
(provider) => provider.generateText(enrichPrompt),
|
|
)
|
|
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
|
const enriched = JSON.parse(cleaned)
|
|
|
|
const enrichedTitle = enriched.enrichedTitle?.substring(0, 60) || title
|
|
const enrichedDescription = enriched.enrichedDescription || description || ''
|
|
const connectionToSeed = enriched.connectionToSeed || 'Manual idea added by participant'
|
|
const noveltyScore = enriched.noveltyScore || null
|
|
|
|
await prisma.brainstormIdea.update({
|
|
where: { id: idea.id },
|
|
data: { title: enrichedTitle, description: enrichedDescription, connectionToSeed, noveltyScore },
|
|
})
|
|
|
|
await emitToSession(sessionId, 'idea:ai_completed', {
|
|
ideaId: idea.id,
|
|
title: enrichedTitle,
|
|
description: enrichedDescription,
|
|
noveltyScore,
|
|
connectionToSeed,
|
|
})
|
|
} catch (enrichError) {
|
|
console.error('Enrichment failed, keeping raw idea:', enrichError)
|
|
// [UPDATE - TEMPS RÉEL] Notifier l'échec — le nœud reste en état dégradé
|
|
await emitToSession(sessionId, 'idea:ai_failed', { ideaId: idea.id })
|
|
}
|
|
}
|
|
|
|
// setImmediate pour ne pas bloquer la réponse HTTP
|
|
setImmediate(() => { enrichAsync() })
|
|
|
|
return immediateResponse
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json({ error: error.issues }, { status: 400 })
|
|
}
|
|
console.error('Error adding manual idea:', error)
|
|
return NextResponse.json({ error: 'Failed to add idea' }, { status: 500 })
|
|
}
|
|
}
|