import { prisma } from '@/services/database'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; import { createSessionPermissionChecks } from '@/services/session-permissions'; import { createShareAndEventHandlers } from '@/services/session-share-events'; import { mergeSessionsByUserId, fetchTeamCollaboratorSessions, getSessionByIdGeneric, } from '@/services/session-queries'; import { getWeekBounds } from '@/lib/date-utils'; import { getEmojiScore } from '@/lib/weather-utils'; import type { ShareRole } from '@prisma/client'; export type WeatherHistoryPoint = { sessionId: string; title: string; date: Date; performance: number | null; moral: number | null; flux: number | null; valueCreation: number | null; }; const weatherInclude = { user: { select: { id: true, name: true, email: true } }, shares: { include: { user: { select: { id: true, name: true, email: true } } } }, _count: { select: { entries: true } }, }; // ============================================ // Weather Session CRUD // ============================================ export async function getWeatherSessionsByUserId(userId: string) { return mergeSessionsByUserId( (uid) => prisma.weatherSession.findMany({ where: { userId: uid }, include: weatherInclude, orderBy: { updatedAt: 'desc' }, }), (uid) => prisma.weatherSessionShare.findMany({ where: { userId: uid }, include: { session: { include: weatherInclude } }, }), userId ); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { return fetchTeamCollaboratorSessions( (teamMemberIds, uid) => prisma.weatherSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, include: weatherInclude, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, userId ); } const weatherByIdInclude = { user: { select: { id: true, name: true, email: true } }, entries: { include: { user: { select: { id: true, name: true, email: true } } }, orderBy: { createdAt: 'asc' } as const, }, shares: { include: { user: { select: { id: true, name: true, email: true } } } }, }; export async function getWeatherSessionById(sessionId: string, userId: string) { return getSessionByIdGeneric( sessionId, userId, (sid, uid) => prisma.weatherSession.findFirst({ where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] }, include: weatherByIdInclude, }), (sid) => prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude }) ); } const weatherPermissions = createSessionPermissionChecks(prisma.weatherSession); const weatherShareEvents = createShareAndEventHandlers< 'ENTRY_CREATED' | 'ENTRY_UPDATED' | 'ENTRY_DELETED' | 'SESSION_UPDATED' >( prisma.weatherSession, prisma.weatherSessionShare, prisma.weatherSessionEvent, weatherPermissions.canAccess ); export const canAccessWeatherSession = weatherPermissions.canAccess; export const canEditWeatherSession = weatherPermissions.canEdit; export const canDeleteWeatherSession = weatherPermissions.canDelete; export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) { return prisma.weatherSession.create({ data: { ...data, date: data.date || new Date(), userId, }, include: { entries: { include: { user: { select: { id: true, name: true, email: true } }, }, }, }, }); } export async function updateWeatherSession( sessionId: string, userId: string, data: { title?: string; date?: Date } ) { if (!(await canEditWeatherSession(sessionId, userId))) { return { count: 0 }; } return prisma.weatherSession.updateMany({ where: { id: sessionId }, data, }); } export async function deleteWeatherSession(sessionId: string, userId: string) { if (!(await canDeleteWeatherSession(sessionId, userId))) { return { count: 0 }; } return prisma.weatherSession.deleteMany({ where: { id: sessionId }, }); } // ============================================ // Weather Entry CRUD // ============================================ export async function getWeatherEntry(sessionId: string, userId: string) { return prisma.weatherEntry.findUnique({ where: { sessionId_userId: { sessionId, userId }, }, include: { user: { select: { id: true, name: true, email: true } }, }, }); } export async function createOrUpdateWeatherEntry( sessionId: string, userId: string, data: { performanceEmoji?: string | null; moralEmoji?: string | null; fluxEmoji?: string | null; valueCreationEmoji?: string | null; notes?: string | null; } ) { return prisma.weatherEntry.upsert({ where: { sessionId_userId: { sessionId, userId }, }, update: data, create: { sessionId, userId, ...data, }, include: { user: { select: { id: true, name: true, email: true } }, }, }); } export async function deleteWeatherEntry(sessionId: string, userId: string) { return prisma.weatherEntry.deleteMany({ where: { sessionId, userId }, }); } // Returns the most recent WeatherEntry per userId from any session BEFORE sessionDate, // excluding the current session. Returned as a map userId → entry. export async function getPreviousWeatherEntriesForUsers( excludeSessionId: string, sessionDate: Date, userIds: string[] ): Promise< Map< string, { performanceEmoji: string | null; moralEmoji: string | null; fluxEmoji: string | null; valueCreationEmoji: string | null; } > > { if (userIds.length === 0) return new Map(); const entries = await prisma.weatherEntry.findMany({ where: { userId: { in: userIds }, sessionId: { not: excludeSessionId }, session: { date: { lt: sessionDate } }, }, select: { userId: true, performanceEmoji: true, moralEmoji: true, fluxEmoji: true, valueCreationEmoji: true, session: { select: { date: true } }, }, }); // Sort by session.date desc (Prisma orderBy on relation is unreliable with SQLite) entries.sort((a, b) => { const dateA = a.session.date.getTime(); const dateB = b.session.date.getTime(); if (dateB !== dateA) return dateB - dateA; // most recent first return a.userId.localeCompare(b.userId); }); // For each user, use the most recent previous value PER AXIS (fallback if latest session has null) const map = new Map< string, { performanceEmoji: string | null; moralEmoji: string | null; fluxEmoji: string | null; valueCreationEmoji: string | null; } >(); for (const entry of entries) { const existing = map.get(entry.userId); const base = existing ?? { performanceEmoji: null as string | null, moralEmoji: null as string | null, fluxEmoji: null as string | null, valueCreationEmoji: null as string | null, }; if (!existing) map.set(entry.userId, base); if (base.performanceEmoji == null && entry.performanceEmoji != null) base.performanceEmoji = entry.performanceEmoji; if (base.moralEmoji == null && entry.moralEmoji != null) base.moralEmoji = entry.moralEmoji; if (base.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji; if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null) base.valueCreationEmoji = entry.valueCreationEmoji; } return map; } // ============================================ // Session Sharing // ============================================ export const shareWeatherSession = weatherShareEvents.share; export async function shareWeatherSessionToTeam( sessionId: string, ownerId: string, teamId: string, role: ShareRole = 'EDITOR' ) { // Verify owner const session = await prisma.weatherSession.findFirst({ where: { id: sessionId, userId: ownerId }, }); if (!session) { throw new Error('Session not found or not owned'); } // Max 1 météo par équipe par semaine const teamMembers = await prisma.teamMember.findMany({ where: { teamId }, select: { userId: true }, }); const teamMemberIds = teamMembers.map((tm) => tm.userId); if (teamMemberIds.length > 0) { const { start: weekStart, end: weekEnd } = getWeekBounds(session.date); const existingCount = await prisma.weatherSession.count({ where: { id: { not: sessionId }, date: { gte: weekStart, lte: weekEnd }, shares: { some: { userId: { in: teamMemberIds } }, }, }, }); if (existingCount > 0) { throw new Error('Cette équipe a déjà une météo pour cette semaine'); } } // Get team members (full) const teamMembersFull = await prisma.teamMember.findMany({ where: { teamId }, include: { user: { select: { id: true, name: true, email: true } }, }, }); if (teamMembersFull.length === 0) { throw new Error('Team has no members'); } // Share with all team members (except owner) const shares = await Promise.all( teamMembersFull .filter((tm) => tm.userId !== ownerId) // Don't share with yourself .map((tm) => prisma.weatherSessionShare.upsert({ where: { sessionId_userId: { sessionId, userId: tm.userId }, }, update: { role }, create: { sessionId, userId: tm.userId, role, }, include: { user: { select: { id: true, name: true, email: true } }, }, }) ) ); return shares; } export const removeWeatherShare = weatherShareEvents.removeShare; export const getWeatherSessionShares = weatherShareEvents.getShares; // ============================================ // Session Events (for real-time sync) // ============================================ export type WeatherSessionEventType = | 'ENTRY_CREATED' | 'ENTRY_UPDATED' | 'ENTRY_DELETED' | 'SESSION_UPDATED'; export const createWeatherSessionEvent = weatherShareEvents.createEvent; export const getWeatherSessionEvents = weatherShareEvents.getEvents; export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp; // ============================================ // Weather History (for trend chart) // ============================================ function avgScore(emojis: (string | null)[]): number | null { const scores = emojis.map(getEmojiScore).filter((s): s is number => s !== null); if (scores.length === 0) return null; return scores.reduce((sum, s) => sum + s, 0) / scores.length; } export async function getWeatherSessionsHistory(userId: string): Promise { const entrySelect = { performanceEmoji: true, moralEmoji: true, fluxEmoji: true, valueCreationEmoji: true, } as const; const [ownSessions, sharedRaw] = await Promise.all([ prisma.weatherSession.findMany({ where: { userId }, select: { id: true, title: true, date: true, entries: { select: entrySelect } }, }), prisma.weatherSessionShare.findMany({ where: { userId }, select: { session: { select: { id: true, title: true, date: true, entries: { select: entrySelect } }, }, }, }), ]); const seen = new Set(); const all: { id: string; title: string; date: Date; entries: typeof ownSessions[0]['entries'] }[] = []; for (const s of [...ownSessions, ...sharedRaw.map((r) => r.session)]) { if (!seen.has(s.id)) { seen.add(s.id); all.push(s); } } all.sort((a, b) => a.date.getTime() - b.date.getTime()); return all.map((s) => ({ sessionId: s.id, title: s.title, date: s.date, performance: avgScore(s.entries.map((e) => e.performanceEmoji)), moral: avgScore(s.entries.map((e) => e.moralEmoji)), flux: avgScore(s.entries.map((e) => e.fluxEmoji)), valueCreation: avgScore(s.entries.map((e) => e.valueCreationEmoji)), })); }