Files
Momento/memento-note/lib/brainstorm-collab.ts
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- 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
2026-05-16 12:59:30 +00:00

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),
},
})
}