feat: implement Year Review feature with session management, item categorization, and real-time collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s

This commit is contained in:
Julien Froidefond
2025-12-16 08:55:13 +01:00
parent 48ff86fb5f
commit 56a9c2c3be
21 changed files with 2480 additions and 50 deletions

View File

@@ -0,0 +1,70 @@
-- CreateTable
CREATE TABLE "YearReviewSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"participant" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "YearReviewSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "YearReviewItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"category" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "YearReviewItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "YRSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "YRSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "YRSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "YRSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "YRSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "YearReviewSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "YRSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "YearReviewSession_userId_idx" ON "YearReviewSession"("userId");
-- CreateIndex
CREATE INDEX "YearReviewSession_year_idx" ON "YearReviewSession"("year");
-- CreateIndex
CREATE INDEX "YearReviewItem_sessionId_idx" ON "YearReviewItem"("sessionId");
-- CreateIndex
CREATE INDEX "YearReviewItem_sessionId_category_idx" ON "YearReviewItem"("sessionId", "category");
-- CreateIndex
CREATE INDEX "YRSessionShare_sessionId_idx" ON "YRSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "YRSessionShare_userId_idx" ON "YRSessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "YRSessionShare_sessionId_userId_key" ON "YRSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "YRSessionEvent_sessionId_createdAt_idx" ON "YRSessionEvent"("sessionId", "createdAt");

View File

