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
167 lines
4.7 KiB
TypeScript
167 lines
4.7 KiB
TypeScript
import prisma from '@/lib/prisma'
|
|
|
|
export type BillingOwnerContext = {
|
|
billingOwnerId: string
|
|
isGuestActor: boolean
|
|
}
|
|
|
|
/** Resolve quota billing owner from an already-loaded session row (no extra query). */
|
|
export function billingOwnerFromSession(
|
|
sessionUserId: string,
|
|
requestingUserId: string,
|
|
): BillingOwnerContext {
|
|
if (!sessionUserId) {
|
|
throw new Error('Session has no owner — cannot resolve billing owner')
|
|
}
|
|
return {
|
|
billingOwnerId: sessionUserId,
|
|
isGuestActor: sessionUserId !== requestingUserId,
|
|
}
|
|
}
|
|
|
|
/** Host-pays: all collaborative AI usage is billed to BrainstormSession.userId (Story 3.4). */
|
|
export async function getBillingOwner(
|
|
sessionId: string,
|
|
requestingUserId: string,
|
|
): Promise<BillingOwnerContext> {
|
|
const session = await prisma.brainstormSession.findUnique({
|
|
where: { id: sessionId },
|
|
select: { userId: true },
|
|
})
|
|
|
|
if (!session) {
|
|
throw new Error('Session not found')
|
|
}
|
|
|
|
return billingOwnerFromSession(session.userId, requestingUserId)
|
|
}
|
|
|
|
export async function verifyParticipant(
|
|
sessionId: string,
|
|
userId: string,
|
|
requiredRole?: 'host' | 'editor' | 'viewer'
|
|
): Promise<{ isParticipant: boolean; role: string }> {
|
|
const participant = await prisma.brainstormParticipant.findFirst({
|
|
where: { sessionId, userId },
|
|
})
|
|
|
|
if (!participant) {
|
|
return { isParticipant: false, role: 'none' }
|
|
}
|
|
|
|
await prisma.brainstormParticipant.update({
|
|
where: { id: participant.id },
|
|
data: { lastSeenAt: new Date() },
|
|
})
|
|
|
|
if (requiredRole === 'host' && participant.role !== 'host') {
|
|
return { isParticipant: false, role: participant.role }
|
|
}
|
|
if (requiredRole === 'editor' && participant.role === 'viewer') {
|
|
return { isParticipant: false, role: participant.role }
|
|
}
|
|
|
|
return { isParticipant: true, role: participant.role }
|
|
}
|
|
|
|
export async function logActivity(
|
|
sessionId: string,
|
|
action: string,
|
|
userId?: string | null,
|
|
details?: Record<string, any>
|
|
) {
|
|
await prisma.brainstormActivity.create({
|
|
data: {
|
|
sessionId,
|
|
userId: userId || null,
|
|
action,
|
|
details: details ? JSON.stringify(details) : null,
|
|
},
|
|
})
|
|
}
|
|
|
|
// [UPDATE - SÉCURITÉ] Résoudre l'userId et le périmètre de notes autorisé pour les appels IA.
|
|
// - Hôte : accès complet à ses notes (publicNoteIds = null)
|
|
// - Invité : restreint aux contextNoteIds publics de la session (publicNoteIds = string[] | [])
|
|
export async function resolveAiContextUserId(
|
|
sessionId: string,
|
|
requestingUserId: string
|
|
): Promise<{ aiUserId: string; isGuest: boolean; publicNoteIds: string[] | null }> {
|
|
const session = await prisma.brainstormSession.findUnique({
|
|
where: { id: sessionId },
|
|
select: {
|
|
userId: true,
|
|
contextNoteIds: true,
|
|
},
|
|
})
|
|
|
|
if (!session) throw new Error('Session not found')
|
|
|
|
const isHost = session.userId === requestingUserId
|
|
if (isHost) {
|
|
return { aiUserId: requestingUserId, isGuest: false, publicNoteIds: null }
|
|
}
|
|
|
|
// Invité : on restreint aux contextNoteIds déclarés publics par l'hôte
|
|
const publicNoteIds: string[] = session.contextNoteIds
|
|
? (JSON.parse(session.contextNoteIds) as string[])
|
|
: []
|
|
|
|
return {
|
|
aiUserId: session.userId,
|
|
isGuest: true,
|
|
publicNoteIds: publicNoteIds.length > 0 ? publicNoteIds : [],
|
|
}
|
|
}
|
|
|
|
// [UPDATE - SÉCURITÉ] Sanitize les notes injectées dans un prompt IA pour un invité.
|
|
// Tronque le contenu et masque les entités nommées (Prénom Nom) avec [Person].
|
|
export function sanitizeNotesForGuest(
|
|
notes: { id: string; title: string | null; summary: string }[]
|
|
): { id: string; title: string; summary: string }[] {
|
|
const namedEntityRe = /\b[A-ZÀ-Ü][a-zà-ü]+ [A-ZÀ-Ü][a-zà-ü]+\b/g
|
|
return notes.map(n => ({
|
|
id: n.id,
|
|
title: (n.title || 'Note').replace(namedEntityRe, '[Person]'),
|
|
summary: n.summary.slice(0, 80).replace(namedEntityRe, '[Person]') + '…',
|
|
}))
|
|
}
|
|
|
|
export async function captureSnapshot(
|
|
sessionId: string,
|
|
label: string,
|
|
activityId?: string
|
|
): Promise<void> {
|
|
const ideas = await prisma.brainstormIdea.findMany({
|
|
where: { sessionId, status: 'active' },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
waveNumber: true,
|
|
positionX: true,
|
|
positionY: true,
|
|
parentIdeaId: true,
|
|
noveltyScore: true,
|
|
createdByType: true,
|
|
status: true,
|
|
},
|
|
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
|
})
|
|
|
|
const maxStep = await prisma.brainstormSnapshot.findFirst({
|
|
where: { sessionId },
|
|
orderBy: { step: 'desc' },
|
|
select: { step: true },
|
|
})
|
|
|
|
await prisma.brainstormSnapshot.create({
|
|
data: {
|
|
sessionId,
|
|
activityId: activityId || null,
|
|
step: (maxStep?.step || 0) + 1,
|
|
label,
|
|
ideaGraph: JSON.stringify(ideas),
|
|
},
|
|
})
|
|
}
|