feat: enhance session management with sharing capabilities, real-time event synchronization, and UI updates for session display

This commit is contained in:
Julien Froidefond
2025-11-27 13:34:03 +01:00
parent 9ce2b62bc6
commit 10ff15392f
15 changed files with 1127 additions and 84 deletions

69
src/actions/share.ts Normal file
View File

@@ -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: [] };
}
}

View File

@@ -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) {

View File

@@ -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<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 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
}
}
}

View File

@@ -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) {
</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">
@@ -56,12 +62,20 @@ export default async function SessionPage({ params }: SessionPageProps) {
</div>
</div>
{/* SWOT Board */}
<SwotBoard
{/* Live Session Wrapper */}
<SessionLiveWrapper
sessionId={session.id}
items={session.items}
actions={session.actions}
/>
sessionTitle={session.title}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<SwotBoard
sessionId={session.id}
items={session.items}
actions={session.actions}
/>
</SessionLiveWrapper>
</main>
);
}

View File

@@ -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 (
<main className="mx-auto max-w-7xl px-4 py-8">
@@ -61,45 +50,89 @@ export default async function SessionsPage() {
</Link>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sessions.map((s) => (
<Link key={s.id} href={`/sessions/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.collaborator}</p>
</div>
<span className="text-2xl">📊</span>
</div>
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes sessions ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.items} items
</Badge>
<Badge variant="success">
{s._count.actions} actions
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
))}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Sessions partagées avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</main>
);
}
type SessionWithMeta = Awaited<ReturnType<typeof getSessionsByUserId>>[number];
function SessionCard({ session: s }: { session: SessionWithMeta }) {
return (
<Link href={`/sessions/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.collaborator}</p>
{!s.isOwner && (
<p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{!s.isOwner && (
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
{s.role === 'EDITOR' ? '✏️' : '👁️'}
</Badge>
)}
<span className="text-2xl">📊</span>
</div>
</div>
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.items} items
</Badge>
<Badge variant="success">
{s._count.actions} actions
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
interface LiveIndicatorProps {
isConnected: boolean;
error?: string | null;
}
export function LiveIndicator({ isConnected, error }: LiveIndicatorProps) {
if (error) {
return (
<div className="flex items-center gap-2 rounded-full bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
<span className="h-2 w-2 rounded-full bg-destructive" />
<span>Hors ligne</span>
</div>
);
}
return (
<div
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors ${
isConnected
? 'bg-success/10 text-success'
: 'bg-yellow/10 text-yellow'
}`}
>
<span
className={`h-2 w-2 rounded-full ${
isConnected ? 'bg-success animate-pulse' : 'bg-yellow'
}`}
/>
<span>{isConnected ? 'Live' : 'Connexion...'}</span>
</div>
);
}

View File

@@ -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<string | null>(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 */}
<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) => (
<div
key={share.id}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
title={share.user.name || share.user.email}
>
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
))}
{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 */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -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<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 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 (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session</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">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
<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 actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,4 @@
export { LiveIndicator } from './LiveIndicator';
export { ShareModal } from './ShareModal';
export { SessionLiveWrapper } from './SessionLiveWrapper';

View File

@@ -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<HTMLSpanElement> {
variant?: BadgeVariant;
@@ -25,6 +26,7 @@ const variantStyles: Record<BadgeVariant, string> = {
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<HTMLSpanElement, BadgeProps>(

118
src/hooks/useSessionLive.ts Normal file
View File

@@ -0,0 +1,118 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
export type LiveEvent = {
type: string;
payload: Record<string, unknown>;
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<LiveEvent | 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);
// 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 };
}

View File

@@ -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<string, unknown>
) {
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;
}