diff --git a/dev.db b/dev.db index 44e532a..5629128 100644 Binary files a/dev.db and b/dev.db differ diff --git a/prisma/migrations/20251127122502_add_session_sharing_and_events/migration.sql b/prisma/migrations/20251127122502_add_session_sharing_and_events/migration.sql new file mode 100644 index 0000000..fde60c9 --- /dev/null +++ b/prisma/migrations/20251127122502_add_session_sharing_and_events/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "SessionShare" ( + "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 "SessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SessionEvent" ( + "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 "SessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "SessionShare_sessionId_idx" ON "SessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "SessionShare_userId_idx" ON "SessionShare"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SessionShare_sessionId_userId_key" ON "SessionShare"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "SessionEvent_sessionId_createdAt_idx" ON "SessionEvent"("sessionId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d17ab9..227f608 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,26 +10,30 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - name String? - password String - sessions Session[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + name String? + password String + sessions Session[] + sharedSessions SessionShare[] + sessionEvents SessionEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Session { - id String @id @default(cuid()) + id String @id @default(cuid()) title String collaborator String - date DateTime @default(now()) + date DateTime @default(now()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) items SwotItem[] actions Action[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + shares SessionShare[] + events SessionEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) } @@ -82,3 +86,39 @@ model ActionLink { @@index([actionId]) @@index([swotItemId]) } + +// ============================================ +// Collaboration & Real-time +// ============================================ + +enum ShareRole { + VIEWER + EDITOR +} + +model SessionShare { + id String @id @default(cuid()) + sessionId String + session Session @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 SessionEvent { + id String @id @default(cuid()) + sessionId String + session Session @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, ACTION_CREATED, etc. + payload String // JSON payload + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/src/actions/share.ts b/src/actions/share.ts new file mode 100644 index 0000000..74ba578 --- /dev/null +++ b/src/actions/share.ts @@ -0,0 +1,69 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { auth } from '@/lib/auth'; +import { + shareSession, + removeShare, + getSessionShares, +} from '@/services/sessions'; +import type { ShareRole } from '@prisma/client'; + +export async function shareSessionAction( + sessionId: string, + email: string, + role: ShareRole = 'EDITOR' +) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non authentifié' }; + } + + try { + const share = await shareSession(sessionId, session.user.id, email, role); + revalidatePath(`/sessions/${sessionId}`); + return { success: true, data: share }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Erreur inconnue'; + if (message === 'User not found') { + return { success: false, error: "Aucun utilisateur trouvé avec cet email" }; + } + if (message === 'Cannot share session with yourself') { + return { success: false, error: "Vous ne pouvez pas partager avec vous-même" }; + } + return { success: false, error: message }; + } +} + +export async function removeShareAction(sessionId: string, shareUserId: string) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non authentifié' }; + } + + try { + await removeShare(sessionId, session.user.id, shareUserId); + revalidatePath(`/sessions/${sessionId}`); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Erreur inconnue'; + return { success: false, error: message }; + } +} + +export async function getSharesAction(sessionId: string) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non authentifié', data: [] }; + } + + try { + const shares = await getSessionShares(sessionId, session.user.id); + return { success: true, data: shares }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Erreur inconnue'; + return { success: false, error: message, data: [] }; + } +} + + diff --git a/src/actions/swot.ts b/src/actions/swot.ts index 997348d..5bed846 100644 --- a/src/actions/swot.ts +++ b/src/actions/swot.ts @@ -20,6 +20,14 @@ export async function createSwotItem( try { const item = await sessionsService.createSwotItem(sessionId, data); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', { + itemId: item.id, + content: item.content, + category: item.category, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -40,6 +48,13 @@ export async function updateSwotItem( try { const item = await sessionsService.updateSwotItem(itemId, data); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_UPDATED', { + itemId: item.id, + ...data, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -56,6 +71,12 @@ export async function deleteSwotItem(itemId: string, sessionId: string) { try { await sessionsService.deleteSwotItem(itemId); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_DELETED', { + itemId, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true }; } catch (error) { @@ -72,6 +93,15 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) { try { const item = await sessionsService.duplicateSwotItem(itemId); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', { + itemId: item.id, + content: item.content, + category: item.category, + duplicatedFrom: itemId, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -93,6 +123,14 @@ export async function moveSwotItem( try { const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_MOVED', { + itemId: item.id, + newCategory, + newOrder, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true, data: item }; } catch (error) { @@ -121,6 +159,14 @@ export async function createAction( try { const action = await sessionsService.createAction(sessionId, data); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_CREATED', { + actionId: action.id, + title: action.title, + linkedItemIds: data.linkedItemIds, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true, data: action }; } catch (error) { @@ -146,6 +192,13 @@ export async function updateAction( try { const action = await sessionsService.updateAction(actionId, data); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_UPDATED', { + actionId: action.id, + ...data, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true, data: action }; } catch (error) { @@ -162,6 +215,12 @@ export async function deleteAction(actionId: string, sessionId: string) { try { await sessionsService.deleteAction(actionId); + + // Emit event for real-time sync + await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_DELETED', { + actionId, + }); + revalidatePath(`/sessions/${sessionId}`); return { success: true }; } catch (error) { diff --git a/src/app/api/sessions/[id]/subscribe/route.ts b/src/app/api/sessions/[id]/subscribe/route.ts new file mode 100644 index 0000000..f29660d --- /dev/null +++ b/src/app/api/sessions/[id]/subscribe/route.ts @@ -0,0 +1,114 @@ +import { auth } from '@/lib/auth'; +import { canAccessSession, getSessionEvents } from '@/services/sessions'; + +export const dynamic = 'force-dynamic'; + +// Store active connections per session +const connections = new Map>(); + +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 canAccessSession(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 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), + 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 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/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index da29dbd..fc71aab 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getSessionById } from '@/services/sessions'; import { SwotBoard } from '@/components/swot/SwotBoard'; +import { SessionLiveWrapper } from '@/components/collaboration'; import { Badge } from '@/components/ui'; interface SessionPageProps { @@ -33,6 +34,11 @@ export default async function SessionPage({ params }: SessionPageProps) { / {session.title} + {!session.isOwner && ( + + Partagé par {session.user.name || session.user.email} + + )}
@@ -56,12 +62,20 @@ export default async function SessionPage({ params }: SessionPageProps) {
- {/* SWOT Board */} - + sessionTitle={session.title} + shares={session.shares} + isOwner={session.isOwner} + canEdit={session.canEdit} + > + + ); } diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index 84f9530..1663d17 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -1,23 +1,8 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; -import { prisma } from '@/services/database'; +import { getSessionsByUserId } from '@/services/sessions'; import { Card, CardContent, Badge, Button } from '@/components/ui'; -async function getSessions(userId: string) { - return prisma.session.findMany({ - where: { userId }, - include: { - _count: { - select: { - items: true, - actions: true, - }, - }, - }, - orderBy: { updatedAt: 'desc' }, - }); -} - export default async function SessionsPage() { const session = await auth(); @@ -25,7 +10,11 @@ export default async function SessionsPage() { return null; } - const sessions = await getSessions(session.user.id); + const sessions = await getSessionsByUserId(session.user.id); + + // Separate owned vs shared sessions + const ownedSessions = sessions.filter((s) => s.isOwner); + const sharedSessions = sessions.filter((s) => !s.isOwner); return (
@@ -61,45 +50,89 @@ export default async function SessionsPage() { ) : ( -
- {sessions.map((s) => ( - - -
-
-

- {s.title} -

-

{s.collaborator}

-
- 📊 -
+
+ {/* My Sessions */} + {ownedSessions.length > 0 && ( +
+

+ 📁 Mes sessions ({ownedSessions.length}) +

+
+ {ownedSessions.map((s) => ( + + ))} +
+
+ )} - -
- - {s._count.items} items - - - {s._count.actions} actions - -
- -

- Mis à jour le{' '} - {new Date(s.updatedAt).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', - year: 'numeric', - })} -

-
- - - ))} + {/* Shared Sessions */} + {sharedSessions.length > 0 && ( +
+

+ 🤝 Sessions partagées avec moi ({sharedSessions.length}) +

+
+ {sharedSessions.map((s) => ( + + ))} +
+
+ )}
)}
); } +type SessionWithMeta = Awaited>[number]; + +function SessionCard({ session: s }: { session: SessionWithMeta }) { + return ( + + +
+
+

+ {s.title} +

+

{s.collaborator}

+ {!s.isOwner && ( +

+ Par {s.user.name || s.user.email} +

+ )} +
+
+ {!s.isOwner && ( + + {s.role === 'EDITOR' ? '✏️' : '👁️'} + + )} + 📊 +
+
+ + +
+ + {s._count.items} items + + + {s._count.actions} actions + +
+ +

+ Mis à jour le{' '} + {new Date(s.updatedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

+
+
+ + ); +} + diff --git a/src/components/collaboration/LiveIndicator.tsx b/src/components/collaboration/LiveIndicator.tsx new file mode 100644 index 0000000..3b9376c --- /dev/null +++ b/src/components/collaboration/LiveIndicator.tsx @@ -0,0 +1,36 @@ +'use client'; + +interface LiveIndicatorProps { + isConnected: boolean; + error?: string | null; +} + +export function LiveIndicator({ isConnected, error }: LiveIndicatorProps) { + if (error) { + return ( +
+ + Hors ligne +
+ ); + } + + return ( +
+ + {isConnected ? 'Live' : 'Connexion...'} +
+ ); +} + + diff --git a/src/components/collaboration/SessionLiveWrapper.tsx b/src/components/collaboration/SessionLiveWrapper.tsx new file mode 100644 index 0000000..258f1e4 --- /dev/null +++ b/src/components/collaboration/SessionLiveWrapper.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive'; +import { LiveIndicator } from './LiveIndicator'; +import { ShareModal } from './ShareModal'; +import { Button } from '@/components/ui/Button'; +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 SessionLiveWrapperProps { + sessionId: string; + sessionTitle: string; + shares: Share[]; + isOwner: boolean; + canEdit: boolean; + children: React.ReactNode; +} + +export function SessionLiveWrapper({ + sessionId, + sessionTitle, + shares, + isOwner, + canEdit, + children, +}: SessionLiveWrapperProps) { + const [shareModalOpen, setShareModalOpen] = useState(false); + const [lastEventUser, setLastEventUser] = useState(null); + + const handleEvent = useCallback((event: LiveEvent) => { + // 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 } = useSessionLive({ + sessionId, + onEvent: handleEvent, + }); + + return ( + <> + {/* Header toolbar */} +
+
+ + + {lastEventUser && ( +
+ ✏️ + {lastEventUser} édite... +
+ )} + + {!canEdit && ( +
+ 👁️ + Mode lecture +
+ )} +
+ +
+ {/* Collaborators avatars */} + {shares.length > 0 && ( +
+ {shares.slice(0, 3).map((share) => ( +
+ {share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()} +
+ ))} + {shares.length > 3 && ( +
+ +{shares.length - 3} +
+ )} +
+ )} + + +
+
+ + {/* Content */} +
+ {children} +
+ + {/* Share Modal */} + setShareModalOpen(false)} + sessionId={sessionId} + sessionTitle={sessionTitle} + shares={shares} + isOwner={isOwner} + /> + + ); +} + + diff --git a/src/components/collaboration/ShareModal.tsx b/src/components/collaboration/ShareModal.tsx new file mode 100644 index 0000000..45eeed5 --- /dev/null +++ b/src/components/collaboration/ShareModal.tsx @@ -0,0 +1,181 @@ +'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 { shareSessionAction, removeShareAction } from '@/actions/share'; +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 ShareModalProps { + isOpen: boolean; + onClose: () => void; + sessionId: string; + sessionTitle: string; + shares: Share[]; + isOwner: boolean; +} + +export function ShareModal({ + isOpen, + onClose, + sessionId, + sessionTitle, + shares, + isOwner, +}: ShareModalProps) { + const [email, setEmail] = useState(''); + const [role, setRole] = useState('EDITOR'); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + async function handleShare(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + startTransition(async () => { + const result = await shareSessionAction(sessionId, email, role); + if (result.success) { + setEmail(''); + } else { + setError(result.error || 'Erreur lors du partage'); + } + }); + } + + async function handleRemove(userId: string) { + startTransition(async () => { + await removeShareAction(sessionId, userId); + }); + } + + return ( + +
+ {/* Session info */} +
+

