From c5e627c5fc1005ec5b25bb41ceeefe0e93b5bb09 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 27 Jul 2026 11:30:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(participants):=20joindre=20participation?= =?UTF-8?q?=E2=86=92profil=20=C3=A0=20travers=20les=20deux=20espaces=20d'i?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une Participation stocke son user comme principal stable `urn:festipod:user:`, alors qu'un UserProfile a pour `id` son NURI `did:ng:`. La jointure brute `partUserIds.includes(u.id)` ne matchait donc jamais en mode connecté → chaque participant s'affichait « inconnu ». - `resolveParticipantUser` : match direct (espace seed demo) puis, à défaut, match sur `normalizeIdentifier(username)` après retrait du préfixe principal. - `USER_PRINCIPAL_PREFIX` : source unique du préfixe, partagée par l'écriture (`currentUserId`) et la lecture, pour qu'elles ne divergent pas. - EventDetailScreen : filtrer soi-même sur `currentUser?.id` (id de profil, même espace que `p.id`) et non sur `currentUserId` (principal). Aussi : épingle `packageManager` pnpm (l'install passe par pnpm, cf. rule_bun-first) — le runtime/test/build restent Bun. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .project/concepts/app-architecture/_debt.md | 1 + package.json | 3 +- .../event/screens/EventDetailScreen.tsx | 10 +++- src/shared/context/FestipodDataContext.tsx | 52 +++++++++++++++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md index 3e75c82..3eb5f3f 100644 --- a/.project/concepts/app-architecture/_debt.md +++ b/.project/concepts/app-architecture/_debt.md @@ -7,3 +7,4 @@ - TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) - TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) - TOUCHED src/shared/context/AccountContext.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/screens/EventDetailScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/package.json b/package.json index 754fbfa..c9a55bb 100644 --- a/package.json +++ b/package.json @@ -64,5 +64,6 @@ "onlyBuiltDependencies": [ "bun" ] - } + }, + "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402" } diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx index d3e4f2c..194a1b5 100644 --- a/src/modules/event/screens/EventDetailScreen.tsx +++ b/src/modules/event/screens/EventDetailScreen.tsx @@ -8,7 +8,7 @@ export function EventDetailScreen() { const { eventId } = useParams(); const { getEvent, - currentUserId, + currentUser, isParticipating, joinEvent, leaveEvent, @@ -30,7 +30,13 @@ export function EventDetailScreen() { })); const isOwner = true; - const knownParticipants = participants.filter(p => p.id !== currentUserId); + // Preview list shows the OTHER participants (deliberate — the total is in the + // header count; the full list at "Voir tous les participants" shows everyone). + // Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the + // same space as `p.id` — whereas `currentUserId` is the `urn:festipod:user:` + // principal, which never equals a profile id in connected mode (so the old + // `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown"). + const knownParticipants = participants.filter(p => p.id !== currentUser?.id); const handleToggleJoin = () => { if (!eventId) return; diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index df5c634..ab8096f 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -99,6 +99,42 @@ function nextId(prefix: string): string { return `${prefix}-${++idCounter}`; } +// The STABLE user-principal prefix. A Participation stores its user (`fp:user`) as +// this principal derived from the login identifier — `urn:festipod:user:` — +// NOT as the UserProfile's `did:ng:` document NURI. `currentUserId` is minted with +// the SAME prefix below, so a participation keyed on it stays consistent with the +// identity the SDK/caps derive. The single source of truth for the prefix, shared +// by the WRITE (currentUserId) and the READ (resolveParticipantUser) so they never +// drift. +const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:'; + +/** + * Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces + * that meet at this join (the root cause of the "unknown participant" bug): + * • a Participation stores `urn:festipod:user:` (the stable + * principal = `currentUserId`), while + * • a UserProfile's `id` is its `did:ng:` document NURI — never that principal. + * The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)` + * for the matching profile (the exact equality `currentUser` resolution already uses). + * So: strip the principal prefix off the participation's userId, and compare the + * remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides + * are the bare seed id (`user-1`), matched directly by `u.id === userId` — which is + * why the direct match is tried FIRST (the seed username `@mariedupont` would not + * normalize to `user-1`). A per-deposit materializer uid (`mint...`, e.g. + * `mrktnoke-rzd699dk`) is a THIRD, unrelated space: it identifies an inbox deposit + * for the count, never a user — it does not participate in this join. + */ +function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined { + // 1) Direct id match — demo/local seed space (`user-1`), or any coincident space. + const direct = users.find(u => u.id === userId); + if (direct) return direct; + // 2) NG principal space: `urn:festipod:user:` → match on normalized username. + const key = userId.startsWith(USER_PRINCIPAL_PREFIX) + ? userId.slice(USER_PRINCIPAL_PREFIX.length) + : userId; + return users.find(u => u.username && normalizeIdentifier(u.username) === key); +} + // NG shape → app type mapping lives in `../data/shapeAdapters` (domain adapters // over the SDK's `watchShape` subjects). @@ -118,8 +154,18 @@ function buildQueries( const getUser = (id: string) => users.find(u => u.id === id); const getEventParticipants = (eventId: string) => { - const partUserIds = participations.filter(p => p.eventId === eventId).map(p => p.userId); - return users.filter(u => partUserIds.includes(u.id)); + // Resolve each of the event's participations to its UserProfile across the two + // id spaces (participation principal vs profile NURI) — see + // `resolveParticipantUser`. A raw `partUserIds.includes(u.id)` join never + // matched in connected mode (principal ≠ NURI) → every participant rendered as + // "unknown". Deduped, returned in `users` order to match the prior contract. + const eventParts = participations.filter(p => p.eventId === eventId); + const resolvedIds = new Set(); + for (const p of eventParts) { + const u = resolveParticipantUser(p.userId, users); + if (u) resolvedIds.add(u.id); + } + return users.filter(u => resolvedIds.has(u.id)); }; const getUserEvents = (userId: string) => { @@ -462,7 +508,7 @@ function useNgData(): FestipodDataContextValue { // participations keyed on it are consistent with reads and isolation. Falls // back to the read profile's IRI only when there is no login (dev/demo). const currentUserId = - (identifier ? `urn:festipod:user:${normalizeIdentifier(identifier)}` : (currentUser?.id || '')); + (identifier ? `${USER_PRINCIPAL_PREFIX}${normalizeIdentifier(identifier)}` : (currentUser?.id || '')); // Identity-first log prefix, reused by every DATA log below (including the // closures defined earlier in this function body — they only execute after // this render has finished, by which point `logPrefix` is initialized).