feat: enhance session management by resolving collaborators to users and integrating CollaboratorDisplay component across motivators and sessions pages

This commit is contained in:
Julien Froidefond
2025-11-28 11:04:58 +01:00
parent 941151553f
commit eaeb1335fa
10 changed files with 237 additions and 33 deletions

BIN
dev.db

Binary file not shown.

View File

@@ -3,7 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getMotivatorSessionById } from '@/services/moving-motivators'; import { getMotivatorSessionById } from '@/services/moving-motivators';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators'; import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge } from '@/components/ui'; import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from './EditableTitle'; import { EditableMotivatorTitle } from './EditableTitle';
interface MotivatorSessionPageProps { interface MotivatorSessionPageProps {
@@ -48,9 +48,13 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
initialTitle={session.title} initialTitle={session.title}
isOwner={session.isOwner} isOwner={session.isOwner}
/> />
<p className="mt-1 text-lg text-muted"> <div className="mt-2">
👤 {session.participant} <CollaboratorDisplay
</p> collaborator={session.resolvedParticipant}
size="lg"
showEmail
/>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="primary"> <Badge variant="primary">

View File

@@ -1,7 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, CardContent, Badge, Button } from '@/components/ui'; import { Card, CardContent, Badge, Button, CollaboratorDisplay } from '@/components/ui';
export default async function MotivatorsPage() { export default async function MotivatorsPage() {
const session = await auth(); const session = await auth();
@@ -95,7 +95,9 @@ function SessionCard({ session: s }: { session: SessionWithMeta }) {
<h3 className="font-semibold text-foreground line-clamp-1"> <h3 className="font-semibold text-foreground line-clamp-1">
{s.title} {s.title}
</h3> </h3>
<p className="text-sm text-muted">{s.participant}</p> <div className="mt-1">
<CollaboratorDisplay collaborator={s.resolvedParticipant} size="sm" />
</div>
{!s.isOwner && ( {!s.isOwner && (
<p className="text-xs text-muted mt-1"> <p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email} Par {s.user.name || s.user.email}

View File

@@ -2,7 +2,7 @@
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Card, Badge, Button, Modal, ModalFooter, Input } from '@/components/ui'; import { Card, Badge, Button, Modal, ModalFooter, Input, CollaboratorDisplay } 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';
@@ -20,10 +20,20 @@ interface Share {
user: ShareUser; user: ShareUser;
} }
interface ResolvedCollaborator {
raw: string;
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
}
interface SwotSession { interface SwotSession {
id: string; id: string;
title: string; title: string;
collaborator: string; collaborator: string;
resolvedCollaborator: ResolvedCollaborator;
updatedAt: Date; updatedAt: Date;
isOwner: boolean; isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR'; role: 'OWNER' | 'VIEWER' | 'EDITOR';
@@ -37,6 +47,7 @@ interface MotivatorSession {
id: string; id: string;
title: string; title: string;
participant: string; participant: string;
resolvedParticipant: ResolvedCollaborator;
updatedAt: Date; updatedAt: Date;
isOwner: boolean; isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR'; role: 'OWNER' | 'VIEWER' | 'EDITOR';
@@ -60,20 +71,45 @@ function getParticipant(session: AnySession): string {
: (session as MotivatorSession).participant; : (session as MotivatorSession).participant;
} }
// Group sessions by participant // Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
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
function getGroupKey(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
// If we have a matched user, use their ID as key (ensures same person = same group)
if (resolved.matchedUser) {
return `user:${resolved.matchedUser.id}`;
}
// Otherwise, normalize the raw string
return `raw:${resolved.raw.trim().toLowerCase()}`;
}
// Group sessions by participant (using matched user ID when available)
function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> { function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
const grouped = new Map<string, AnySession[]>(); const grouped = new Map<string, AnySession[]>();
sessions.forEach((session) => { sessions.forEach((session) => {
const participant = getParticipant(session).trim().toLowerCase(); const key = getGroupKey(session);
const displayName = getParticipant(session).trim();
// Use normalized key but store with original display name const existing = grouped.get(key);
const existing = grouped.get(participant);
if (existing) { if (existing) {
existing.push(session); existing.push(session);
} else { } else {
grouped.set(participant, [session]); grouped.set(key, [session]);
} }
}); });
@@ -155,16 +191,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{sortedPersons.map(([personKey, sessions]) => { {sortedPersons.map(([personKey, sessions]) => {
const displayName = getParticipant(sessions[0]); const resolved = getResolvedCollaborator(sessions[0]);
return ( return (
<section key={personKey}> <section key={personKey}>
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2"> <h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-3">
<span className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary text-sm"> <CollaboratorDisplay collaborator={resolved} size="md" />
{displayName.charAt(0).toUpperCase()} <Badge variant="primary">
</span> {sessions.length} atelier{sessions.length > 1 ? 's' : ''}
{displayName}
<Badge variant="primary" className="ml-2">
{sessions.length}
</Badge> </Badge>
</h2> </h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -336,12 +369,15 @@ function SessionCard({ session }: { session: AnySession }) {
</div> </div>
{/* Participant + Owner info */} {/* Participant + Owner info */}
<p className="text-sm text-muted mb-3 line-clamp-1"> <div className="mb-3 flex items-center gap-2">
👤 {participant} <CollaboratorDisplay
collaborator={getResolvedCollaborator(session)}
size="sm"
/>
{!session.isOwner && ( {!session.isOwner && (
<span className="text-xs"> · par {session.user.name || session.user.email}</span> <span className="text-xs text-muted">· par {session.user.name || session.user.email}</span>
)} )}
</p> </div>
{/* Footer: Stats + Avatars + Date */} {/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">

View File

@@ -5,7 +5,7 @@ import { getSessionById } from '@/services/sessions';
import { SwotBoard } from '@/components/swot/SwotBoard'; import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration'; import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableTitle } from '@/components/session'; import { EditableTitle } from '@/components/session';
import { Badge } from '@/components/ui'; import { Badge, CollaboratorDisplay } from '@/components/ui';
interface SessionPageProps { interface SessionPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -49,9 +49,13 @@ export default async function SessionPage({ params }: SessionPageProps) {
initialTitle={session.title} initialTitle={session.title}
isOwner={session.isOwner} isOwner={session.isOwner}
/> />
<p className="mt-1 text-lg text-muted"> <div className="mt-2">
👤 {session.collaborator} <CollaboratorDisplay
</p> collaborator={session.resolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge> <Badge variant="primary">{session.items.length} items</Badge>

View File

@@ -0,0 +1,82 @@
import { getGravatarUrl } from '@/lib/gravatar';
interface CollaboratorDisplayProps {
collaborator: {
raw: string;
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
};
size?: 'sm' | 'md' | 'lg';
showEmail?: boolean;
}
const sizeConfig = {
sm: { avatar: 24, text: 'text-sm', gap: 'gap-1.5' },
md: { avatar: 32, text: 'text-base', gap: 'gap-2' },
lg: { avatar: 40, text: 'text-lg', gap: 'gap-3' },
};
export function CollaboratorDisplay({
collaborator,
size = 'md',
showEmail = false,
}: CollaboratorDisplayProps) {
const { raw, matchedUser } = collaborator;
const config = sizeConfig[size];
// If we have a matched user, show their avatar and name
if (matchedUser) {
const displayName = matchedUser.name || matchedUser.email.split('@')[0];
return (
<div className={`flex items-center ${config.gap}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getGravatarUrl(matchedUser.email, config.avatar * 2)}
alt={displayName}
width={config.avatar}
height={config.avatar}
className="rounded-full border border-border"
/>
<div className="min-w-0">
<span className={`${config.text} font-medium text-foreground truncate block`}>
{displayName}
</span>
{showEmail && matchedUser.name && (
<span className="text-xs text-muted truncate block">{matchedUser.email}</span>
)}
</div>
</div>
);
}
// No match - just show the raw name with a default person icon
return (
<div className={`flex items-center ${config.gap}`}>
<div
className="flex items-center justify-center rounded-full border border-border bg-card-hover"
style={{ width: config.avatar, height: config.avatar }}
>
<svg
className="text-muted"
style={{ width: config.avatar * 0.5, height: config.avatar * 0.5 }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<span className={`${config.text} text-muted truncate`}>{raw}</span>
</div>
);
}

View File

@@ -2,6 +2,7 @@ export { Avatar } from './Avatar';
export { Badge } from './Badge'; export { Badge } from './Badge';
export { Button } from './Button'; export { Button } from './Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'; export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { CollaboratorDisplay } from './CollaboratorDisplay';
export { Input } from './Input'; export { Input } from './Input';
export { Modal, ModalFooter } from './Modal'; export { Modal, ModalFooter } from './Modal';
export { Textarea } from './Textarea'; export { Textarea } from './Textarea';

View File

@@ -60,6 +60,53 @@ export async function getUserByEmail(email: string) {
}); });
} }
// Check if string looks like an email
function isEmail(str: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
}
export interface ResolvedCollaborator {
raw: string; // Original value (name or email)
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
}
// Resolve collaborator string to user - try email first, then name
export async function resolveCollaborator(collaborator: string): Promise<ResolvedCollaborator> {
const trimmed = collaborator.trim();
// 1. Try email match first
if (isEmail(trimmed)) {
const user = await prisma.user.findUnique({
where: { email: trimmed.toLowerCase() },
select: { id: true, email: true, name: true },
});
if (user) {
return { raw: collaborator, matchedUser: user };
}
}
// 2. Fallback: try matching by name (case-insensitive via raw query for SQLite)
// SQLite LIKE is case-insensitive by default for ASCII
const users = await prisma.user.findMany({
where: {
name: { not: null },
},
select: { id: true, email: true, name: true },
});
const normalizedSearch = trimmed.toLowerCase();
const userByName = users.find(
(u) => u.name?.toLowerCase() === normalizedSearch
) || null;
return { raw: collaborator, matchedUser: userByName };
}
export async function getUserById(id: string) { export async function getUserById(id: string) {
return prisma.user.findUnique({ return prisma.user.findUnique({
where: { id }, where: { id },

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, MotivatorType } from '@prisma/client'; import type { ShareRole, MotivatorType } from '@prisma/client';
// ============================================ // ============================================
@@ -56,9 +57,19 @@ export async function getMotivatorSessionsByUserId(userId: string) {
sharedAt: s.createdAt, sharedAt: s.createdAt,
})); }));
return [...ownedWithRole, ...sharedWithRole].sort( const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() (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 getMotivatorSessionById(sessionId: string, userId: string) { export async function getMotivatorSessionById(sessionId: string, userId: string) {
@@ -92,7 +103,10 @@ export async function getMotivatorSessionById(sessionId: string, userId: string)
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR'; const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit }; // 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) // Check if user can access session (owner or shared)

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { SwotCategory, ShareRole } from '@prisma/client'; import type { SwotCategory, ShareRole } from '@prisma/client';
// ============================================ // ============================================
@@ -58,9 +59,19 @@ export async function getSessionsByUserId(userId: string) {
sharedAt: s.createdAt, sharedAt: s.createdAt,
})); }));
return [...ownedWithRole, ...sharedWithRole].sort( const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
// Resolve collaborators to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedCollaborator: await resolveCollaborator(s.collaborator),
}))
);
return sessionsWithResolved;
} }
export async function getSessionById(sessionId: string, userId: string) { export async function getSessionById(sessionId: string, userId: string) {
@@ -104,7 +115,10 @@ export async function getSessionById(sessionId: string, userId: string) {
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR'; const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit }; // Resolve collaborator to user if it's an email
const resolvedCollaborator = await resolveCollaborator(session.collaborator);
return { ...session, isOwner, role, canEdit, resolvedCollaborator };
} }
// Check if user can access session (owner or shared) // Check if user can access session (owner or shared)