Session

+

{sessionTitle}

+
+ + {/* Share form (only for owner) */} + {isOwner && ( +
+
+ setEmail(e.target.value)} + className="flex-1" + required + /> + +
+ + {error &&

{error}

} + + +
+ )} + + {/* Current shares */} +
+

+ Collaborateurs ({shares.length}) +

+ + {shares.length === 0 ? ( +

+ Aucun collaborateur pour le moment +

+ ) : ( +
    + {shares.map((share) => ( +
  • +
    +
    + {share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()} +
    +
    +

    + {share.user.name || share.user.email} +

    + {share.user.name && ( +

    {share.user.email}

    + )} +
    +
    + +
    + + {share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'} + + {isOwner && ( + + )} +
    +
  • + ))} +
+ )} +
+ + {/* Help text */} +
+

+ Éditeur : peut modifier les items et actions +
+ Lecteur : peut uniquement consulter +

+
+
+
+ ); +} + + diff --git a/src/components/collaboration/index.ts b/src/components/collaboration/index.ts new file mode 100644 index 0000000..e005bde --- /dev/null +++ b/src/components/collaboration/index.ts @@ -0,0 +1,4 @@ +export { LiveIndicator } from './LiveIndicator'; +export { ShareModal } from './ShareModal'; +export { SessionLiveWrapper } from './SessionLiveWrapper'; + diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 652ba63..bf10b74 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -1,6 +1,6 @@ import { HTMLAttributes, forwardRef } from 'react'; -type BadgeVariant = +export type BadgeVariant = | 'default' | 'primary' | 'strength' @@ -9,7 +9,8 @@ type BadgeVariant = | 'threat' | 'success' | 'warning' - | 'destructive'; + | 'destructive' + | 'accent'; interface BadgeProps extends HTMLAttributes { variant?: BadgeVariant; @@ -25,6 +26,7 @@ const variantStyles: Record = { success: 'bg-success/10 text-success border-success/20', warning: 'bg-warning/10 text-warning border-warning/20', destructive: 'bg-destructive/10 text-destructive border-destructive/20', + accent: 'bg-accent/10 text-accent border-accent/20', }; export const Badge = forwardRef( diff --git a/src/hooks/useSessionLive.ts b/src/hooks/useSessionLive.ts new file mode 100644 index 0000000..98c5113 --- /dev/null +++ b/src/hooks/useSessionLive.ts @@ -0,0 +1,118 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useRouter } from 'next/navigation'; + +export type LiveEvent = { + type: string; + payload: Record; + user?: { id: string; name: string | null; email: string }; + timestamp: string; +}; + +interface UseSessionLiveOptions { + sessionId: string; + enabled?: boolean; + onEvent?: (event: LiveEvent) => void; +} + +interface UseSessionLiveReturn { + isConnected: boolean; + lastEvent: LiveEvent | null; + error: string | null; +} + +export function useSessionLive({ + sessionId, + enabled = true, + onEvent, +}: UseSessionLiveOptions): UseSessionLiveReturn { + const [isConnected, setIsConnected] = useState(false); + const [lastEvent, setLastEvent] = useState(null); + const [error, setError] = useState(null); + const router = useRouter(); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const onEventRef = useRef(onEvent); + + // Keep onEvent ref updated + useEffect(() => { + onEventRef.current = onEvent; + }, [onEvent]); + + useEffect(() => { + if (!enabled || typeof window === 'undefined') return; + + function connect() { + // Close existing connection + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + try { + const eventSource = new EventSource(`/api/sessions/${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 LiveEvent; + + // Handle connection event + if (data.type === 'connected') { + return; + } + + setLastEvent(data); + onEventRef.current?.(data); + + // Refresh the page data when we receive an event + 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 }; +} + diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 7fa88d6..c2d6860 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -1,32 +1,70 @@ import { prisma } from '@/services/database'; -import type { SwotCategory } from '@prisma/client'; +import type { SwotCategory, ShareRole } from '@prisma/client'; // ============================================ // Session CRUD // ============================================ export async function getSessionsByUserId(userId: string) { - return prisma.session.findMany({ - where: { userId }, - include: { - _count: { - select: { - items: true, - actions: true, + // Get owned sessions + shared sessions + const [owned, shared] = await Promise.all([ + prisma.session.findMany({ + where: { userId }, + include: { + user: { select: { id: true, name: true, email: true } }, + _count: { + select: { + items: true, + actions: true, + }, }, }, - }, - orderBy: { updatedAt: 'desc' }, - }); + orderBy: { updatedAt: 'desc' }, + }), + prisma.sessionShare.findMany({ + where: { userId }, + include: { + session: { + include: { + user: { select: { id: true, name: true, email: true } }, + _count: { + select: { + items: true, + actions: 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, + })); + + return [...ownedWithRole, ...sharedWithRole].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); } export async function getSessionById(sessionId: string, userId: string) { - return prisma.session.findFirst({ + // Check if user owns the session OR has it shared + const session = await prisma.session.findFirst({ where: { id: sessionId, - userId, + OR: [ + { userId }, // Owner + { shares: { some: { userId } } }, // Shared with user + ], }, include: { + user: { select: { id: true, name: true, email: true } }, items: { orderBy: { order: 'asc' }, }, @@ -40,8 +78,45 @@ export async function getSessionById(sessionId: string, userId: string) { }, orderBy: { createdAt: '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'; + + return { ...session, isOwner, role, canEdit }; +} + +// Check if user can access session (owner or shared) +export async function canAccessSession(sessionId: string, userId: string) { + const count = await prisma.session.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 canEditSession(sessionId: string, userId: string) { + const count = await prisma.session.count({ + where: { + id: sessionId, + OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }], + }, + }); + return count > 0; } export async function createSession(userId: string, data: { title: string; collaborator: string }) { @@ -238,3 +313,131 @@ export async function unlinkItemFromAction(actionId: string, swotItemId: string) }); } +// ============================================ +// Session Sharing +// ============================================ + +export async function shareSession( + sessionId: string, + ownerId: string, + targetEmail: string, + role: ShareRole = 'EDITOR' +) { + // Verify owner + const session = await prisma.session.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.sessionShare.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 removeShare(sessionId: string, ownerId: string, shareUserId: string) { + // Verify owner + const session = await prisma.session.findFirst({ + where: { id: sessionId, userId: ownerId }, + }); + if (!session) { + throw new Error('Session not found or not owned'); + } + + return prisma.sessionShare.deleteMany({ + where: { sessionId, userId: shareUserId }, + }); +} + +export async function getSessionShares(sessionId: string, userId: string) { + // Verify access + if (!(await canAccessSession(sessionId, userId))) { + throw new Error('Access denied'); + } + + return prisma.sessionShare.findMany({ + where: { sessionId }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }); +} + +// ============================================ +// Session Events (for real-time sync) +// ============================================ + +export type SessionEventType = + | 'ITEM_CREATED' + | 'ITEM_UPDATED' + | 'ITEM_DELETED' + | 'ITEM_MOVED' + | 'ACTION_CREATED' + | 'ACTION_UPDATED' + | 'ACTION_DELETED' + | 'SESSION_UPDATED'; + +export async function createSessionEvent( + sessionId: string, + userId: string, + type: SessionEventType, + payload: Record +) { + return prisma.sessionEvent.create({ + data: { + sessionId, + userId, + type, + payload: JSON.stringify(payload), + }, + }); +} + +export async function getSessionEvents(sessionId: string, since?: Date) { + return prisma.sessionEvent.findMany({ + where: { + sessionId, + ...(since && { createdAt: { gt: since } }), + }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); +} + +export async function getLatestEventTimestamp(sessionId: string) { + const event = await prisma.sessionEvent.findFirst({ + where: { sessionId }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + return event?.createdAt; +} +