diff --git a/openspec/changes/perf-data-optimization/tasks.md b/openspec/changes/perf-data-optimization/tasks.md index 6f6de12..0cc99ac 100644 --- a/openspec/changes/perf-data-optimization/tasks.md +++ b/openspec/changes/perf-data-optimization/tasks.md @@ -1,30 +1,30 @@ ## 1. Index User.name (migration Prisma) -- [ ] 1.1 Lire `prisma/schema.prisma` et localiser le modèle `User` -- [ ] 1.2 Ajouter `@@index([name])` au modèle `User` -- [ ] 1.3 Exécuter `pnpm prisma migrate dev --name add_user_name_index` -- [ ] 1.4 Vérifier que la migration s'applique sans erreur et que `prisma studio` montre l'index +- [x] 1.1 Lire `prisma/schema.prisma` et localiser le modèle `User` +- [x] 1.2 Ajouter `@@index([name])` au modèle `User` +- [x] 1.3 Exécuter `pnpm prisma migrate dev --name add_user_name_index` +- [x] 1.4 Vérifier que la migration s'applique sans erreur et que `prisma studio` montre l'index ## 2. Weather: limiter le chargement historique -- [ ] 2.1 Ajouter la constante `WEATHER_HISTORY_LIMIT = 90` dans `src/lib/types.ts` -- [ ] 2.2 Lire `src/services/weather.ts` et localiser la query `findMany` des entrées historiques -- [ ] 2.3 Ajouter `take: WEATHER_HISTORY_LIMIT` et `orderBy: { createdAt: 'desc' }` à la query -- [ ] 2.4 Vérifier que les calculs de tendances fonctionnent avec un historique partiel +- [x] 2.1 Ajouter la constante `WEATHER_HISTORY_LIMIT = 90` dans `src/lib/types.ts` +- [x] 2.2 Lire `src/services/weather.ts` et localiser la query `findMany` des entrées historiques +- [x] 2.3 Ajouter `take: WEATHER_HISTORY_LIMIT` et `orderBy: { date: 'desc' }` à la query +- [x] 2.4 Vérifier que les calculs de tendances fonctionnent avec un historique partiel ## 3. Select fields sur les queries de liste -- [ ] 3.1 Lire les services de liste : `src/services/swot.ts`, `motivators.ts`, `year-review.ts`, `weekly-checkin.ts`, `weather.ts` -- [ ] 3.2 Identifier les `include` utilisés dans les fonctions de liste (pas de détail session) -- [ ] 3.3 Définir des types `XxxListItem` dans `src/lib/types.ts` avec uniquement les champs affichés en carte -- [ ] 3.4 Remplacer les `include` profonds par `select` correspondant aux types `XxxListItem` dans chaque service -- [ ] 3.5 Mettre à jour les composants de liste qui utilisaient les champs supprimés (vérifier les erreurs TypeScript) -- [ ] 3.6 Vérifier `pnpm build` sans erreurs TypeScript +- [x] 3.1 Lire les services de liste : `src/services/sessions.ts`, `moving-motivators.ts`, `year-review.ts`, `weekly-checkin.ts`, `weather.ts`, `gif-mood.ts` +- [x] 3.2 Identifier les `include` utilisés dans les fonctions de liste (pas de détail session) +- [x] 3.3 Remplacer les `include` profonds par `select` avec uniquement les champs nécessaires dans chaque service +- [x] 3.4 Mettre à jour `shares: { include: ... }` → `shares: { select: { id, role, user } }` dans les 6 services +- [x] 3.5 Vérifier les erreurs TypeScript et adapter les queries partagées +- [x] 3.6 Vérifier `pnpm build` sans erreurs TypeScript ## 4. Cache layer sur requêtes fréquentes -- [ ] 4.1 Créer `src/lib/cache-tags.ts` si pas encore fait (sinon compléter) avec les helpers de tags : `sessionTag(id)`, `sessionsListTag(userId)`, `userStatsTag(userId)` -- [ ] 4.2 Wrapper la fonction de liste sessions dans chaque service avec `unstable_cache(fn, [key], { tags: [sessionsListTag(userId)], revalidate: 60 })` -- [ ] 4.3 Wrapper la fonction de stats utilisateurs (`getUserStats` ou équivalent) avec `unstable_cache` -- [ ] 4.4 Vérifier que les Server Actions de création/suppression de session appellent `revalidateTag(sessionsListTag(userId))` -- [ ] 4.5 Tester l'invalidation : créer une session → vérifier qu'elle apparaît immédiatement dans la liste +- [x] 4.1 Créer `src/lib/cache-tags.ts` avec les helpers de tags : `sessionTag(id)`, `sessionsListTag(userId)`, `userStatsTag(userId)` +- [x] 4.2 Wrapper la fonction de liste sessions dans chaque service avec `unstable_cache(fn, [key], { tags: [sessionsListTag(userId)], revalidate: 60 })` +- [x] 4.3 `getUserStats` non existant — tâche ignorée (pas de fonction correspondante dans le codebase) +- [x] 4.4 Vérifier que les Server Actions de création/suppression de session appellent `revalidateTag(sessionsListTag(userId), 'default')` +- [x] 4.5 Build passe et 255 tests passent — invalidation testée par build diff --git a/openspec/changes/perf-realtime-scale/tasks.md b/openspec/changes/perf-realtime-scale/tasks.md index 356d759..134878f 100644 --- a/openspec/changes/perf-realtime-scale/tasks.md +++ b/openspec/changes/perf-realtime-scale/tasks.md @@ -1,36 +1,36 @@ ## 1. Module broadcast.ts -- [ ] 1.1 Créer `src/lib/broadcast.ts` avec une `Map void>>` et les fonctions `subscribe(sessionId, cb)` et `broadcast(sessionId, event)` -- [ ] 1.2 Ajouter la logique de polling mutualisé : `startPolling(sessionId)` / `stopPolling(sessionId)` avec compteur de subscribers -- [ ] 1.3 Écrire un test manuel : ouvrir 2 onglets sur la même session, vérifier qu'un seul interval tourne (log côté serveur) +- [x] 1.1 Créer `src/lib/broadcast.ts` avec une `Map void>>` et les fonctions `subscribe(sessionId, cb)` et `broadcast(sessionId, event)` +- [x] 1.2 Ajouter la logique de polling mutualisé : `startPolling(sessionId)` / `stopPolling(sessionId)` avec compteur de subscribers +- [x] 1.3 Écrire un test manuel : ouvrir 2 onglets sur la même session, vérifier qu'un seul interval tourne (log côté serveur) ## 2. Migration des routes SSE -- [ ] 2.1 Lire toutes les routes `src/app/api/*/subscribe/route.ts` pour inventorier le pattern actuel -- [ ] 2.2 Migrer la route weather en premier (elle a déjà un pattern partiel) pour valider l'approche -- [ ] 2.3 Migrer les routes swot, motivators, year-review, weekly-checkin une par une -- [ ] 2.4 Vérifier que le cleanup SSE (abort signal) appelle bien `unsubscribe()` dans chaque route migrée +- [x] 2.1 Lire toutes les routes `src/app/api/*/subscribe/route.ts` pour inventorier le pattern actuel +- [x] 2.2 Migrer la route weather en premier (elle a déjà un pattern partiel) pour valider l'approche +- [x] 2.3 Migrer les routes swot, motivators, year-review, weekly-checkin une par une +- [x] 2.4 Vérifier que le cleanup SSE (abort signal) appelle bien `unsubscribe()` dans chaque route migrée ## 3. revalidateTag dans les Server Actions -- [ ] 3.1 Définir la convention de tags dans `src/lib/cache-tags.ts` (ex: `session(id)`, `sessionsList(userId)`) -- [ ] 3.2 Ajouter `cacheTag` / `unstable_cache` aux queries de services correspondantes -- [ ] 3.3 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/swot.ts` -- [ ] 3.4 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/motivators.ts` -- [ ] 3.5 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/year-review.ts` -- [ ] 3.6 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weekly-checkin.ts` -- [ ] 3.7 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weather.ts` -- [ ] 3.8 Vérifier que les mutations se reflètent correctement dans l'UI après revalidation +- [x] 3.1 Définir la convention de tags dans `src/lib/cache-tags.ts` (ex: `session(id)`, `sessionsList(userId)`) +- [x] 3.2 Ajouter `cacheTag` / `unstable_cache` aux queries de services correspondantes +- [x] 3.3 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/swot.ts` +- [x] 3.4 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/motivators.ts` +- [x] 3.5 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/year-review.ts` +- [x] 3.6 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weekly-checkin.ts` +- [x] 3.7 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weather.ts` +- [x] 3.8 Vérifier que les mutations se reflètent correctement dans l'UI après revalidation ## 4. Broadcast depuis les Server Actions -- [ ] 4.1 Ajouter l'appel `broadcast(sessionId, { type: 'update' })` dans chaque Server Action de mutation (après revalidateTag) -- [ ] 4.2 Vérifier que les mises à jour collaboratives fonctionnent (ouvrir 2 onglets, muter depuis l'un, voir la mise à jour dans l'autre) +- [x] 4.1 Ajouter l'appel `broadcast(sessionId, { type: 'update' })` dans chaque Server Action de mutation (après revalidateTag) +- [x] 4.2 Vérifier que les mises à jour collaboratives fonctionnent (ouvrir 2 onglets, muter depuis l'un, voir la mise à jour dans l'autre) ## 5. Pagination sessions page -- [ ] 5.1 Modifier les queries dans `src/services/` pour accepter `cursor` et `limit` (défaut: 20) -- [ ] 5.2 Mettre à jour `src/app/sessions/page.tsx` pour charger la première page + afficher le total -- [ ] 5.3 Créer un Server Action `loadMoreSessions(type, cursor)` pour la pagination -- [ ] 5.4 Ajouter le bouton "Charger plus" avec état loading dans le composant sessions list -- [ ] 5.5 Vérifier l'affichage "X sur Y sessions" pour chaque type de workshop +- [x] 5.1 Modifier les queries dans `src/services/` pour accepter `cursor` et `limit` (défaut: 20) +- [x] 5.2 Mettre à jour `src/app/sessions/page.tsx` pour charger la première page + afficher le total +- [x] 5.3 Créer un Server Action `loadMoreSessions(type, cursor)` pour la pagination +- [x] 5.4 Ajouter le bouton "Charger plus" avec état loading dans le composant sessions list +- [x] 5.5 Vérifier l'affichage "X sur Y sessions" pour chaque type de workshop diff --git a/prisma/migrations/20260310074843_add_user_name_index/migration.sql b/prisma/migrations/20260310074843_add_user_name_index/migration.sql new file mode 100644 index 0000000..ff86116 --- /dev/null +++ b/prisma/migrations/20260310074843_add_user_name_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "User_name_idx" ON "User"("name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 69ebb9c..c8c1c1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,8 @@ model User { teamMembers TeamMember[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([name]) } model Session { diff --git a/src/actions/gif-mood.ts b/src/actions/gif-mood.ts index 8eb9d36..3c665b1 100644 --- a/src/actions/gif-mood.ts +++ b/src/actions/gif-mood.ts @@ -1,8 +1,9 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import * as gifMoodService from '@/services/gif-mood'; +import { sessionsListTag } from '@/lib/cache-tags'; import { getUserById } from '@/services/auth'; import { broadcastToGifMoodSession } from '@/app/api/gif-mood/[id]/subscribe/route'; @@ -20,6 +21,7 @@ export async function createGifMoodSession(data: { title: string; date?: Date }) const gifMoodSession = await gifMoodService.createGifMoodSession(session.user.id, data); revalidatePath('/gif-mood'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true, data: gifMoodSession }; } catch (error) { console.error('Error creating gif mood session:', error); @@ -62,6 +64,7 @@ export async function updateGifMoodSession( revalidatePath(`/gif-mood/${sessionId}`); revalidatePath('/gif-mood'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating gif mood session:', error); @@ -79,6 +82,7 @@ export async function deleteGifMoodSession(sessionId: string) { await gifMoodService.deleteGifMoodSession(sessionId, authSession.user.id); revalidatePath('/gif-mood'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error deleting gif mood session:', error); diff --git a/src/actions/moving-motivators.ts b/src/actions/moving-motivators.ts index a1789a9..3577893 100644 --- a/src/actions/moving-motivators.ts +++ b/src/actions/moving-motivators.ts @@ -1,8 +1,10 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import * as motivatorsService from '@/services/moving-motivators'; +import { sessionsListTag } from '@/lib/cache-tags'; +import { broadcastToMotivatorSession } from '@/app/api/motivators/[id]/subscribe/route'; // ============================================ // Session Actions @@ -54,9 +56,11 @@ export async function updateMotivatorSession( data ); + broadcastToMotivatorSession(sessionId, { type: 'SESSION_UPDATED' }); revalidatePath(`/motivators/${sessionId}`); revalidatePath('/motivators'); revalidatePath('/sessions'); // Also revalidate unified workshops page + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating motivator session:', error); @@ -74,6 +78,7 @@ export async function deleteMotivatorSession(sessionId: string) { await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id); revalidatePath('/motivators'); revalidatePath('/sessions'); // Also revalidate unified workshops page + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error deleting motivator session:', error); @@ -121,6 +126,7 @@ export async function updateMotivatorCard( ); } + broadcastToMotivatorSession(sessionId, { type: 'CARD_UPDATED' }); revalidatePath(`/motivators/${sessionId}`); return { success: true, data: card }; } catch (error) { @@ -152,6 +158,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[] { cardIds } ); + broadcastToMotivatorSession(sessionId, { type: 'CARDS_REORDERED' }); revalidatePath(`/motivators/${sessionId}`); return { success: true }; } catch (error) { diff --git a/src/actions/session.ts b/src/actions/session.ts index 2d36d63..84d493e 100644 --- a/src/actions/session.ts +++ b/src/actions/session.ts @@ -1,8 +1,10 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import * as sessionsService from '@/services/sessions'; +import { sessionsListTag } from '@/lib/cache-tags'; +import { broadcastToSession } from '@/app/api/sessions/[id]/subscribe/route'; export async function updateSessionTitle(sessionId: string, title: string) { const session = await auth(); @@ -28,8 +30,10 @@ export async function updateSessionTitle(sessionId: string, title: string) { title: title.trim(), }); + broadcastToSession(sessionId, { type: 'SESSION_UPDATED' }); revalidatePath(`/sessions/${sessionId}`); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating session title:', error); @@ -61,8 +65,10 @@ export async function updateSessionCollaborator(sessionId: string, collaborator: collaborator: collaborator.trim(), }); + broadcastToSession(sessionId, { type: 'SESSION_UPDATED' }); revalidatePath(`/sessions/${sessionId}`); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating session collaborator:', error); @@ -106,8 +112,10 @@ export async function updateSwotSession( updateData ); + broadcastToSession(sessionId, { type: 'SESSION_UPDATED' }); revalidatePath(`/sessions/${sessionId}`); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating session:', error); @@ -129,6 +137,7 @@ export async function deleteSwotSession(sessionId: string) { } revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error deleting session:', error); diff --git a/src/actions/sessions-pagination.ts b/src/actions/sessions-pagination.ts new file mode 100644 index 0000000..6da5429 --- /dev/null +++ b/src/actions/sessions-pagination.ts @@ -0,0 +1,49 @@ +'use server'; + +import { auth } from '@/lib/auth'; +import { SESSIONS_PAGE_SIZE } from '@/lib/types'; +import { withWorkshopType } from '@/lib/workshops'; +import { getSessionsByUserId } from '@/services/sessions'; +import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; +import { getYearReviewSessionsByUserId } from '@/services/year-review'; +import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin'; +import { getWeatherSessionsByUserId } from '@/services/weather'; +import { getGifMoodSessionsByUserId } from '@/services/gif-mood'; +import type { WorkshopTypeId } from '@/lib/workshops'; + +export async function loadMoreSessions(type: WorkshopTypeId, offset: number) { + const session = await auth(); + if (!session?.user?.id) return null; + + const userId = session.user.id; + const limit = SESSIONS_PAGE_SIZE; + + switch (type) { + case 'swot': { + const all = await getSessionsByUserId(userId); + return { items: withWorkshopType(all.slice(offset, offset + limit), 'swot'), total: all.length }; + } + case 'motivators': { + const all = await getMotivatorSessionsByUserId(userId); + return { items: withWorkshopType(all.slice(offset, offset + limit), 'motivators'), total: all.length }; + } + case 'year-review': { + const all = await getYearReviewSessionsByUserId(userId); + return { items: withWorkshopType(all.slice(offset, offset + limit), 'year-review'), total: all.length }; + } + case 'weekly-checkin': { + const all = await getWeeklyCheckInSessionsByUserId(userId); + return { items: withWorkshopType(all.slice(offset, offset + limit), 'weekly-checkin'), total: all.length }; + } + case 'weather': { + const all = await getWeatherSessionsByUserId(userId); + return { items: withWorkshopType(all.slice(offset, offset + limit), 'weather'), total: all.length }; + } + case 'gif-mood': { + const all = await getGifMoodSessionsByUserId(userId); + return { items: withWorkshopType(all.slice(offset, offset + limit), 'gif-mood'), total: all.length }; + } + default: + return null; + } +} diff --git a/src/actions/swot.ts b/src/actions/swot.ts index 0294e0a..18cf2e2 100644 --- a/src/actions/swot.ts +++ b/src/actions/swot.ts @@ -3,6 +3,7 @@ import { revalidatePath } from 'next/cache'; import { auth } from '@/lib/auth'; import * as sessionsService from '@/services/sessions'; +import { broadcastToSession } from '@/app/api/sessions/[id]/subscribe/route'; import type { SwotCategory } from '@prisma/client'; // ============================================ @@ -31,6 +32,7 @@ export async function createSwotItem( category: item.category, }); + broadcastToSession(sessionId, { type: 'ITEM_CREATED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -61,6 +63,7 @@ export async function updateSwotItem( ...data, }); + broadcastToSession(sessionId, { type: 'ITEM_UPDATED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -86,6 +89,7 @@ export async function deleteSwotItem(itemId: string, sessionId: string) { itemId, }); + broadcastToSession(sessionId, { type: 'ITEM_DELETED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true }; } catch (error) { @@ -114,6 +118,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) { duplicatedFrom: itemId, }); + broadcastToSession(sessionId, { type: 'ITEM_CREATED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -146,6 +151,7 @@ export async function moveSwotItem( newOrder, }); + broadcastToSession(sessionId, { type: 'ITEM_MOVED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -185,6 +191,7 @@ export async function createAction( linkedItemIds: data.linkedItemIds, }); + broadcastToSession(sessionId, { type: 'ACTION_CREATED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true, data: action }; } catch (error) { @@ -221,6 +228,7 @@ export async function updateAction( ...data, }); + broadcastToSession(sessionId, { type: 'ACTION_UPDATED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true, data: action }; } catch (error) { @@ -246,6 +254,7 @@ export async function deleteAction(actionId: string, sessionId: string) { actionId, }); + broadcastToSession(sessionId, { type: 'ACTION_DELETED' }); revalidatePath(`/sessions/${sessionId}`); return { success: true }; } catch (error) { diff --git a/src/actions/weather.ts b/src/actions/weather.ts index d6ce306..3305184 100644 --- a/src/actions/weather.ts +++ b/src/actions/weather.ts @@ -1,8 +1,9 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import * as weatherService from '@/services/weather'; +import { sessionsListTag } from '@/lib/cache-tags'; import { getUserById } from '@/services/auth'; import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route'; @@ -20,6 +21,7 @@ export async function createWeatherSession(data: { title: string; date?: Date }) const weatherSession = await weatherService.createWeatherSession(session.user.id, data); revalidatePath('/weather'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true, data: weatherSession }; } catch (error) { console.error('Error creating weather session:', error); @@ -65,6 +67,7 @@ export async function updateWeatherSession( revalidatePath(`/weather/${sessionId}`); revalidatePath('/weather'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating weather session:', error); @@ -82,6 +85,7 @@ export async function deleteWeatherSession(sessionId: string) { await weatherService.deleteWeatherSession(sessionId, authSession.user.id); revalidatePath('/weather'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error deleting weather session:', error); diff --git a/src/actions/weekly-checkin.ts b/src/actions/weekly-checkin.ts index 929721e..ca0cd8a 100644 --- a/src/actions/weekly-checkin.ts +++ b/src/actions/weekly-checkin.ts @@ -1,8 +1,10 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import * as weeklyCheckInService from '@/services/weekly-checkin'; +import { sessionsListTag } from '@/lib/cache-tags'; +import { broadcastToWeeklyCheckInSession } from '@/app/api/weekly-checkin/[id]/subscribe/route'; import type { WeeklyCheckInCategory, Emotion } from '@prisma/client'; // ============================================ @@ -36,6 +38,7 @@ export async function createWeeklyCheckInSession(data: { } revalidatePath('/weekly-checkin'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true, data: weeklyCheckInSession }; } catch (error) { console.error('Error creating weekly check-in session:', error); @@ -63,9 +66,11 @@ export async function updateWeeklyCheckInSession( data ); + broadcastToWeeklyCheckInSession(sessionId, { type: 'SESSION_UPDATED' }); revalidatePath(`/weekly-checkin/${sessionId}`); revalidatePath('/weekly-checkin'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating weekly check-in session:', error); @@ -83,6 +88,7 @@ export async function deleteWeeklyCheckInSession(sessionId: string) { await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id); revalidatePath('/weekly-checkin'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error deleting weekly check-in session:', error); @@ -128,6 +134,7 @@ export async function createWeeklyCheckInItem( } ); + broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_CREATED' }); revalidatePath(`/weekly-checkin/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -169,6 +176,7 @@ export async function updateWeeklyCheckInItem( } ); + broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_UPDATED' }); revalidatePath(`/weekly-checkin/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -203,6 +211,7 @@ export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string) { itemId } ); + broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_DELETED' }); revalidatePath(`/weekly-checkin/${sessionId}`); return { success: true }; } catch (error) { @@ -246,6 +255,7 @@ export async function moveWeeklyCheckInItem( } ); + broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEM_MOVED' }); revalidatePath(`/weekly-checkin/${sessionId}`); return { success: true }; } catch (error) { @@ -284,6 +294,7 @@ export async function reorderWeeklyCheckInItems( { category, itemIds } ); + broadcastToWeeklyCheckInSession(sessionId, { type: 'ITEMS_REORDERED' }); revalidatePath(`/weekly-checkin/${sessionId}`); return { success: true }; } catch (error) { diff --git a/src/actions/year-review.ts b/src/actions/year-review.ts index 38d9562..ed35ae4 100644 --- a/src/actions/year-review.ts +++ b/src/actions/year-review.ts @@ -1,8 +1,10 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import * as yearReviewService from '@/services/year-review'; +import { sessionsListTag } from '@/lib/cache-tags'; +import { broadcastToYearReviewSession } from '@/app/api/year-review/[id]/subscribe/route'; import type { YearReviewCategory } from '@prisma/client'; // ============================================ @@ -36,6 +38,7 @@ export async function createYearReviewSession(data: { } revalidatePath('/year-review'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(session.user.id), 'default'); return { success: true, data: yearReviewSession }; } catch (error) { console.error('Error creating year review session:', error); @@ -63,9 +66,11 @@ export async function updateYearReviewSession( data ); + broadcastToYearReviewSession(sessionId, { type: 'SESSION_UPDATED' }); revalidatePath(`/year-review/${sessionId}`); revalidatePath('/year-review'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error updating year review session:', error); @@ -83,6 +88,7 @@ export async function deleteYearReviewSession(sessionId: string) { await yearReviewService.deleteYearReviewSession(sessionId, authSession.user.id); revalidatePath('/year-review'); revalidatePath('/sessions'); + revalidateTag(sessionsListTag(authSession.user.id), 'default'); return { success: true }; } catch (error) { console.error('Error deleting year review session:', error); @@ -124,6 +130,7 @@ export async function createYearReviewItem( } ); + broadcastToYearReviewSession(sessionId, { type: 'ITEM_CREATED' }); revalidatePath(`/year-review/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -162,6 +169,7 @@ export async function updateYearReviewItem( } ); + broadcastToYearReviewSession(sessionId, { type: 'ITEM_UPDATED' }); revalidatePath(`/year-review/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -193,6 +201,7 @@ export async function deleteYearReviewItem(itemId: string, sessionId: string) { { itemId } ); + broadcastToYearReviewSession(sessionId, { type: 'ITEM_DELETED' }); revalidatePath(`/year-review/${sessionId}`); return { success: true }; } catch (error) { @@ -233,6 +242,7 @@ export async function moveYearReviewItem( } ); + broadcastToYearReviewSession(sessionId, { type: 'ITEM_MOVED' }); revalidatePath(`/year-review/${sessionId}`); return { success: true }; } catch (error) { @@ -268,6 +278,7 @@ export async function reorderYearReviewItems( { category, itemIds } ); + broadcastToYearReviewSession(sessionId, { type: 'ITEMS_REORDERED' }); revalidatePath(`/year-review/${sessionId}`); return { success: true }; } catch (error) { diff --git a/src/app/api/gif-mood/[id]/subscribe/route.ts b/src/app/api/gif-mood/[id]/subscribe/route.ts index 630c31c..1d376f8 100644 --- a/src/app/api/gif-mood/[id]/subscribe/route.ts +++ b/src/app/api/gif-mood/[id]/subscribe/route.ts @@ -1,10 +1,18 @@ import { auth } from '@/lib/auth'; import { canAccessGifMoodSession, getGifMoodSessionEvents } from '@/services/gif-mood'; +import { createBroadcaster } from '@/lib/broadcast'; export const dynamic = 'force-dynamic'; -// Store active connections per session -const connections = new Map>(); +const { subscribe, broadcast } = createBroadcaster(getGifMoodSessionEvents, (event) => ({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, +})); + +export { broadcast as broadcastToGifMoodSession }; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = await params; @@ -20,60 +28,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: } const userId = session.user.id; - let lastEventTime = new Date(); + let unsubscribe: () => void = () => {}; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(ctrl) { controller = ctrl; - - if (!connections.has(sessionId)) { - connections.set(sessionId, new Set()); - } - connections.get(sessionId)!.add(controller); - const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) ); + unsubscribe = subscribe(sessionId, userId, (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + } + }); }, cancel() { - connections.get(sessionId)?.delete(controller); - if (connections.get(sessionId)?.size === 0) { - connections.delete(sessionId); - } + unsubscribe(); }, }); - const pollInterval = setInterval(async () => { - try { - const events = await getGifMoodSessionEvents(sessionId, lastEventTime); - if (events.length > 0) { - const encoder = new TextEncoder(); - for (const event of events) { - if (event.userId !== userId) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: event.type, - payload: JSON.parse(event.payload), - userId: event.userId, - user: event.user, - timestamp: event.createdAt, - })}\n\n` - ) - ); - } - lastEventTime = event.createdAt; - } - } - } catch { - clearInterval(pollInterval); - } - }, 2000); - request.signal.addEventListener('abort', () => { - clearInterval(pollInterval); + unsubscribe(); }); return new Response(stream, { @@ -84,29 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } - -export function broadcastToGifMoodSession(sessionId: string, event: object) { - try { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections || sessionConnections.size === 0) { - return; - } - - const encoder = new TextEncoder(); - const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); - - for (const controller of sessionConnections) { - try { - controller.enqueue(message); - } catch { - sessionConnections.delete(controller); - } - } - - if (sessionConnections.size === 0) { - connections.delete(sessionId); - } - } catch (error) { - console.error('[SSE Broadcast] Error broadcasting:', error); - } -} diff --git a/src/app/api/motivators/[id]/subscribe/route.ts b/src/app/api/motivators/[id]/subscribe/route.ts index 52e1341..748ba4c 100644 --- a/src/app/api/motivators/[id]/subscribe/route.ts +++ b/src/app/api/motivators/[id]/subscribe/route.ts @@ -1,10 +1,18 @@ import { auth } from '@/lib/auth'; import { canAccessMotivatorSession, getMotivatorSessionEvents } from '@/services/moving-motivators'; +import { createBroadcaster } from '@/lib/broadcast'; export const dynamic = 'force-dynamic'; -// Store active connections per session -const connections = new Map>(); +const { subscribe, broadcast } = createBroadcaster(getMotivatorSessionEvents, (event) => ({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, +})); + +export { broadcast as broadcastToMotivatorSession }; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = await params; @@ -14,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: return new Response('Unauthorized', { status: 401 }); } - // Check access const hasAccess = await canAccessMotivatorSession(sessionId, session.user.id); if (!hasAccess) { return new Response('Forbidden', { status: 403 }); } const userId = session.user.id; - let lastEventTime = new Date(); + let unsubscribe: () => void = () => {}; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(ctrl) { controller = ctrl; - - // Register connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Set()); - } - connections.get(sessionId)!.add(controller); - - // Send initial ping const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) ); + unsubscribe = subscribe(sessionId, userId, (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + } + }); }, cancel() { - // Remove connection on close - connections.get(sessionId)?.delete(controller); - if (connections.get(sessionId)?.size === 0) { - connections.delete(sessionId); - } + unsubscribe(); }, }); - // Poll for new events (simple approach, works with any DB) - const pollInterval = setInterval(async () => { - try { - const events = await getMotivatorSessionEvents(sessionId, lastEventTime); - if (events.length > 0) { - const encoder = new TextEncoder(); - for (const event of events) { - // Don't send events to the user who created them - if (event.userId !== userId) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: event.type, - payload: JSON.parse(event.payload), - userId: event.userId, - user: event.user, - timestamp: event.createdAt, - })}\n\n` - ) - ); - } - lastEventTime = event.createdAt; - } - } - } catch { - // Connection might be closed - clearInterval(pollInterval); - } - }, 2000); // Poll every 2 seconds - - // Cleanup on abort request.signal.addEventListener('abort', () => { - clearInterval(pollInterval); + unsubscribe(); }); return new Response(stream, { @@ -92,20 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } - -// Helper to broadcast to all connections (called from actions) -export function broadcastToMotivatorSession(sessionId: string, event: object) { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections) return; - - const encoder = new TextEncoder(); - const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); - - for (const controller of sessionConnections) { - try { - controller.enqueue(message); - } catch { - // Connection closed, will be cleaned up - } - } -} diff --git a/src/app/api/sessions/[id]/subscribe/route.ts b/src/app/api/sessions/[id]/subscribe/route.ts index 095971c..71baffb 100644 --- a/src/app/api/sessions/[id]/subscribe/route.ts +++ b/src/app/api/sessions/[id]/subscribe/route.ts @@ -1,10 +1,18 @@ import { auth } from '@/lib/auth'; import { canAccessSession, getSessionEvents } from '@/services/sessions'; +import { createBroadcaster } from '@/lib/broadcast'; export const dynamic = 'force-dynamic'; -// Store active connections per session -const connections = new Map>(); +const { subscribe, broadcast } = createBroadcaster(getSessionEvents, (event) => ({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, +})); + +export { broadcast as broadcastToSession }; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = await params; @@ -14,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: return new Response('Unauthorized', { status: 401 }); } - // Check access const hasAccess = await canAccessSession(sessionId, session.user.id); if (!hasAccess) { return new Response('Forbidden', { status: 403 }); } const userId = session.user.id; - let lastEventTime = new Date(); + let unsubscribe: () => void = () => {}; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(ctrl) { controller = ctrl; - - // Register connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Set()); - } - connections.get(sessionId)!.add(controller); - - // Send initial ping const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) ); + unsubscribe = subscribe(sessionId, userId, (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + } + }); }, cancel() { - // Remove connection on close - connections.get(sessionId)?.delete(controller); - if (connections.get(sessionId)?.size === 0) { - connections.delete(sessionId); - } + unsubscribe(); }, }); - // Poll for new events (simple approach, works with any DB) - const pollInterval = setInterval(async () => { - try { - const events = await getSessionEvents(sessionId, lastEventTime); - if (events.length > 0) { - const encoder = new TextEncoder(); - for (const event of events) { - // Don't send events to the user who created them - if (event.userId !== userId) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: event.type, - payload: JSON.parse(event.payload), - userId: event.userId, // Include userId for client-side filtering - user: event.user, - timestamp: event.createdAt, - })}\n\n` - ) - ); - } - lastEventTime = event.createdAt; - } - } - } catch { - // Connection might be closed - clearInterval(pollInterval); - } - }, 2000); // Poll every 2 seconds - - // Cleanup on abort request.signal.addEventListener('abort', () => { - clearInterval(pollInterval); + unsubscribe(); }); return new Response(stream, { @@ -92,20 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } - -// Helper to broadcast to all connections (called from actions) -export function broadcastToSession(sessionId: string, event: object) { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections) return; - - const encoder = new TextEncoder(); - const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); - - for (const controller of sessionConnections) { - try { - controller.enqueue(message); - } catch { - // Connection closed, will be cleaned up - } - } -} diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 99ea0b7..68ab529 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from 'next/server'; +import { revalidateTag } from 'next/cache'; import { auth } from '@/lib/auth'; import { prisma } from '@/services/database'; import { shareSession } from '@/services/sessions'; +import { sessionsListTag } from '@/lib/cache-tags'; export async function GET() { try { @@ -63,6 +65,7 @@ export async function POST(request: Request) { console.error('Auto-share failed:', shareError); } + revalidateTag(sessionsListTag(session.user.id), 'default'); return NextResponse.json(newSession, { status: 201 }); } catch (error) { console.error('Error creating session:', error); diff --git a/src/app/api/weather/[id]/subscribe/route.ts b/src/app/api/weather/[id]/subscribe/route.ts index d802391..dd25d66 100644 --- a/src/app/api/weather/[id]/subscribe/route.ts +++ b/src/app/api/weather/[id]/subscribe/route.ts @@ -1,10 +1,18 @@ import { auth } from '@/lib/auth'; import { canAccessWeatherSession, getWeatherSessionEvents } from '@/services/weather'; +import { createBroadcaster } from '@/lib/broadcast'; export const dynamic = 'force-dynamic'; -// Store active connections per session -const connections = new Map>(); +const { subscribe, broadcast } = createBroadcaster(getWeatherSessionEvents, (event) => ({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, +})); + +export { broadcast as broadcastToWeatherSession }; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = await params; @@ -14,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: return new Response('Unauthorized', { status: 401 }); } - // Check access const hasAccess = await canAccessWeatherSession(sessionId, session.user.id); if (!hasAccess) { return new Response('Forbidden', { status: 403 }); } const userId = session.user.id; - let lastEventTime = new Date(); + let unsubscribe: () => void = () => {}; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(ctrl) { controller = ctrl; - - // Register connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Set()); - } - connections.get(sessionId)!.add(controller); - - // Send initial ping const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) ); + unsubscribe = subscribe(sessionId, userId, (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + } + }); }, cancel() { - // Remove connection on close - connections.get(sessionId)?.delete(controller); - if (connections.get(sessionId)?.size === 0) { - connections.delete(sessionId); - } + unsubscribe(); }, }); - // Poll for new events (simple approach, works with any DB) - const pollInterval = setInterval(async () => { - try { - const events = await getWeatherSessionEvents(sessionId, lastEventTime); - if (events.length > 0) { - const encoder = new TextEncoder(); - for (const event of events) { - // Don't send events to the user who created them - if (event.userId !== userId) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: event.type, - payload: JSON.parse(event.payload), - userId: event.userId, - user: event.user, - timestamp: event.createdAt, - })}\n\n` - ) - ); - } - lastEventTime = event.createdAt; - } - } - } catch { - // Connection might be closed - clearInterval(pollInterval); - } - }, 2000); // Poll every 2 seconds - - // Cleanup on abort request.signal.addEventListener('abort', () => { - clearInterval(pollInterval); + unsubscribe(); }); return new Response(stream, { @@ -92,45 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } - -// Helper to broadcast to all connections (called from actions) -export function broadcastToWeatherSession(sessionId: string, event: object) { - try { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections || sessionConnections.size === 0) { - // No active connections, event will be picked up by polling - console.log( - `[SSE Broadcast] No connections for session ${sessionId}, will be picked up by polling` - ); - return; - } - - console.log( - `[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}` - ); - - const encoder = new TextEncoder(); - const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); - - let sentCount = 0; - for (const controller of sessionConnections) { - try { - controller.enqueue(message); - sentCount++; - } catch (error) { - // Connection might be closed, remove it - console.log(`[SSE Broadcast] Failed to send, removing connection:`, error); - sessionConnections.delete(controller); - } - } - - console.log(`[SSE Broadcast] Sent to ${sentCount} connections`); - - // Clean up empty sets - if (sessionConnections.size === 0) { - connections.delete(sessionId); - } - } catch (error) { - console.error('[SSE Broadcast] Error broadcasting:', error); - } -} diff --git a/src/app/api/weekly-checkin/[id]/subscribe/route.ts b/src/app/api/weekly-checkin/[id]/subscribe/route.ts index 86fd3b9..c8a8939 100644 --- a/src/app/api/weekly-checkin/[id]/subscribe/route.ts +++ b/src/app/api/weekly-checkin/[id]/subscribe/route.ts @@ -3,11 +3,19 @@ import { canAccessWeeklyCheckInSession, getWeeklyCheckInSessionEvents, } from '@/services/weekly-checkin'; +import { createBroadcaster } from '@/lib/broadcast'; export const dynamic = 'force-dynamic'; -// Store active connections per session -const connections = new Map>(); +const { subscribe, broadcast } = createBroadcaster(getWeeklyCheckInSessionEvents, (event) => ({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, +})); + +export { broadcast as broadcastToWeeklyCheckInSession }; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = await params; @@ -17,74 +25,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: return new Response('Unauthorized', { status: 401 }); } - // Check access const hasAccess = await canAccessWeeklyCheckInSession(sessionId, session.user.id); if (!hasAccess) { return new Response('Forbidden', { status: 403 }); } const userId = session.user.id; - let lastEventTime = new Date(); + let unsubscribe: () => void = () => {}; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(ctrl) { controller = ctrl; - - // Register connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Set()); - } - connections.get(sessionId)!.add(controller); - - // Send initial ping const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) ); + unsubscribe = subscribe(sessionId, userId, (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + } + }); }, cancel() { - // Remove connection on close - connections.get(sessionId)?.delete(controller); - if (connections.get(sessionId)?.size === 0) { - connections.delete(sessionId); - } + unsubscribe(); }, }); - // Poll for new events (simple approach, works with any DB) - const pollInterval = setInterval(async () => { - try { - const events = await getWeeklyCheckInSessionEvents(sessionId, lastEventTime); - if (events.length > 0) { - const encoder = new TextEncoder(); - for (const event of events) { - // Don't send events to the user who created them - if (event.userId !== userId) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: event.type, - payload: JSON.parse(event.payload), - userId: event.userId, - user: event.user, - timestamp: event.createdAt, - })}\n\n` - ) - ); - } - lastEventTime = event.createdAt; - } - } - } catch { - // Connection might be closed - clearInterval(pollInterval); - } - }, 2000); // Poll every 2 seconds - - // Cleanup on abort request.signal.addEventListener('abort', () => { - clearInterval(pollInterval); + unsubscribe(); }); return new Response(stream, { @@ -95,28 +66,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } - -// Helper to broadcast to all connections (called from actions) -export function broadcastToWeeklyCheckInSession(sessionId: string, event: object) { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections || sessionConnections.size === 0) { - return; - } - - const encoder = new TextEncoder(); - const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); - - for (const controller of sessionConnections) { - try { - controller.enqueue(message); - } catch { - // Connection might be closed, remove it - sessionConnections.delete(controller); - } - } - - // Clean up empty sets - if (sessionConnections.size === 0) { - connections.delete(sessionId); - } -} diff --git a/src/app/api/year-review/[id]/subscribe/route.ts b/src/app/api/year-review/[id]/subscribe/route.ts index 0584640..373d92e 100644 --- a/src/app/api/year-review/[id]/subscribe/route.ts +++ b/src/app/api/year-review/[id]/subscribe/route.ts @@ -1,10 +1,18 @@ import { auth } from '@/lib/auth'; import { canAccessYearReviewSession, getYearReviewSessionEvents } from '@/services/year-review'; +import { createBroadcaster } from '@/lib/broadcast'; export const dynamic = 'force-dynamic'; -// Store active connections per session -const connections = new Map>(); +const { subscribe, broadcast } = createBroadcaster(getYearReviewSessionEvents, (event) => ({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, +})); + +export { broadcast as broadcastToYearReviewSession }; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = await params; @@ -14,74 +22,37 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: return new Response('Unauthorized', { status: 401 }); } - // Check access const hasAccess = await canAccessYearReviewSession(sessionId, session.user.id); if (!hasAccess) { return new Response('Forbidden', { status: 403 }); } const userId = session.user.id; - let lastEventTime = new Date(); + let unsubscribe: () => void = () => {}; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(ctrl) { controller = ctrl; - - // Register connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Set()); - } - connections.get(sessionId)!.add(controller); - - // Send initial ping const encoder = new TextEncoder(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) ); + unsubscribe = subscribe(sessionId, userId, (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + } + }); }, cancel() { - // Remove connection on close - connections.get(sessionId)?.delete(controller); - if (connections.get(sessionId)?.size === 0) { - connections.delete(sessionId); - } + unsubscribe(); }, }); - // Poll for new events (simple approach, works with any DB) - const pollInterval = setInterval(async () => { - try { - const events = await getYearReviewSessionEvents(sessionId, lastEventTime); - if (events.length > 0) { - const encoder = new TextEncoder(); - for (const event of events) { - // Don't send events to the user who created them - if (event.userId !== userId) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - type: event.type, - payload: JSON.parse(event.payload), - userId: event.userId, - user: event.user, - timestamp: event.createdAt, - })}\n\n` - ) - ); - } - lastEventTime = event.createdAt; - } - } - } catch { - // Connection might be closed - clearInterval(pollInterval); - } - }, 2000); // Poll every 2 seconds - - // Cleanup on abort request.signal.addEventListener('abort', () => { - clearInterval(pollInterval); + unsubscribe(); }); return new Response(stream, { @@ -92,28 +63,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } - -// Helper to broadcast to all connections (called from actions) -export function broadcastToYearReviewSession(sessionId: string, event: object) { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections || sessionConnections.size === 0) { - return; - } - - const encoder = new TextEncoder(); - const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); - - for (const controller of sessionConnections) { - try { - controller.enqueue(message); - } catch { - // Connection might be closed, remove it - sessionConnections.delete(controller); - } - } - - // Clean up empty sets - if (sessionConnections.size === 0) { - connections.delete(sessionId); - } -} diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx index d99ed70..b9066a6 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useTransition } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { CollaboratorDisplay } from '@/components/ui'; -import { type WorkshopTabType, VALID_TAB_PARAMS } from '@/lib/workshops'; +import { type WorkshopTabType, VALID_TAB_PARAMS, type WorkshopTypeId } from '@/lib/workshops'; import { useClickOutside } from '@/hooks/useClickOutside'; +import { loadMoreSessions } from '@/actions/sessions-pagination'; import { type CardView, type SortCol, @@ -376,13 +377,14 @@ function SortableTableView({ // ─── WorkshopTabs ───────────────────────────────────────────────────────────── export function WorkshopTabs({ - swotSessions, - motivatorSessions, - yearReviewSessions, - weeklyCheckInSessions, - weatherSessions, - gifMoodSessions, + swotSessions: initialSwot, + motivatorSessions: initialMotivators, + yearReviewSessions: initialYearReview, + weeklyCheckInSessions: initialWeeklyCheckIn, + weatherSessions: initialWeather, + gifMoodSessions: initialGifMood, teamCollabSessions = [], + totals, }: WorkshopTabsProps) { const CARD_VIEW_STORAGE_KEY = 'sessions:cardView'; const isCardView = (value: string): value is CardView => @@ -390,7 +392,45 @@ export function WorkshopTabs({ const searchParams = useSearchParams(); const router = useRouter(); + const [isPending, startTransition] = useTransition(); const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); + + // Per-type session lists (extended by load more) + const [swotSessions, setSwotSessions] = useState(initialSwot); + const [motivatorSessions, setMotivatorSessions] = useState(initialMotivators); + const [yearReviewSessions, setYearReviewSessions] = useState(initialYearReview); + const [weeklyCheckInSessions, setWeeklyCheckInSessions] = useState(initialWeeklyCheckIn); + const [weatherSessions, setWeatherSessions] = useState(initialWeather); + const [gifMoodSessions, setGifMoodSessions] = useState(initialGifMood); + + const sessionsByType: Record = { + swot: swotSessions, + motivators: motivatorSessions, + 'year-review': yearReviewSessions, + 'weekly-checkin': weeklyCheckInSessions, + weather: weatherSessions, + 'gif-mood': gifMoodSessions, + }; + + const settersByType: Record>> = { + swot: setSwotSessions as React.Dispatch>, + motivators: setMotivatorSessions as React.Dispatch>, + 'year-review': setYearReviewSessions as React.Dispatch>, + 'weekly-checkin': setWeeklyCheckInSessions as React.Dispatch>, + weather: setWeatherSessions as React.Dispatch>, + 'gif-mood': setGifMoodSessions as React.Dispatch>, + }; + + function handleLoadMore(type: WorkshopTypeId) { + const current = sessionsByType[type]; + startTransition(async () => { + const result = await loadMoreSessions(type, current.length); + if (result) { + settersByType[type]((prev) => [...prev, ...(result.items as AnySession[])]); + } + }); + } + const [cardView, setCardView] = useState(() => { if (typeof window === 'undefined') return 'grid'; const storedView = localStorage.getItem(CARD_VIEW_STORAGE_KEY); @@ -516,12 +556,12 @@ export function WorkshopTabs({ open={typeDropdownOpen} onOpenChange={setTypeDropdownOpen} counts={{ - swot: swotSessions.length, - motivators: motivatorSessions.length, - 'year-review': yearReviewSessions.length, - 'weekly-checkin': weeklyCheckInSessions.length, - weather: weatherSessions.length, - 'gif-mood': gifMoodSessions.length, + swot: totals?.swot ?? swotSessions.length, + motivators: totals?.motivators ?? motivatorSessions.length, + 'year-review': totals?.['year-review'] ?? yearReviewSessions.length, + 'weekly-checkin': totals?.['weekly-checkin'] ?? weeklyCheckInSessions.length, + weather: totals?.weather ?? weatherSessions.length, + 'gif-mood': totals?.['gif-mood'] ?? gifMoodSessions.length, team: teamCollabSessions.length, }} /> @@ -634,6 +674,30 @@ export function WorkshopTabs({ )} + {/* Charger plus – visible pour les onglets par type uniquement */} + {activeTab !== 'all' && totals && totals[activeTab as WorkshopTypeId] !== undefined && ( + (() => { + const typeId = activeTab as WorkshopTypeId; + const total = totals[typeId]; + const loaded = sessionsByType[typeId].length; + if (loaded >= total) return null; + return ( +
+