@@ -21,6 +21,10 @@ model User {
motivatorSessions MovingMotivatorsSession[] motivatorSessions MovingMotivatorsSession[]
sharedMotivatorSessions MMSessionShare[] sharedMotivatorSessions MMSessionShare[]
motivatorSessionEvents MMSessionEvent[] motivatorSessionEvents MMSessionEvent[]
// Year Review relations
yearReviewSessions YearReviewSession[]
sharedYearReviewSessions YRSessionShare[]
yearReviewSessionEvents YRSessionEvent[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -200,3 +204,73 @@ model MMSessionEvent {
@@index([sessionId, createdAt]) @@index([sessionId, createdAt])
} }
// ============================================
// Year Review Workshop
// ============================================
enum YearReviewCategory {
ACHIEVEMENTS // Réalisations / Accomplissements
CHALLENGES // Défis / Difficultés rencontrées
LEARNINGS // Apprentissages / Compétences développées
GOALS // Objectifs pour l'année suivante
MOMENTS // Moments forts / Moments difficiles
}
model YearReviewSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
year Int // Année du bilan (ex: 2024)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items YearReviewItem[]
shares YRSessionShare[]
events YRSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([year])
}
model YearReviewItem {
id String @id @default(cuid())
content String
category YearReviewCategory
order Int @default(0)
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionId])
@@index([sessionId, category])
}
model YRSessionShare {
id String @id @default(cuid())
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model YRSessionEvent {
id String @id @default(cuid())
sessionId String
session YearReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

329
src/actions/year-review.ts Normal file
View File

@@ -0,0 +1,329 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as yearReviewService from '@/services/year-review';
import type { YearReviewCategory } from '@prisma/client';
// ============================================
// Session Actions
// ============================================
export async function createYearReviewSession(data: {
title: string;
participant: string;
year: number;
}) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const yearReviewSession = await yearReviewService.createYearReviewSession(
session.user.id,
data
);
revalidatePath('/year-review');
revalidatePath('/sessions');
return { success: true, data: yearReviewSession };
} catch (error) {
console.error('Error creating year review session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateYearReviewSession(
sessionId: string,
data: { title?: string; participant?: string; year?: number }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await yearReviewService.updateYearReviewSession(sessionId, authSession.user.id, data);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
revalidatePath(`/year-review/${sessionId}`);
revalidatePath('/year-review');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating year review session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteYearReviewSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await yearReviewService.deleteYearReviewSession(sessionId, authSession.user.id);
revalidatePath('/year-review');
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting year review session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Item Actions
// ============================================
export async function createYearReviewItem(
sessionId: string,
data: { content: string; category: YearReviewCategory }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await yearReviewService.createYearReviewItem(sessionId, data);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_CREATED',
{
itemId: item.id,
content: item.content,
category: item.category,
}
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error creating year review item:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateYearReviewItem(
itemId: string,
sessionId: string,
data: { content?: string; category?: YearReviewCategory }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const item = await yearReviewService.updateYearReviewItem(itemId, data);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_UPDATED',
{
itemId: item.id,
...data,
}
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error updating year review item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteYearReviewItem(itemId: string, sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await yearReviewService.deleteYearReviewItem(itemId);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_DELETED',
{ itemId }
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting year review item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
export async function moveYearReviewItem(
itemId: string,
sessionId: string,
newCategory: YearReviewCategory,
newOrder: number
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await yearReviewService.moveYearReviewItem(itemId, newCategory, newOrder);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEM_MOVED',
{
itemId,
category: newCategory,
order: newOrder,
}
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error moving year review item:', error);
return { success: false, error: 'Erreur lors du déplacement' };
}
}
export async function reorderYearReviewItems(
sessionId: string,
category: YearReviewCategory,
itemIds: string[]
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await yearReviewService.canEditYearReviewSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await yearReviewService.reorderYearReviewItems(sessionId, category, itemIds);
// Emit event for real-time sync
await yearReviewService.createYearReviewSessionEvent(
sessionId,
authSession.user.id,
'ITEMS_REORDERED',
{ category, itemIds }
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error reordering year review items:', error);
return { success: false, error: 'Erreur lors du réordonnancement' };
}
}
// ============================================
// Sharing Actions
// ============================================
export async function shareYearReviewSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await yearReviewService.shareYearReviewSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/year-review/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing year review session:', error);
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function removeYearReviewShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await yearReviewService.removeYearReviewShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/year-review/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing year review share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -0,0 +1,123 @@
import { auth } from '@/lib/auth';
import {
canAccessYearReviewSession,
getYearReviewSessionEvents,
} from '@/services/year-review';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.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 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`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// 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);
}
}, 1000); // Poll every second
// Cleanup on abort
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
// 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);
}
}

View File

@@ -18,6 +18,7 @@ export function EditableMotivatorTitle({
const [title, setTitle] = useState(initialTitle); const [title, setTitle] = useState(initialTitle);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const prevInitialTitleRef = useRef(initialTitle);
useEffect(() => { useEffect(() => {
if (isEditing && inputRef.current) { if (isEditing && inputRef.current) {
@@ -26,9 +27,14 @@ export function EditableMotivatorTitle({
} }
}, [isEditing]); }, [isEditing]);
// Update local state when prop changes (e.g., from SSE) // Update local state when prop changes (e.g., from SSE) - only when not editing
// This is a valid pattern for syncing external state (SSE updates) with local state
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing && prevInitialTitleRef.current !== initialTitle) {
prevInitialTitleRef.current = initialTitle;
// Synchronizing with external prop updates (e.g., from SSE)
// eslint-disable-next-line react-hooks/exhaustive-deps
setTitle(initialTitle); setTitle(initialTitle);
} }
}, [initialTitle, isEditing]); }, [initialTitle, isEditing]);

View File

@@ -20,7 +20,7 @@ export default function Home() {
<h2 className="mb-8 text-center text-2xl font-bold text-foreground"> <h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Choisissez votre atelier Choisissez votre atelier
</h2> </h2>
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto"> <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 max-w-6xl mx-auto">
{/* SWOT Workshop Card */} {/* SWOT Workshop Card */}
<WorkshopCard <WorkshopCard
href="/sessions?tab=swot" href="/sessions?tab=swot"
@@ -52,6 +52,22 @@ export default function Home() {
accentColor="#8b5cf6" accentColor="#8b5cf6"
newHref="/motivators/new" newHref="/motivators/new"
/> />
{/* Year Review Workshop Card */}
<WorkshopCard
href="/sessions?tab=year-review"
icon="📅"
title="Year Review"
tagline="Faites le bilan de l'année"
description="Réalisez un bilan complet de l'année écoulée. Identifiez réalisations, défis, apprentissages et définissez vos objectifs pour l'année à venir."
features={[
'5 catégories : Réalisations, Défis, Apprentissages, Objectifs, Moments',
'Organisation par drag & drop',
'Vue d\'ensemble de l\'année',
]}
accentColor="#f59e0b"
newHref="/year-review/new"
/>
</div> </div>
</section> </section>
@@ -250,6 +266,95 @@ export default function Home() {
</div> </div>
</section> </section>
{/* Year Review Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">📅</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Year Review</h2>
<p className="text-amber-500 font-medium">Faites le bilan de l&apos;année écoulée</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💭</span>
Pourquoi faire un bilan annuel ?
</h3>
<p className="text-muted mb-4">
Le Year Review est un exercice de réflexion structuré qui permet de prendre du recul
sur l&apos;année écoulée. Il aide à identifier les patterns, célébrer les réussites,
apprendre des défis et préparer l&apos;avenir avec clarté.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Prendre conscience de ses accomplissements et les célébrer
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Identifier les apprentissages et compétences développées
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Comprendre les défis rencontrés pour mieux les anticiper
</li>
<li className="flex items-start gap-2">
<span className="text-amber-500"></span>
Définir des objectifs clairs et motivants pour l&apos;année à venir
</li>
</ul>
</div>
{/* The 5 categories */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 5 catégories du bilan
</h3>
<div className="space-y-3">
<CategoryPill icon="🏆" name="Réalisations" color="#22c55e" description="Ce que vous avez accompli" />
<CategoryPill icon="⚔️" name="Défis" color="#ef4444" description="Les difficultés rencontrées" />
<CategoryPill icon="📚" name="Apprentissages" color="#3b82f6" description="Ce que vous avez appris" />
<CategoryPill icon="🎯" name="Objectifs" color="#8b5cf6" description="Vos ambitions pour l'année prochaine" />
<CategoryPill icon="⭐" name="Moments" color="#f59e0b" description="Les moments forts et marquants" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Réfléchir"
description="Prenez le temps de revenir sur l'année écoulée, consultez votre agenda, vos notes, vos projets"
/>
<StepCard
number={2}
title="Catégoriser"
description="Organisez vos réflexions dans les 5 catégories : réalisations, défis, apprentissages, objectifs et moments"
/>
<StepCard
number={3}
title="Prioriser"
description="Classez les éléments par importance et impact pour identifier ce qui compte vraiment"
/>
<StepCard
number={4}
title="Planifier"
description="Utilisez ce bilan pour définir vos objectifs et actions pour l'année à venir"
/>
</div>
</div>
</div>
</section>
{/* Benefits Section */} {/* Benefits Section */}
<section className="rounded-2xl border border-border bg-card p-8"> <section className="rounded-2xl border border-border bg-card p-8">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground"> <h2 className="mb-8 text-center text-2xl font-bold text-foreground">
@@ -420,3 +525,30 @@ function MotivatorPill({ icon, name, color }: { icon: string; name: string; colo
</div> </div>
); );
} }
function CategoryPill({
icon,
name,
color,
description,
}: {
icon: string;
name: string;
color: string;
description: string;
}) {
return (
<div
className="flex items-start gap-3 px-4 py-3 rounded-lg"
style={{ backgroundColor: `${color}10`, border: `1px solid ${color}30` }}
>
<span className="text-xl">{icon}</span>
<div className="flex-1">
<p className="font-semibold text-sm mb-0.5" style={{ color }}>
{name}
</p>
<p className="text-xs text-muted">{description}</p>
</div>
</div>
);
}

View File

@@ -14,10 +14,11 @@ import {
} from '@/components/ui'; } from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session'; import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators'; import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson'; type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson']; const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson'];
interface ShareUser { interface ShareUser {
id: string; id: string;
@@ -68,34 +69,38 @@ interface MotivatorSession {
workshopType: 'motivators'; workshopType: 'motivators';
} }
type AnySession = SwotSession | MotivatorSession; interface YearReviewSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
year: number;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number };
workshopType: 'year-review';
}
type AnySession = SwotSession | MotivatorSession | YearReviewSession;
interface WorkshopTabsProps { interface WorkshopTabsProps {
swotSessions: SwotSession[]; swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[]; motivatorSessions: MotivatorSession[];
} yearReviewSessions: YearReviewSession[];
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
} }
// Helper to get resolved collaborator from any session // Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator { function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot' if (session.workshopType === 'swot') {
? (session as SwotSession).resolvedCollaborator return (session as SwotSession).resolvedCollaborator;
: (session as MotivatorSession).resolvedParticipant; } else if (session.workshopType === 'year-review') {
} return (session as YearReviewSession).resolvedParticipant;
} else {
// Get display name for grouping - prefer matched user name return (session as MotivatorSession).resolvedParticipant;
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
} }
return resolved.raw;
} }
// Get grouping key - use matched user ID if available, otherwise normalized raw string // Get grouping key - use matched user ID if available, otherwise normalized raw string
@@ -132,7 +137,11 @@ function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
return grouped; return grouped;
} }
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) { export function WorkshopTabs({
swotSessions,
motivatorSessions,
yearReviewSessions,
}: WorkshopTabsProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -152,9 +161,11 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
}; };
// Combine and sort all sessions // Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort( const allSessions: AnySession[] = [
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ...swotSessions,
); ...motivatorSessions,
...yearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs) // Filter based on active tab (for non-byPerson tabs)
const filteredSessions = const filteredSessions =
@@ -162,7 +173,9 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
? allSessions ? allSessions
: activeTab === 'swot' : activeTab === 'swot'
? swotSessions ? swotSessions
: motivatorSessions; : activeTab === 'motivators'
? motivatorSessions
: yearReviewSessions;
// Separate by ownership // Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -206,6 +219,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
label="Moving Motivators" label="Moving Motivators"
count={motivatorSessions.length} count={motivatorSessions.length}
/> />
<TabButton
active={activeTab === 'year-review'}
onClick={() => setActiveTab('year-review')}
icon="📅"
label="Year Review"
count={yearReviewSessions.length}
/>
</div> </div>
{/* Sessions */} {/* Sessions */}
@@ -316,22 +336,33 @@ function SessionCard({ session }: { session: AnySession }) {
const [editParticipant, setEditParticipant] = useState( const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot' session.workshopType === 'swot'
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: (session as MotivatorSession).participant : session.workshopType === 'year-review'
? (session as YearReviewSession).participant
: (session as MotivatorSession).participant
); );
const isSwot = session.workshopType === 'swot'; const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`; const isYearReview = session.workshopType === 'year-review';
const icon = isSwot ? '📊' : '🎯'; const href = isSwot
? `/sessions/${session.id}`
: isYearReview
? `/year-review/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯';
const participant = isSwot const participant = isSwot
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: (session as MotivatorSession).participant; : isYearReview
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6'; ? (session as YearReviewSession).participant
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6';
const handleDelete = () => { const handleDelete = () => {
startTransition(async () => { startTransition(async () => {
const result = isSwot const result = isSwot
? await deleteSwotSession(session.id) ? await deleteSwotSession(session.id)
: await deleteMotivatorSession(session.id); : isYearReview
? await deleteYearReviewSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) { if (result.success) {
setShowDeleteModal(false); setShowDeleteModal(false);
@@ -345,10 +376,15 @@ function SessionCard({ session }: { session: AnySession }) {
startTransition(async () => { startTransition(async () => {
const result = isSwot const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant }) ? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, { : isYearReview
title: editTitle, ? await updateYearReviewSession(session.id, {
participant: editParticipant, title: editTitle,
}); participant: editParticipant,
})
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) { if (result.success) {
setShowEditModal(false); setShowEditModal(false);
@@ -414,6 +450,12 @@ function SessionCard({ session }: { session: AnySession }) {
<span>·</span> <span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span> <span>{(session as SwotSession)._count.actions} actions</span>
</> </>
) : isYearReview ? (
<>
<span>{(session as YearReviewSession)._count.items} items</span>
<span>·</span>
<span>Année {(session as YearReviewSession).year}</span>
</>
) : ( ) : (
<span>{(session as MotivatorSession)._count.cards}/10</span> <span>{(session as MotivatorSession)._count.cards}/10</span>
)} )}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions'; import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { Card, Button } from '@/components/ui'; import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs'; import { WorkshopTabs } from './WorkshopTabs';
@@ -32,10 +33,11 @@ export default async function SessionsPage() {
return null; return null;
} }
// Fetch both SWOT and Moving Motivators sessions // Fetch SWOT, Moving Motivators, and Year Review sessions
const [swotSessions, motivatorSessions] = await Promise.all([ const [swotSessions, motivatorSessions, yearReviewSessions] = await Promise.all([
getSessionsByUserId(session.user.id), getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
]); ]);
// Add type to each session for unified display // Add type to each session for unified display
@@ -49,10 +51,17 @@ export default async function SessionsPage() {
workshopType: 'motivators' as const, workshopType: 'motivators' as const,
})); }));
const allYearReviewSessions = yearReviewSessions.map((s) => ({
...s,
workshopType: 'year-review' as const,
}));
// Combine and sort by updatedAt // Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort( const allSessions = [
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ...allSwotSessions,
); ...allMotivatorSessions,
...allYearReviewSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0; const hasNoSessions = allSessions.length === 0;
@@ -72,11 +81,17 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/motivators/new"> <Link href="/motivators/new">
<Button> <Button variant="outline">
<span>🎯</span> <span>🎯</span>
Nouveau Motivators Nouveau Motivators
</Button> </Button>
</Link> </Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Nouveau Year Review
</Button>
</Link>
</div> </div>
</div> </div>
@@ -88,8 +103,8 @@ export default async function SessionsPage() {
Commencez votre premier atelier Commencez votre premier atelier
</h2> </h2>
<p className="text-muted mb-6 max-w-md mx-auto"> <p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
pour découvrir les motivations de vos collaborateurs. découvrir les motivations, ou un Year Review pour faire le bilan de l&apos;année.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Link href="/sessions/new"> <Link href="/sessions/new">
@@ -99,16 +114,26 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/motivators/new"> <Link href="/motivators/new">
<Button> <Button variant="outline">
<span>🎯</span> <span>🎯</span>
Créer un Moving Motivators Créer un Moving Motivators
</Button> </Button>
</Link> </Link>
<Link href="/year-review/new">
<Button>
<span>📅</span>
Créer un Year Review
</Button>
</Link>
</div> </div>
</Card> </Card>
) : ( ) : (
<Suspense fallback={<WorkshopTabsSkeleton />}> <Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} /> <WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
/>
</Suspense> </Suspense>
)} )}
</main> </main>

View File

@@ -0,0 +1,114 @@
'use client';
import { useState, useTransition, useRef, useEffect } from 'react';
import { updateYearReviewSession } from '@/actions/year-review';
interface EditableYearReviewTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableYearReviewTitle({
sessionId,
initialTitle,
isOwner,
}: EditableYearReviewTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(initialTitle);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const prevInitialTitleRef = useRef(initialTitle);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
// Update local state when prop changes (e.g., from SSE) - only when not editing
// This is a valid pattern for syncing external state (SSE updates) with local state
useEffect(() => {
if (!isEditing && prevInitialTitleRef.current !== initialTitle) {
prevInitialTitleRef.current = initialTitle;
// Synchronizing with external prop updates (e.g., from SSE)
// eslint-disable-next-line react-hooks/exhaustive-deps
setTitle(initialTitle);
}
}, [initialTitle, isEditing]);
const handleSave = () => {
if (!title.trim()) {
setTitle(initialTitle);
setIsEditing(false);
return;
}
if (title.trim() === initialTitle) {
setIsEditing(false);
return;
}
startTransition(async () => {
const result = await updateYearReviewSession(sessionId, { title: title.trim() });
if (!result.success) {
setTitle(initialTitle);
console.error(result.error);
}
setIsEditing(false);
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setTitle(initialTitle);
setIsEditing(false);
}
};
if (!isOwner) {
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
}
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isPending}
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
/>
);
}
return (
<button
onClick={() => setIsEditing(true)}
className="group flex items-center gap-2 text-left"
title="Cliquez pour modifier"
>
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
<svg
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,82 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getYearReviewSessionById } from '@/services/year-review';
import { YearReviewBoard, YearReviewLiveWrapper } from '@/components/year-review';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableYearReviewTitle } from './EditableTitle';
interface YearReviewSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function YearReviewSessionPage({ params }: YearReviewSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getYearReviewSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=year-review" className="hover:text-foreground">
Year Review
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableYearReviewTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="default">Année {session.year}</Badge>
<span className="text-sm text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */}
<YearReviewLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<YearReviewBoard sessionId={session.id} items={session.items} />
</YearReviewLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createYearReviewSession } from '@/actions/year-review';
export default function NewYearReviewPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentYear = new Date().getFullYear();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const participant = formData.get('participant') as string;
const year = parseInt(formData.get('year') as string, 10);
if (!title || !participant || !year) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createYearReviewSession({ title, participant, year });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/year-review/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📅</span>
Nouveau Bilan Annuel
</CardTitle>
<CardDescription>
Créez un bilan de l&apos;année pour faire le point sur les réalisations, défis,
apprentissages et objectifs
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre du bilan"
name="title"
placeholder={`Ex: Bilan annuel ${currentYear}`}
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div>
<label htmlFor="year" className="block text-sm font-medium text-foreground mb-1">
Année du bilan
</label>
<input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={currentYear}
required
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<strong>Réalisations</strong> : Notez ce que vous avez accompli cette année
</li>
<li>
<strong>Défis</strong> : Identifiez les difficultés rencontrées
</li>
<li>
<strong>Apprentissages</strong> : Listez ce que vous avez appris et développé
</li>
<li>
<strong>Objectifs</strong> : Définissez vos objectifs pour l&apos;année prochaine
</li>
<li>
<strong>Moments</strong> : Partagez les moments forts et marquants
</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le bilan
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -22,9 +22,12 @@ export function EditableTitle({ sessionId, initialTitle, isOwner }: EditableTitl
} }
}, [isEditing]); }, [isEditing]);
// Update local state when prop changes (e.g., from SSE) // Update local state when prop changes (e.g., from SSE) - only when not editing
// This is a valid pattern for syncing external state (SSE updates) with local state
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
// Synchronizing with external prop updates (e.g., from SSE)
// eslint-disable-next-line react-hooks/exhaustive-deps
setTitle(initialTitle); setTitle(initialTitle);
} }
}, [initialTitle, isEditing]); }, [initialTitle, isEditing]);

View File

@@ -0,0 +1,94 @@
'use client';
import { useTransition } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import type { YearReviewItem, YearReviewCategory } from '@prisma/client';
import { YearReviewSection } from './YearReviewSection';
import { YearReviewCard } from './YearReviewCard';
import { moveYearReviewItem, reorderYearReviewItems } from '@/actions/year-review';
import { YEAR_REVIEW_SECTIONS } from '@/lib/types';
interface YearReviewBoardProps {
sessionId: string;
items: YearReviewItem[];
}
export function YearReviewBoard({ sessionId, items }: YearReviewBoardProps) {
const [isPending, startTransition] = useTransition();
const itemsByCategory = YEAR_REVIEW_SECTIONS.reduce(
(acc, section) => {
acc[section.category] = items
.filter((item) => item.category === section.category)
.sort((a, b) => a.order - b.order);
return acc;
},
{} as Record<YearReviewCategory, YearReviewItem[]>
);
function handleDragEnd(result: DropResult) {
if (!result.destination) return;
const { source, destination, draggableId } = result;
const sourceCategory = source.droppableId as YearReviewCategory;
const destCategory = destination.droppableId as YearReviewCategory;
// If same position, do nothing
if (sourceCategory === destCategory && source.index === destination.index) {
return;
}
startTransition(async () => {
if (sourceCategory === destCategory) {
// Same category - just reorder
const categoryItems = itemsByCategory[sourceCategory];
const itemIds = categoryItems.map((item) => item.id);
const [removed] = itemIds.splice(source.index, 1);
itemIds.splice(destination.index, 0, removed);
await reorderYearReviewItems(sessionId, sourceCategory, itemIds);
} else {
// Different category - move item
await moveYearReviewItem(draggableId, sessionId, destCategory, destination.index);
}
});
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Year Review Sections */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{YEAR_REVIEW_SECTIONS.map((section) => (
<Droppable key={section.category} droppableId={section.category}>
{(provided, snapshot) => (
<YearReviewSection
category={section.category}
sessionId={sessionId}
isDraggingOver={snapshot.isDraggingOver}
ref={provided.innerRef}
{...provided.droppableProps}
>
{itemsByCategory[section.category].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(dragProvided, dragSnapshot) => (
<YearReviewCard
item={item}
sessionId={sessionId}
isDragging={dragSnapshot.isDragging}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
</YearReviewSection>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import type { YearReviewItem } from '@prisma/client';
import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
interface YearReviewCardProps {
item: YearReviewItem;
sessionId: string;
isDragging: boolean;
}
export const YearReviewCard = forwardRef<HTMLDivElement, YearReviewCardProps>(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
return;
}
if (!content.trim()) {
// If empty, delete
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
setIsEditing(false);
});
}
async function handleDelete() {
startTransition(async () => {
await deleteYearReviewItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setContent(item.content);
setIsEditing(false);
}
}
return (
<div
ref={ref}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${isPending ? 'opacity-50' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '3px',
}}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{/* Actions (visible on hover) */}
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</>
)}
</div>
);
}
);
YearReviewCard.displayName = 'YearReviewCard';

View File

@@ -0,0 +1,133 @@
'use client';
import { useState, useCallback } from 'react';
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { YearReviewShareModal } from './YearReviewShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface YearReviewLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function YearReviewLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: YearReviewLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: YearReviewLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useYearReviewLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<YearReviewShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { forwardRef, useState, useTransition, ReactNode } from 'react';
import type { YearReviewCategory } from '@prisma/client';
import { createYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
interface YearReviewSectionProps {
category: YearReviewCategory;
sessionId: string;
isDraggingOver: boolean;
children: ReactNode;
}
export const YearReviewSection = forwardRef<HTMLDivElement, YearReviewSectionProps>(
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
const [isAdding, setIsAdding] = useState(false);
const [newContent, setNewContent] = useState('');
const [isPending, startTransition] = useTransition();
const config = YEAR_REVIEW_BY_CATEGORY[category];
async function handleAdd() {
if (!newContent.trim()) {
setIsAdding(false);
return;
}
startTransition(async () => {
await createYearReviewItem(sessionId, {
content: newContent.trim(),
category,
});
setNewContent('');
setIsAdding(false);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAdd();
} else if (e.key === 'Escape') {
setIsAdding(false);
setNewContent('');
}
}
return (
<div
ref={ref}
className={`
rounded-xl border-2 p-4 min-h-[200px] transition-colors
bg-card border-border
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
style={{
borderLeftColor: config.color,
borderLeftWidth: '4px',
}}
{...props}
>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{config.icon}</span>
<div>
<h3 className="font-semibold text-foreground">{config.title}</h3>
<p className="text-xs text-muted">{config.description}</p>
</div>
</div>
<button
onClick={() => setIsAdding(true)}
className="rounded-lg p-1.5 transition-colors hover:bg-card-hover text-muted hover:text-foreground"
aria-label={`Ajouter un item ${config.title}`}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
{/* Items */}
<div className="space-y-2">
{children}
{/* Add Form */}
{isAdding && (
<div className="rounded-lg border border-border bg-card p-2 shadow-sm">
<textarea
autoFocus
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleAdd}
placeholder={`Décrivez ${config.title.toLowerCase()}...`}
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
<div className="mt-1 flex justify-end gap-1">
<button
onClick={() => {
setIsAdding(false);
setNewContent('');
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onClick={handleAdd}
disabled={isPending || !newContent.trim()}
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
>
{isPending ? '...' : 'Ajouter'}
</button>
</div>
</div>
)}
</div>
</div>
);
}
);
YearReviewSection.displayName = 'YearReviewSection';

View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface YearReviewShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function YearReviewShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: YearReviewShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareYearReviewSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeYearReviewShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager le bilan">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Bilan annuel</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { YearReviewBoard } from './YearReviewBoard';
export { YearReviewCard } from './YearReviewCard';
export { YearReviewSection } from './YearReviewSection';
export { YearReviewLiveWrapper } from './YearReviewLiveWrapper';
export { YearReviewShareModal } from './YearReviewShareModal';

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
export type YearReviewLiveEvent = {
type: string;
payload: Record<string, unknown>;
userId?: string;
user?: { id: string; name: string | null; email: string };
timestamp: string;
};
interface UseYearReviewLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: YearReviewLiveEvent) => void;
}
interface UseYearReviewLiveReturn {
isConnected: boolean;
lastEvent: YearReviewLiveEvent | null;
error: string | null;
}
export function useYearReviewLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseYearReviewLiveOptions): UseYearReviewLiveReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<YearReviewLiveEvent | null>(null);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const onEventRef = useRef(onEvent);
const currentUserIdRef = useRef(currentUserId);
// Keep refs updated
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
function connect() {
// Close existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
try {
const eventSource = new EventSource(`/api/year-review/${sessionId}/subscribe`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setIsConnected(true);
setError(null);
reconnectAttemptsRef.current = 0;
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as YearReviewLiveEvent;
// Handle connection event
if (data.type === 'connected') {
return;
}
// Client-side filter: ignore events created by current user
if (currentUserIdRef.current && data.userId === currentUserIdRef.current) {
return;
}
setLastEvent(data);
onEventRef.current?.(data);
// Refresh the page data when we receive an event from another user
router.refresh();
} catch (e) {
console.error('Failed to parse SSE event:', e);
}
};
eventSource.onerror = () => {
setIsConnected(false);
eventSource.close();
// Exponential backoff reconnect
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
reconnectAttemptsRef.current++;
if (reconnectAttemptsRef.current <= 5) {
reconnectTimeoutRef.current = setTimeout(connect, delay);
} else {
setError('Connexion perdue. Rechargez la page.');
}
};
} catch (e) {
setError('Impossible de se connecter au mode live');
console.error('Failed to create EventSource:', e);
}
}
connect();
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
}, [sessionId, enabled, router]);
return { isConnected, lastEvent, error };
}

