From 10ff15392f715c2fe688bc5ed48473220b91f075 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 27 Nov 2025 13:34:03 +0100 Subject: [PATCH] feat: enhance session management with sharing capabilities, real-time event synchronization, and UI updates for session display --- dev.db | Bin 81920 -> 122880 bytes .../migration.sql | 34 +++ prisma/schema.prisma | 64 ++++- src/actions/share.ts | 69 ++++++ src/actions/swot.ts | 59 +++++ src/app/api/sessions/[id]/subscribe/route.ts | 114 +++++++++ src/app/sessions/[id]/page.tsx | 24 +- src/app/sessions/page.tsx | 137 +++++++---- .../collaboration/LiveIndicator.tsx | 36 +++ .../collaboration/SessionLiveWrapper.tsx | 136 +++++++++++ src/components/collaboration/ShareModal.tsx | 181 ++++++++++++++ src/components/collaboration/index.ts | 4 + src/components/ui/Badge.tsx | 6 +- src/hooks/useSessionLive.ts | 118 +++++++++ src/services/sessions.ts | 229 +++++++++++++++++- 15 files changed, 1127 insertions(+), 84 deletions(-) create mode 100644 prisma/migrations/20251127122502_add_session_sharing_and_events/migration.sql create mode 100644 src/actions/share.ts create mode 100644 src/app/api/sessions/[id]/subscribe/route.ts create mode 100644 src/components/collaboration/LiveIndicator.tsx create mode 100644 src/components/collaboration/SessionLiveWrapper.tsx create mode 100644 src/components/collaboration/ShareModal.tsx create mode 100644 src/components/collaboration/index.ts create mode 100644 src/hooks/useSessionLive.ts diff --git a/dev.db b/dev.db index 44e532a92bf0b6eb004fbd5967ef6328a558ea16..562912858b29057c08aba7d14d74a1581479a929 100644 GIT binary patch delta 3646 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 delta 927 zcmaJ;O-vI(7~N@i`v*H83q82t+Cnr)1iIa(1$*cUiGd52A~t$4Sb`~{QsWP$fdmek zcp&_YaYLfS6AcGLbPpbsmIOJFC{4UTVi1G_dh}v2G2*lf2Q_h$nY{Vt%lqD&H?n9Q z8Q~@>1V6(tPPz{lKUwQ5_A~WkdpzthW$3wP?LyVMP*QIf zFp$s%_f;SX*CPvpbq3aTp=%Y))#eLi$aP{$W@$OVK!CQ@0tu+AI=l4~UbZ8*t2o9u z98m-V_&dJKf8(zgeJZ->SaU>+zu4c|o9W;xJ82#qv)R;238^hDBr-0RbuP`_ywcr! zXKC(yXUExdEuF0#iR_D-XA@!aY|!HN`vRwAIVj7DtoZA^-a4hB)2jp(CFl#((?`8M zRcb1t`X8ubfmVxnkVqzmF*!1PlDr<0u(u)T^Vcg4-cf6@O(nZS_epH%p1wU?Rq8O~ zCfyhBP%i4J18(jFrqP(jqQ8V|%{JY2%X& z%)@2i@f+NS%lQ>PNtwEMl3a+eVJoYW_X};7u!U7sl|*tPIh|41Fh}eCnKm}eQm~kr zVZ$a0Vp&>26zJKA(+0x9Y-{_6^#1cJTS(bZl#}oZ>>RN0d9!Q#wnpHd5%}F%V4F)!7)E@vb`o?Cp`yc1i zza;W@|IB|>rRODN(*zfi3{CkzGDH=A%TQH-+N=w?47saWku!8q^w^D9V?ZCI3x;>7 zbpe(Owl%?n4t%4Gh^)jjE^(GZ8vPzro`06KS>(); + +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; +} +