From eaeb1335fa952e1256b8831fd25a72884bb452b5 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 28 Nov 2025 11:04:58 +0100 Subject: [PATCH] feat: enhance session management by resolving collaborators to users and integrating CollaboratorDisplay component across motivators and sessions pages --- dev.db | Bin 229376 -> 233472 bytes src/app/motivators/[id]/page.tsx | 12 ++-- src/app/motivators/page.tsx | 6 +- src/app/sessions/WorkshopTabs.tsx | 74 ++++++++++++++----- src/app/sessions/[id]/page.tsx | 12 ++-- src/components/ui/CollaboratorDisplay.tsx | 82 ++++++++++++++++++++++ src/components/ui/index.ts | 1 + src/services/auth.ts | 47 +++++++++++++ src/services/moving-motivators.ts | 18 ++++- src/services/sessions.ts | 18 ++++- 10 files changed, 237 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/CollaboratorDisplay.tsx diff --git a/dev.db b/dev.db index 06fbd62dcadbd5c1ef2e80bb8c64a6c134459f02..156f36c782476ed52515c21755d231aa1f9c376d 100644 GIT binary patch delta 1097 zcmb7COHUI~6rQ=a(1LWPrD#$s3IoO{(oE;kc7~V{By>e;kQg;+?PI`}w$s+4U}Dmu z8xt3hMsM8FxX{E9W4f5=3PVgZER#TRK>bcvDrmtuu(*}p? zRb-waUxIoSA9-KLv_gf9rsDC8C!FE%vd>r^)6|Iv8NWJYpeX17PJQk<8JmL5U>cci zpaXzuynYpQ7L_53q9|QOYv90NPiRN2C3tGuSr}IiaoXe{@<~$SClF4vH%Z2k= zj*%Hd{CbCQaofVF$e*z3ay)Z=ifCJRb1~SzbT=0eqim6yY<1{zN~a2NCs+ zjNz*t(D2NHz6g+MmgH?QzN*h6`Y6D2)j7wxeY{wm3p;ba%<*ookE_o4%{l7HLA(A~ zfQ24tCl--80~vC0w)~>AIKWqn;@T{VwjWpY^A+HXj>yu%{@3(_#k^~I=ufjSPc>Pw x(|}47w2w;ToFfz_Al4Ar&*)tOHxEHzs|8!e6$gacJNrbod*?7hC3$ZU{{ci|Nge;ih+T_VxodQW7Nikh5o!i9uwbY2L1@X&3xjU1qC?yJR2pMIqXG6 z-R<4olXEjO^HWj`%?yAbwWuJ!)FjiOBrhu~C9x_6gpCZ0Omz(nb&V`S3@oiojIE5# z^-PTov<(cb3=Aea+Dn??Re?it^4hr7ERCv+9Ghd}Lj**zIe>Yx?|<3mNBP?yKrcBqlSiBkdy*CRgIP!Bh8ZmO%>*|97 zWV&MxljQU?eC% zfEY9XM_?#3Sn&Vm|Hl83O^hX<=>(%9&@JqY+Z7#|FYrzOxSmOv*@#OO -

- 👤 {session.participant} -

+
+ +
diff --git a/src/app/motivators/page.tsx b/src/app/motivators/page.tsx index db474fc..686de42 100644 --- a/src/app/motivators/page.tsx +++ b/src/app/motivators/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; 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() { const session = await auth(); @@ -95,7 +95,9 @@ function SessionCard({ session: s }: { session: SessionWithMeta }) {

{s.title}

-

{s.participant}

+
+ +
{!s.isOwner && (

Par {s.user.name || s.user.email} diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx index 9f3bda6..e695340 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from 'react'; 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 { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators'; @@ -20,10 +20,20 @@ interface Share { user: ShareUser; } +interface ResolvedCollaborator { + raw: string; + matchedUser: { + id: string; + email: string; + name: string | null; + } | null; +} + interface SwotSession { id: string; title: string; collaborator: string; + resolvedCollaborator: ResolvedCollaborator; updatedAt: Date; isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; @@ -37,6 +47,7 @@ interface MotivatorSession { id: string; title: string; participant: string; + resolvedParticipant: ResolvedCollaborator; updatedAt: Date; isOwner: boolean; role: 'OWNER' | 'VIEWER' | 'EDITOR'; @@ -60,20 +71,45 @@ function getParticipant(session: AnySession): string { : (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 { const grouped = new Map(); sessions.forEach((session) => { - const participant = getParticipant(session).trim().toLowerCase(); - const displayName = getParticipant(session).trim(); + const key = getGroupKey(session); - // Use normalized key but store with original display name - const existing = grouped.get(participant); + const existing = grouped.get(key); if (existing) { existing.push(session); } else { - grouped.set(participant, [session]); + grouped.set(key, [session]); } }); @@ -155,16 +191,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr ) : (

{sortedPersons.map(([personKey, sessions]) => { - const displayName = getParticipant(sessions[0]); + const resolved = getResolvedCollaborator(sessions[0]); return (
-

- - {displayName.charAt(0).toUpperCase()} - - {displayName} - - {sessions.length} +

+ + + {sessions.length} atelier{sessions.length > 1 ? 's' : ''}

@@ -336,12 +369,15 @@ function SessionCard({ session }: { session: AnySession }) {
{/* Participant + Owner info */} -

- 👤 {participant} +

+ {!session.isOwner && ( - · par {session.user.name || session.user.email} + · par {session.user.name || session.user.email} )} -

+
{/* Footer: Stats + Avatars + Date */}
diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index 98ac952..2f015ba 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -5,7 +5,7 @@ import { getSessionById } from '@/services/sessions'; import { SwotBoard } from '@/components/swot/SwotBoard'; import { SessionLiveWrapper } from '@/components/collaboration'; import { EditableTitle } from '@/components/session'; -import { Badge } from '@/components/ui'; +import { Badge, CollaboratorDisplay } from '@/components/ui'; interface SessionPageProps { params: Promise<{ id: string }>; @@ -49,9 +49,13 @@ export default async function SessionPage({ params }: SessionPageProps) { initialTitle={session.title} isOwner={session.isOwner} /> -

- 👤 {session.collaborator} -

+
+ +
{session.items.length} items diff --git a/src/components/ui/CollaboratorDisplay.tsx b/src/components/ui/CollaboratorDisplay.tsx new file mode 100644 index 0000000..245e2cc --- /dev/null +++ b/src/components/ui/CollaboratorDisplay.tsx @@ -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 ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {displayName} +
+ + {displayName} + + {showEmail && matchedUser.name && ( + {matchedUser.email} + )} +
+
+ ); + } + + // No match - just show the raw name with a default person icon + return ( +
+
+ + + +
+ {raw} +
+ ); +} + diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index d30105a..bedced6 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -2,6 +2,7 @@ export { Avatar } from './Avatar'; export { Badge } from './Badge'; export { Button } from './Button'; export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'; +export { CollaboratorDisplay } from './CollaboratorDisplay'; export { Input } from './Input'; export { Modal, ModalFooter } from './Modal'; export { Textarea } from './Textarea'; diff --git a/src/services/auth.ts b/src/services/auth.ts index a22361c..2361fbd 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -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 { + 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) { return prisma.user.findUnique({ where: { id }, diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts index 362b979..6118d73 100644 --- a/src/services/moving-motivators.ts +++ b/src/services/moving-motivators.ts @@ -1,4 +1,5 @@ import { prisma } from '@/services/database'; +import { resolveCollaborator } from '@/services/auth'; import type { ShareRole, MotivatorType } from '@prisma/client'; // ============================================ @@ -56,9 +57,19 @@ export async function getMotivatorSessionsByUserId(userId: string) { sharedAt: s.createdAt, })); - return [...ownedWithRole, ...sharedWithRole].sort( + const allSessions = [...ownedWithRole, ...sharedWithRole].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); + + // Resolve participants to users + const sessionsWithResolved = await Promise.all( + allSessions.map(async (s) => ({ + ...s, + resolvedParticipant: await resolveCollaborator(s.participant), + })) + ); + + return sessionsWithResolved; } export async function 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 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) diff --git a/src/services/sessions.ts b/src/services/sessions.ts index b174bc4..466b7e5 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -1,4 +1,5 @@ import { prisma } from '@/services/database'; +import { resolveCollaborator } from '@/services/auth'; import type { SwotCategory, ShareRole } from '@prisma/client'; // ============================================ @@ -58,9 +59,19 @@ export async function getSessionsByUserId(userId: string) { sharedAt: s.createdAt, })); - return [...ownedWithRole, ...sharedWithRole].sort( + const allSessions = [...ownedWithRole, ...sharedWithRole].sort( (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) { @@ -104,7 +115,10 @@ export async function getSessionById(sessionId: string, userId: string) { const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const); 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)