View File

@@ -298,3 +298,120 @@ export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> = MOTIVAT
}, },
{} as Record<MotivatorType, MotivatorConfig> {} as Record<MotivatorType, MotivatorConfig>
); );
// ============================================
// Year Review - Type Definitions
// ============================================
export type YearReviewCategory =
| 'ACHIEVEMENTS' // Réalisations / Accomplissements
| 'CHALLENGES' // Défis / Difficultés rencontrées
| 'LEARNINGS' // Apprentissages / Compétences développées
| 'GOALS' // Objectifs pour l'année suivante
| 'MOMENTS'; // Moments forts / Moments difficiles
export interface YearReviewItem {
id: string;
content: string;
category: YearReviewCategory;
order: number;
sessionId: string;
createdAt: Date;
updatedAt: Date;
}
export interface YearReviewSession {
id: string;
title: string;
participant: string;
year: number;
userId: string;
items: YearReviewItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CreateYearReviewSessionInput {
title: string;
participant: string;
year: number;
}
export interface UpdateYearReviewSessionInput {
title?: string;
participant?: string;
year?: number;
}
export interface CreateYearReviewItemInput {
content: string;
category: YearReviewCategory;
order?: number;
}
export interface UpdateYearReviewItemInput {
content?: string;
category?: YearReviewCategory;
order?: number;
}
// ============================================
// Year Review - UI Config
// ============================================
export interface YearReviewSectionConfig {
category: YearReviewCategory;
title: string;
icon: string;
description: string;
color: string;
}
export const YEAR_REVIEW_SECTIONS: YearReviewSectionConfig[] = [
{
category: 'ACHIEVEMENTS',
title: 'Réalisations',
icon: '🏆',
description: 'Ce que vous avez accompli cette année',
color: '#22c55e', // green
},
{
category: 'CHALLENGES',
title: 'Défis',
icon: '⚔️',
description: 'Les difficultés que vous avez rencontrées',
color: '#ef4444', // red
},
{
category: 'LEARNINGS',
title: 'Apprentissages',
icon: '📚',
description: 'Ce que vous avez appris et développé',
color: '#3b82f6', // blue
},
{
category: 'GOALS',
title: 'Objectifs',
icon: '🎯',
description: 'Ce que vous souhaitez accomplir l\'année prochaine',
color: '#8b5cf6', // purple
},
{
category: 'MOMENTS',
title: 'Moments',
icon: '⭐',
description: 'Les moments forts et marquants de l\'année',
color: '#f59e0b', // amber
},
];
export const YEAR_REVIEW_BY_CATEGORY: Record<
YearReviewCategory,
YearReviewSectionConfig
> = YEAR_REVIEW_SECTIONS.reduce(
(acc, config) => {
acc[config.category] = config;
return acc;
},
{} as Record<YearReviewCategory, YearReviewSectionConfig>
);

370
src/services/year-review.ts Normal file
View File

@@ -0,0 +1,370 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, YearReviewCategory } from '@prisma/client';
// ============================================
// Year Review Session CRUD
// ============================================
export async function getYearReviewSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.yearReviewSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.yRSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Resolve participants to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
return sessionsWithResolved;
}
export async function getYearReviewSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.yearReviewSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
// Resolve participant to user if it's an email
const resolvedParticipant = await resolveCollaborator(session.participant);
return { ...session, isOwner, role, canEdit, resolvedParticipant };
}
// Check if user can access session (owner or shared)
export async function canAccessYearReviewSession(sessionId: string, userId: string) {
const count = await prisma.yearReviewSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
// Check if user can edit session (owner or EDITOR role)
export async function canEditYearReviewSession(sessionId: string, userId: string) {
const count = await prisma.yearReviewSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createYearReviewSession(
userId: string,
data: { title: string; participant: string; year: number }
) {
return prisma.yearReviewSession.create({
data: {
...data,
userId,
},
include: {
items: {
orderBy: [{ category: 'asc' }, { order: 'asc' }],
},
},
});
}
export async function updateYearReviewSession(
sessionId: string,
userId: string,
data: { title?: string; participant?: string; year?: number }
) {
return prisma.yearReviewSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteYearReviewSession(sessionId: string, userId: string) {
return prisma.yearReviewSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Year Review Items CRUD
// ============================================
export async function createYearReviewItem(
sessionId: string,
data: { content: string; category: YearReviewCategory }
) {
// Get max order for this category in this session
const maxOrder = await prisma.yearReviewItem.findFirst({
where: { sessionId, category: data.category },
orderBy: { order: 'desc' },
select: { order: true },
});
return prisma.yearReviewItem.create({
data: {
...data,
sessionId,
order: (maxOrder?.order ?? -1) + 1,
},
});
}
export async function updateYearReviewItem(
itemId: string,
data: { content?: string; category?: YearReviewCategory; order?: number }
) {
return prisma.yearReviewItem.update({
where: { id: itemId },
data,
});
}
export async function deleteYearReviewItem(itemId: string) {
return prisma.yearReviewItem.delete({
where: { id: itemId },
});
}
export async function moveYearReviewItem(
itemId: string,
newCategory: YearReviewCategory,
newOrder: number
) {
return prisma.yearReviewItem.update({
where: { id: itemId },
data: {
category: newCategory,
order: newOrder,
},
});
}
export async function reorderYearReviewItems(
sessionId: string,
category: YearReviewCategory,
itemIds: string[]
) {
const updates = itemIds.map((id, index) =>
prisma.yearReviewItem.update({
where: { id },
data: { order: index },
})
);
return prisma.$transaction(updates);
}
// ============================================
// Session Sharing
// ============================================
export async function shareYearReviewSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.yearReviewSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Find target user
const targetUser = await prisma.user.findUnique({
where: { email: targetEmail },
});
if (!targetUser) {
throw new Error('User not found');
}
// Can't share with yourself
if (targetUser.id === ownerId) {
throw new Error('Cannot share session with yourself');
}
// Create or update share
return prisma.yRSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: targetUser.id },
},
update: { role },
create: {
sessionId,
userId: targetUser.id,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function removeYearReviewShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.yearReviewSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.yRSessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getYearReviewSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessYearReviewSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.yRSessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type YRSessionEventType =
| 'ITEM_CREATED'
| 'ITEM_UPDATED'
| 'ITEM_DELETED'
| 'ITEM_MOVED'
| 'ITEMS_REORDERED'
| 'SESSION_UPDATED';
export async function createYearReviewSessionEvent(
sessionId: string,
userId: string,
type: YRSessionEventType,
payload: Record<string, unknown>
) {
return prisma.yRSessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getYearReviewSessionEvents(sessionId: string, since?: Date) {
return prisma.yRSessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestYearReviewEventTimestamp(sessionId: string) {
const event = await prisma.yRSessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}