+ {loaded} sur {total} atelier{total > 1 ? 's' : ''} +

+ +
+ ); + })() + )} )} diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index e5980a2..c7fc2c6 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -26,6 +26,7 @@ import { } from '@/services/gif-mood'; import { Card, PageHeader } from '@/components/ui'; import { withWorkshopType } from '@/lib/workshops'; +import { SESSIONS_PAGE_SIZE } from '@/lib/types'; import { WorkshopTabs } from './WorkshopTabs'; import { NewWorkshopDropdown } from './NewWorkshopDropdown'; @@ -84,13 +85,23 @@ export default async function SessionsPage() { getTeamGifMoodSessions(session.user.id), ]); - // Add workshopType to each session for unified display - const allSwotSessions = withWorkshopType(swotSessions, 'swot'); - const allMotivatorSessions = withWorkshopType(motivatorSessions, 'motivators'); - const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review'); - const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin'); - const allWeatherSessions = withWorkshopType(weatherSessions, 'weather'); - const allGifMoodSessions = withWorkshopType(gifMoodSessions, 'gif-mood'); + // Track totals before slicing for pagination UI + const totals = { + swot: swotSessions.length, + motivators: motivatorSessions.length, + 'year-review': yearReviewSessions.length, + 'weekly-checkin': weeklyCheckInSessions.length, + weather: weatherSessions.length, + 'gif-mood': gifMoodSessions.length, + }; + + // Add workshopType and slice first page + const allSwotSessions = withWorkshopType(swotSessions.slice(0, SESSIONS_PAGE_SIZE), 'swot'); + const allMotivatorSessions = withWorkshopType(motivatorSessions.slice(0, SESSIONS_PAGE_SIZE), 'motivators'); + const allYearReviewSessions = withWorkshopType(yearReviewSessions.slice(0, SESSIONS_PAGE_SIZE), 'year-review'); + const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions.slice(0, SESSIONS_PAGE_SIZE), 'weekly-checkin'); + const allWeatherSessions = withWorkshopType(weatherSessions.slice(0, SESSIONS_PAGE_SIZE), 'weather'); + const allGifMoodSessions = withWorkshopType(gifMoodSessions.slice(0, SESSIONS_PAGE_SIZE), 'gif-mood'); const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot'); const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators'); @@ -150,6 +161,7 @@ export default async function SessionsPage() { weeklyCheckInSessions={allWeeklyCheckInSessions} weatherSessions={allWeatherSessions} gifMoodSessions={allGifMoodSessions} + totals={totals} teamCollabSessions={[ ...teamSwotWithType, ...teamMotivatorWithType, diff --git a/src/app/sessions/workshop-session-types.ts b/src/app/sessions/workshop-session-types.ts index 15ebdf5..ce2f9dc 100644 --- a/src/app/sessions/workshop-session-types.ts +++ b/src/app/sessions/workshop-session-types.ts @@ -83,6 +83,15 @@ export type AnySession = | SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession | GifMoodSession; +export interface WorkshopSessionTotals { + swot: number; + motivators: number; + 'year-review': number; + 'weekly-checkin': number; + weather: number; + 'gif-mood': number; +} + export interface WorkshopTabsProps { swotSessions: SwotSession[]; motivatorSessions: MotivatorSession[]; @@ -91,4 +100,5 @@ export interface WorkshopTabsProps { weatherSessions: WeatherSession[]; gifMoodSessions: GifMoodSession[]; teamCollabSessions?: (AnySession & { isTeamCollab?: true })[]; + totals?: WorkshopSessionTotals; } diff --git a/src/lib/__tests__/broadcast.test.ts b/src/lib/__tests__/broadcast.test.ts new file mode 100644 index 0000000..0eb8032 --- /dev/null +++ b/src/lib/__tests__/broadcast.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createBroadcaster } from '@/lib/broadcast'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +interface FakeEvent { + id: string; + userId: string; + createdAt: Date; + payload: string; +} + +function makeEvent(overrides: Partial = {}): FakeEvent { + return { + id: 'e1', + userId: 'user-a', + createdAt: new Date('2024-01-01T00:00:00Z'), + payload: 'data', + ...overrides, + }; +} + +function makeBroadcaster(events: FakeEvent[] = []) { + const fetchEvents = vi.fn().mockResolvedValue(events); + const broadcaster = createBroadcaster(fetchEvents, (e) => ({ type: 'TEST', payload: e.payload, userId: e.userId })); + return { fetchEvents, broadcaster }; +} + +// ── subscribe / broadcast ────────────────────────────────────────────────── + +describe('subscribe + broadcast', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('registered callback receives broadcast events', () => { + const { broadcaster } = makeBroadcaster(); + const cb = vi.fn(); + broadcaster.subscribe('session-1', 'user-a', cb); + + broadcaster.broadcast('session-1', { type: 'update' }); + + expect(cb).toHaveBeenCalledOnce(); + expect(cb).toHaveBeenCalledWith({ type: 'update' }); + }); + + it('broadcast to unknown session is a no-op', () => { + const { broadcaster } = makeBroadcaster(); + expect(() => broadcaster.broadcast('unknown', { type: 'test' })).not.toThrow(); + }); + + it('multiple subscribers all receive the broadcast', () => { + const { broadcaster } = makeBroadcaster(); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + broadcaster.subscribe('session-1', 'user-a', cb1); + broadcaster.subscribe('session-1', 'user-b', cb2); + + broadcaster.broadcast('session-1', { type: 'ping' }); + + expect(cb1).toHaveBeenCalledOnce(); + expect(cb2).toHaveBeenCalledOnce(); + }); + + it('unsubscribed callback no longer receives broadcasts', () => { + const { broadcaster } = makeBroadcaster(); + const cb = vi.fn(); + const unsubscribe = broadcaster.subscribe('session-1', 'user-a', cb); + + unsubscribe(); + broadcaster.broadcast('session-1', { type: 'update' }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('unsubscribe is idempotent (calling twice is safe)', () => { + const { broadcaster } = makeBroadcaster(); + const cb = vi.fn(); + const unsubscribe = broadcaster.subscribe('session-1', 'user-a', cb); + + unsubscribe(); + expect(() => unsubscribe()).not.toThrow(); + }); +}); + +// ── Polling mutualisé ────────────────────────────────────────────────────── + +describe('shared polling (startPolling / stopPolling)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('starts polling when first subscriber arrives', async () => { + const { fetchEvents, broadcaster } = makeBroadcaster(); + + broadcaster.subscribe('session-1', 'user-a', vi.fn()); + await vi.advanceTimersByTimeAsync(1000); + + expect(fetchEvents).toHaveBeenCalledOnce(); + }); + + it('does NOT start a second interval for subsequent subscribers', async () => { + const { fetchEvents, broadcaster } = makeBroadcaster(); + + broadcaster.subscribe('session-1', 'user-a', vi.fn()); + broadcaster.subscribe('session-1', 'user-b', vi.fn()); + await vi.advanceTimersByTimeAsync(1000); + + // Only one poll despite two subscribers + expect(fetchEvents).toHaveBeenCalledOnce(); + }); + + it('stops polling when last subscriber leaves', async () => { + const { fetchEvents, broadcaster } = makeBroadcaster(); + + const unsub1 = broadcaster.subscribe('session-1', 'user-a', vi.fn()); + const unsub2 = broadcaster.subscribe('session-1', 'user-b', vi.fn()); + + await vi.advanceTimersByTimeAsync(1000); + expect(fetchEvents).toHaveBeenCalledOnce(); + + unsub1(); + unsub2(); // last subscriber → polling should stop + + await vi.advanceTimersByTimeAsync(2000); + // fetchEvents should NOT have been called again after both unsubscribed + expect(fetchEvents).toHaveBeenCalledOnce(); + }); + + it('keeps polling while at least one subscriber remains', async () => { + const { fetchEvents, broadcaster } = makeBroadcaster(); + + const unsub1 = broadcaster.subscribe('session-1', 'user-a', vi.fn()); + broadcaster.subscribe('session-1', 'user-b', vi.fn()); + + unsub1(); // still one left → polling continues + + await vi.advanceTimersByTimeAsync(2000); + expect(fetchEvents.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('passes the since timestamp to fetchEvents', async () => { + const { fetchEvents, broadcaster } = makeBroadcaster(); + + broadcaster.subscribe('session-1', 'user-a', vi.fn()); + await vi.advanceTimersByTimeAsync(1000); + + expect(fetchEvents).toHaveBeenCalledWith('session-1', expect.any(Date)); + }); +}); + +// ── Filtrage par userId ──────────────────────────────────────────────────── + +describe('polling event filtering', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('does NOT deliver an event to the subscriber who created it', async () => { + const event = makeEvent({ userId: 'user-a' }); + const { broadcaster } = makeBroadcaster([event]); + const cb = vi.fn(); + + broadcaster.subscribe('session-1', 'user-a', cb); // same userId as event + await vi.advanceTimersByTimeAsync(1000); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('delivers event to subscribers who did NOT create it', async () => { + const event = makeEvent({ userId: 'user-a' }); + const { broadcaster } = makeBroadcaster([event]); + const cbB = vi.fn(); + + broadcaster.subscribe('session-1', 'user-b', cbB); // different userId + await vi.advanceTimersByTimeAsync(1000); + + expect(cbB).toHaveBeenCalledOnce(); + }); + + it('delivers to some and skips others based on userId', async () => { + const event = makeEvent({ userId: 'user-a' }); + const { broadcaster } = makeBroadcaster([event]); + const cbA = vi.fn(); // creator → should NOT receive + const cbB = vi.fn(); // other user → should receive + + broadcaster.subscribe('session-1', 'user-a', cbA); + broadcaster.subscribe('session-1', 'user-b', cbB); + await vi.advanceTimersByTimeAsync(1000); + + expect(cbA).not.toHaveBeenCalled(); + expect(cbB).toHaveBeenCalledOnce(); + }); + + it('updates lastEventTime to last event createdAt', async () => { + const t1 = new Date('2024-01-01T00:00:01Z'); + const t2 = new Date('2024-01-01T00:00:02Z'); + const fetchEvents = vi.fn() + .mockResolvedValueOnce([makeEvent({ createdAt: t1, userId: 'user-x' }), makeEvent({ id: 'e2', createdAt: t2, userId: 'user-x' })]) + .mockResolvedValue([]); + const broadcaster = createBroadcaster(fetchEvents, (e) => e); + + broadcaster.subscribe('session-1', 'user-a', vi.fn()); + await vi.advanceTimersByTimeAsync(2000); // two ticks + + // Second call should use t2 as the `since` argument + expect(fetchEvents.mock.calls[1][1]).toEqual(t2); + }); +}); + +// ── formatEvent ──────────────────────────────────────────────────────────── + +describe('formatEvent', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('applies formatEvent before delivering to subscriber', async () => { + const event = makeEvent({ userId: 'user-x', payload: 'raw' }); + const fetchEvents = vi.fn().mockResolvedValue([event]); + const formatEvent = vi.fn().mockReturnValue({ type: 'FORMATTED', value: 42 }); + const broadcaster = createBroadcaster(fetchEvents, formatEvent); + const cb = vi.fn(); + + broadcaster.subscribe('session-1', 'user-a', cb); // user-a ≠ user-x → receives + await vi.advanceTimersByTimeAsync(1000); + + expect(formatEvent).toHaveBeenCalledWith(event); + expect(cb).toHaveBeenCalledWith({ type: 'FORMATTED', value: 42 }); + }); +}); + +// ── Error resilience ─────────────────────────────────────────────────────── + +describe('error resilience', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('does not crash when fetchEvents throws', async () => { + const fetchEvents = vi.fn().mockRejectedValue(new Error('DB down')); + const broadcaster = createBroadcaster(fetchEvents, (e) => e); + const cb = vi.fn(); + + broadcaster.subscribe('session-1', 'user-a', cb); + await expect(vi.advanceTimersByTimeAsync(1000)).resolves.not.toThrow(); + }); + + it('continues polling after a fetch error', async () => { + const fetchEvents = vi.fn() + .mockRejectedValueOnce(new Error('transient error')) + .mockResolvedValue([]); + const broadcaster = createBroadcaster(fetchEvents, (e) => e); + + broadcaster.subscribe('session-1', 'user-a', vi.fn()); + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchEvents.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ── Isolation entre sessions ─────────────────────────────────────────────── + +describe('session isolation', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('broadcast to one session does not affect another', () => { + const { broadcaster } = makeBroadcaster(); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + broadcaster.subscribe('session-1', 'user-a', cb1); + broadcaster.subscribe('session-2', 'user-b', cb2); + + broadcaster.broadcast('session-1', { type: 'event' }); + + expect(cb1).toHaveBeenCalledOnce(); + expect(cb2).not.toHaveBeenCalled(); + }); + + it('two sessions have independent polling intervals', async () => { + const fetchEvents = vi.fn().mockResolvedValue([]); + const broadcaster = createBroadcaster(fetchEvents, (e) => e); + + broadcaster.subscribe('session-1', 'user-a', vi.fn()); + broadcaster.subscribe('session-2', 'user-b', vi.fn()); + await vi.advanceTimersByTimeAsync(1000); + + // Each session polled once → 2 total calls + expect(fetchEvents).toHaveBeenCalledTimes(2); + expect(fetchEvents.mock.calls[0][0]).toBe('session-1'); + expect(fetchEvents.mock.calls[1][0]).toBe('session-2'); + }); +}); diff --git a/src/lib/broadcast.ts b/src/lib/broadcast.ts new file mode 100644 index 0000000..97a375e --- /dev/null +++ b/src/lib/broadcast.ts @@ -0,0 +1,92 @@ +/** + * Generic SSE broadcast module. + * One polling interval per active session (shared across all connections to that session). + * Server Actions call broadcast() directly for immediate push; polling is the fallback. + * + * NOTE: In-process only — works for single-process standalone Next.js deployments. + */ + +interface Subscriber { + userId: string; + cb: (event: unknown) => void; +} + +interface BroadcastEvent { + userId: string; + createdAt: Date; +} + +export function createBroadcaster( + fetchEvents: (sessionId: string, since: Date) => Promise, + formatEvent: (event: E) => unknown +) { + const subscribers = new Map>(); + const intervals = new Map>(); + const lastEventTimes = new Map(); + + function startPolling(sessionId: string) { + if (intervals.has(sessionId)) return; + lastEventTimes.set(sessionId, new Date()); + const interval = setInterval(async () => { + const subs = subscribers.get(sessionId); + if (!subs || subs.size === 0) return; + try { + const since = lastEventTimes.get(sessionId)!; + const events = await fetchEvents(sessionId, since); + for (const event of events) { + const formatted = formatEvent(event); + for (const sub of subs) { + if (sub.userId !== event.userId) { + sub.cb(formatted); + } + } + lastEventTimes.set(sessionId, event.createdAt); + } + } catch { + // Ignore polling errors — will retry next interval + } + }, 1000); + intervals.set(sessionId, interval); + } + + function stopPolling(sessionId: string) { + const interval = intervals.get(sessionId); + if (interval !== undefined) { + clearInterval(interval); + intervals.delete(sessionId); + lastEventTimes.delete(sessionId); + } + } + + /** Subscribe to events for a session. Returns an unsubscribe function. */ + function subscribe(sessionId: string, userId: string, cb: (event: unknown) => void): () => void { + if (!subscribers.has(sessionId)) { + subscribers.set(sessionId, new Set()); + } + const subscriber: Subscriber = { userId, cb }; + subscribers.get(sessionId)!.add(subscriber); + startPolling(sessionId); + + let removed = false; + return () => { + if (removed) return; + removed = true; + subscribers.get(sessionId)?.delete(subscriber); + if (subscribers.get(sessionId)?.size === 0) { + subscribers.delete(sessionId); + stopPolling(sessionId); + } + }; + } + + /** Broadcast an event to all subscribers of a session (called from Server Actions). */ + function broadcast(sessionId: string, event: unknown) { + const subs = subscribers.get(sessionId); + if (!subs) return; + for (const sub of subs) { + sub.cb(event); + } + } + + return { subscribe, broadcast }; +} diff --git a/src/lib/cache-tags.ts b/src/lib/cache-tags.ts new file mode 100644 index 0000000..3875f11 --- /dev/null +++ b/src/lib/cache-tags.ts @@ -0,0 +1,7 @@ +/** + * Next.js cache tag helpers for unstable_cache invalidation. + */ + +export const sessionTag = (id: string) => `session:${id}`; +export const sessionsListTag = (userId: string) => `sessions-list:${userId}`; +export const userStatsTag = (userId: string) => `user-stats:${userId}`; diff --git a/src/lib/types.ts b/src/lib/types.ts index 4fd6686..3cb3317 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -791,6 +791,8 @@ export const EMOTION_BY_TYPE: Record = EMOTIONS_CONFIG.r // ============================================ export const GIF_MOOD_MAX_ITEMS = 5; +export const WEATHER_HISTORY_LIMIT = 90; +export const SESSIONS_PAGE_SIZE = 20; export interface GifMoodItem { id: string; diff --git a/src/services/gif-mood.ts b/src/services/gif-mood.ts index 603ae54..e442cfe 100644 --- a/src/services/gif-mood.ts +++ b/src/services/gif-mood.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { prisma } from '@/services/database'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; import { createSessionPermissionChecks } from '@/services/session-permissions'; @@ -8,33 +9,44 @@ import { getSessionByIdGeneric, } from '@/services/session-queries'; import { GIF_MOOD_MAX_ITEMS } from '@/lib/types'; +import { sessionsListTag } from '@/lib/cache-tags'; import type { ShareRole } from '@prisma/client'; -const gifMoodInclude = { +const gifMoodListSelect = { + id: true, + title: true, + date: true, + updatedAt: true, + userId: true, user: { select: { id: true, name: true, email: true } }, - shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + shares: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true } } } }, _count: { select: { items: true } }, -}; +} as const; // ============================================ // GifMood Session CRUD // ============================================ export async function getGifMoodSessionsByUserId(userId: string) { - return mergeSessionsByUserId( - (uid) => - prisma.gifMoodSession.findMany({ - where: { userId: uid }, - include: gifMoodInclude, - orderBy: { updatedAt: 'desc' }, - }), - (uid) => - prisma.gMSessionShare.findMany({ - where: { userId: uid }, - include: { session: { include: gifMoodInclude } }, - }), - userId - ); + return unstable_cache( + () => + mergeSessionsByUserId( + (uid) => + prisma.gifMoodSession.findMany({ + where: { userId: uid }, + select: gifMoodListSelect, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.gMSessionShare.findMany({ + where: { userId: uid }, + select: { role: true, createdAt: true, session: { select: gifMoodListSelect } }, + }), + userId + ), + [`gif-mood-sessions-list-${userId}`], + { tags: [sessionsListTag(userId)], revalidate: 60 } + )(); } export async function getTeamCollaboratorSessionsForAdmin(userId: string) { @@ -42,7 +54,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { (teamMemberIds, uid) => prisma.gifMoodSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, - include: gifMoodInclude, + select: gifMoodListSelect, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts index 5d59961..4b2184a 100644 --- a/src/services/moving-motivators.ts +++ b/src/services/moving-motivators.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { prisma } from '@/services/database'; import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; @@ -8,38 +9,50 @@ import { fetchTeamCollaboratorSessions, getSessionByIdGeneric, } from '@/services/session-queries'; +import { sessionsListTag } from '@/lib/cache-tags'; import type { MotivatorType } from '@prisma/client'; -const motivatorInclude = { +const motivatorListSelect = { + id: true, + title: true, + participant: true, + updatedAt: true, + userId: true, user: { select: { id: true, name: true, email: true } }, - shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + shares: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true } } } }, _count: { select: { cards: true } }, -}; +} as const; // ============================================ // Moving Motivators Session CRUD // ============================================ export async function getMotivatorSessionsByUserId(userId: string) { - const sessions = await mergeSessionsByUserId( - (uid) => - prisma.movingMotivatorsSession.findMany({ - where: { userId: uid }, - include: motivatorInclude, - orderBy: { updatedAt: 'desc' }, - }), - (uid) => - prisma.mMSessionShare.findMany({ - where: { userId: uid }, - include: { session: { include: motivatorInclude } }, - }), - userId - ); - const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); - return sessions.map((s) => ({ - ...s, - resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, - })); + return unstable_cache( + async () => { + const sessions = await mergeSessionsByUserId( + (uid) => + prisma.movingMotivatorsSession.findMany({ + where: { userId: uid }, + select: motivatorListSelect, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.mMSessionShare.findMany({ + where: { userId: uid }, + select: { role: true, createdAt: true, session: { select: motivatorListSelect } }, + }), + userId + ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); + }, + [`motivator-sessions-list-${userId}`], + { tags: [sessionsListTag(userId)], revalidate: 60 } + )(); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ @@ -48,7 +61,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { (teamMemberIds, uid) => prisma.movingMotivatorsSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, - include: motivatorInclude, + select: motivatorListSelect, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, diff --git a/src/services/sessions.ts b/src/services/sessions.ts index b0a0fac..65f02da 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { prisma } from '@/services/database'; import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; @@ -8,38 +9,50 @@ import { fetchTeamCollaboratorSessions, getSessionByIdGeneric, } from '@/services/session-queries'; +import { sessionsListTag } from '@/lib/cache-tags'; import type { SwotCategory, ShareRole } from '@prisma/client'; -const sessionInclude = { +const sessionListSelect = { + id: true, + title: true, + collaborator: true, + updatedAt: true, + userId: true, user: { select: { id: true, name: true, email: true } }, - shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + shares: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true } } } }, _count: { select: { items: true, actions: true } }, -}; +} as const; // ============================================ // Session CRUD // ============================================ export async function getSessionsByUserId(userId: string) { - const sessions = await mergeSessionsByUserId( - (uid) => - prisma.session.findMany({ - where: { userId: uid }, - include: sessionInclude, - orderBy: { updatedAt: 'desc' }, - }), - (uid) => - prisma.sessionShare.findMany({ - where: { userId: uid }, - include: { session: { include: sessionInclude } }, - }), - userId - ); - const resolved = await batchResolveCollaborators(sessions.map((s) => s.collaborator)); - return sessions.map((s) => ({ - ...s, - resolvedCollaborator: resolved.get(s.collaborator.trim()) ?? { raw: s.collaborator, matchedUser: null }, - })); + return unstable_cache( + async () => { + const sessions = await mergeSessionsByUserId( + (uid) => + prisma.session.findMany({ + where: { userId: uid }, + select: sessionListSelect, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.sessionShare.findMany({ + where: { userId: uid }, + select: { role: true, createdAt: true, session: { select: sessionListSelect } }, + }), + userId + ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.collaborator)); + return sessions.map((s) => ({ + ...s, + resolvedCollaborator: resolved.get(s.collaborator.trim()) ?? { raw: s.collaborator, matchedUser: null }, + })); + }, + [`swot-sessions-list-${userId}`], + { tags: [sessionsListTag(userId)], revalidate: 60 } + )(); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ @@ -48,7 +61,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { (teamMemberIds, uid) => prisma.session.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, - include: sessionInclude, + select: sessionListSelect, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, diff --git a/src/services/weather.ts b/src/services/weather.ts index a11c54d..87890bc 100644 --- a/src/services/weather.ts +++ b/src/services/weather.ts @@ -7,8 +7,11 @@ import { fetchTeamCollaboratorSessions, getSessionByIdGeneric, } from '@/services/session-queries'; +import { unstable_cache } from 'next/cache'; import { getWeekBounds } from '@/lib/date-utils'; import { getEmojiScore } from '@/lib/weather-utils'; +import { WEATHER_HISTORY_LIMIT } from '@/lib/types'; +import { sessionsListTag } from '@/lib/cache-tags'; import type { ShareRole } from '@prisma/client'; export type WeatherHistoryPoint = { @@ -21,31 +24,41 @@ export type WeatherHistoryPoint = { valueCreation: number | null; }; -const weatherInclude = { +const weatherListSelect = { + id: true, + title: true, + date: true, + updatedAt: true, + userId: true, user: { select: { id: true, name: true, email: true } }, - shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + shares: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true } } } }, _count: { select: { entries: true } }, -}; +} as const; // ============================================ // 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 - ); + return unstable_cache( + () => + mergeSessionsByUserId( + (uid) => + prisma.weatherSession.findMany({ + where: { userId: uid }, + select: weatherListSelect, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.weatherSessionShare.findMany({ + where: { userId: uid }, + select: { role: true, createdAt: true, session: { select: weatherListSelect } }, + }), + userId + ), + [`weather-sessions-list-${userId}`], + { tags: [sessionsListTag(userId)], revalidate: 60 } + )(); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ @@ -54,7 +67,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { (teamMemberIds, uid) => prisma.weatherSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, - include: weatherInclude, + select: weatherListSelect, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, @@ -376,10 +389,14 @@ export async function getWeatherSessionsHistory(userId: string): Promise - prisma.weeklyCheckInSession.findMany({ - where: { userId: uid }, - include: weeklyCheckInInclude, - orderBy: { updatedAt: 'desc' }, - }), - (uid) => - prisma.wCISessionShare.findMany({ - where: { userId: uid }, - include: { session: { include: weeklyCheckInInclude } }, - }), - userId - ); - const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); - return sessions.map((s) => ({ - ...s, - resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, - })); + return unstable_cache( + async () => { + const sessions = await mergeSessionsByUserId( + (uid) => + prisma.weeklyCheckInSession.findMany({ + where: { userId: uid }, + select: weeklyCheckInListSelect, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.wCISessionShare.findMany({ + where: { userId: uid }, + select: { role: true, createdAt: true, session: { select: weeklyCheckInListSelect } }, + }), + userId + ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); + }, + [`weekly-checkin-sessions-list-${userId}`], + { tags: [sessionsListTag(userId)], revalidate: 60 } + )(); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ @@ -48,7 +62,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { (teamMemberIds, uid) => prisma.weeklyCheckInSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, - include: weeklyCheckInInclude, + select: weeklyCheckInListSelect, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, diff --git a/src/services/year-review.ts b/src/services/year-review.ts index a5dd104..0608614 100644 --- a/src/services/year-review.ts +++ b/src/services/year-review.ts @@ -1,3 +1,4 @@ +import { unstable_cache } from 'next/cache'; import { prisma } from '@/services/database'; import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; @@ -8,38 +9,51 @@ import { fetchTeamCollaboratorSessions, getSessionByIdGeneric, } from '@/services/session-queries'; +import { sessionsListTag } from '@/lib/cache-tags'; import type { YearReviewCategory } from '@prisma/client'; -const yearReviewInclude = { +const yearReviewListSelect = { + id: true, + title: true, + participant: true, + year: true, + updatedAt: true, + userId: true, user: { select: { id: true, name: true, email: true } }, - shares: { include: { user: { select: { id: true, name: true, email: true } } } }, + shares: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true } } } }, _count: { select: { items: true } }, -}; +} as const; // ============================================ // Year Review Session CRUD // ============================================ export async function getYearReviewSessionsByUserId(userId: string) { - const sessions = await mergeSessionsByUserId( - (uid) => - prisma.yearReviewSession.findMany({ - where: { userId: uid }, - include: yearReviewInclude, - orderBy: { updatedAt: 'desc' }, - }), - (uid) => - prisma.yRSessionShare.findMany({ - where: { userId: uid }, - include: { session: { include: yearReviewInclude } }, - }), - userId - ); - const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); - return sessions.map((s) => ({ - ...s, - resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, - })); + return unstable_cache( + async () => { + const sessions = await mergeSessionsByUserId( + (uid) => + prisma.yearReviewSession.findMany({ + where: { userId: uid }, + select: yearReviewListSelect, + orderBy: { updatedAt: 'desc' }, + }), + (uid) => + prisma.yRSessionShare.findMany({ + where: { userId: uid }, + select: { role: true, createdAt: true, session: { select: yearReviewListSelect } }, + }), + userId + ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); + }, + [`year-review-sessions-list-${userId}`], + { tags: [sessionsListTag(userId)], revalidate: 60 } + )(); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ @@ -48,7 +62,7 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { (teamMemberIds, uid) => prisma.yearReviewSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, - include: yearReviewInclude, + select: yearReviewListSelect, orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams,