diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md new file mode 100644 index 0000000..6863127 --- /dev/null +++ b/.project/concepts/app-architecture/_debt.md @@ -0,0 +1,9 @@ +# Doc-debt — app-architecture + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/event/screens/EventDetailScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/modules/user/screens/UpdateProfileScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/data-layer/_debt.md b/.project/concepts/data-layer/_debt.md new file mode 100644 index 0000000..8de5f5c --- /dev/null +++ b/.project/concepts/data-layer/_debt.md @@ -0,0 +1,8 @@ +# Doc-debt — data-layer + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/shared/utils/currentPrincipal.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx index 4c8c515..f4d0dc4 100644 --- a/src/modules/event/screens/EventDetailScreen.tsx +++ b/src/modules/event/screens/EventDetailScreen.tsx @@ -40,23 +40,23 @@ export function EventDetailScreen() { const handleToggleJoin = () => { if (!eventId) return; - // The optimistic toast stays immediate (the overlay already reflects the - // change), but the write can genuinely FAIL — a participation document that - // cannot be recorded throws instead of reading empty forever, and an - // unconfirmed withdrawal throws too. Surface it rather than leave the user - // with a success message and nothing written. + // THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before + // the call had settled, so a sign-up that wrote nothing still read as + // « Tu participes ». The screen's own list flips immediately anyway (the data + // layer's optimistic overlay), so nothing is lost by waiting for the truth. + const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone); const failed = (message: string) => (err: unknown) => { console.error('[EventDetail] participation write failed:', err); showToast(message, 'error'); }; if (joined) { void Promise.resolve(leaveEvent(eventId)) + .then(confirmed('Participation annulée', 'info')) .catch(failed("La désinscription n'a pas pu être enregistrée")); - showToast('Participation annulée', 'info'); } else { void Promise.resolve(joinEvent(eventId)) + .then(confirmed('Tu participes à cet événement', 'success')) .catch(failed("L'inscription n'a pas pu être enregistrée")); - showToast('Tu participes à cet événement', 'success'); } }; diff --git a/src/modules/user/screens/UpdateProfileScreen.tsx b/src/modules/user/screens/UpdateProfileScreen.tsx index 09f5ba4..e68feec 100644 --- a/src/modules/user/screens/UpdateProfileScreen.tsx +++ b/src/modules/user/screens/UpdateProfileScreen.tsx @@ -18,9 +18,18 @@ export function UpdateProfileScreen() { const handleSave = () => { const fullName = `${firstName} ${lastName}`.trim(); const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase(); - updateProfile({ name: fullName, initials, username, city, bio }); - showToast('Profil mis à jour', 'success'); - navigate('/profile'); + // The edit REJECTS when no profile of mine is resolved — it refuses to write + // into someone else's document. So confirm and leave the screen only once the + // write has settled, never before. + void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio })) + .then(() => { + showToast('Profil mis à jour', 'success'); + navigate('/profile'); + }) + .catch((err: unknown) => { + console.error('[UpdateProfile] profile write failed:', err); + showToast("Le profil n'a pas pu être enregistré", 'error'); + }); }; return ( diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 0fba0f3..baf5563 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -10,8 +10,6 @@ import type { import { depositRegistration, depositLeave, - buildNotification, - insertNotification, readRegistrationNotifications, materializeAttendance, deleteParticipation, @@ -116,24 +114,44 @@ function nextId(prefix: string): string { // old form, or those participations render as "participant inconnu". const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:'; -/** Waits between attempts at resolving the owned-event set (see its effect). */ +/** Waits between attempts at resolving an owned-document set (see the effects). */ const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000]; /** - * 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. + * What a BRAND-NEW profile is created with. + * + * A profile is entirely Festipod's own object — the data layer knows nothing of + * pseudos or display names — but the UserProfile SHEX shape makes `fp:name`, + * `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written + * empty. And nothing about the person is known at sign-in: the identity the + * session signed in as is OPAQUE (no display name; it is never parsed, never + * rendered, never written into an entity). So the three required fields carry a + * PLACEHOLDER that reads on screen as "not filled in yet": none of them is a + * person's name or handle, and none is derived from the identity. The user + * replaces them through `updateProfile` (UpdateProfileScreen). + */ +const UNSET_PROFILE = { + name: 'Profil à compléter', + initials: '?', + username: '(pseudo non défini)', +} as const; + +/** + * Resolve a Participation's `fp:user` to its UserProfile. + * + * TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document + * NURI, so the DIRECT match `u.id === userId` answers — and it is tried first. + * The demo/local fixtures coincide there too (both sides are the bare `user-1`). + * + * LEGACY ONLY: participations written under the earlier scheme carry + * `urn:festipod:user:`, which matches no profile id. For those + * — and ONLY those — the handle is stripped off and compared to + * `normalizeIdentifier(profile.username)`. This username bridge is a READ-side + * survival for old data; it plays NO part in deciding who the current user is + * (my profile is the profile document I own — see the "WHO AM I" block below). + * + * 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. */ function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined { // 1) Direct id match — demo/local seed space (`user-1`), or any coincident space. @@ -304,10 +322,10 @@ function useNgData(): FestipodDataContextValue { const eventQuery = useShapeQuery(FpEventShapeType, 'public'); const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected'); const partQuery = useShapeQuery(FpParticipationShapeType, 'protected'); - const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]); // The RAW reactive sets straight from `watchShape` (before the optimistic - // overlay). The exposed `events`/`participations` merge these with the pending - // overlay below — see the "OPTIMISTIC OVERLAY" block. + // overlay). The exposed `events`/`users`/`participations` merge these with the + // pending overlay below — see the "OPTIMISTIC OVERLAY" block. + const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]); const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]); const reactiveParticipations = React.useMemo( () => adaptParticipations(partQuery.data), @@ -331,9 +349,17 @@ function useNgData(): FestipodDataContextValue { // dropped from pendingRemoves. No re-query, no interval — the overlay only reacts // to `watchShape`'s own pushes. const [pendingAddEvents, setPendingAddEvents] = useState([]); + const [pendingAddUsers, setPendingAddUsers] = useState([]); const [pendingAddParticipations, setPendingAddParticipations] = useState([]); const [pendingRemoveIds, setPendingRemoveIds] = useState>(() => new Set()); + const users = React.useMemo(() => { + if (pendingAddUsers.length === 0) return reactiveUsers; + const seen = new Set(reactiveUsers.map(u => u.id)); + const extra = pendingAddUsers.filter(u => !seen.has(u.id)); + return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers; + }, [reactiveUsers, pendingAddUsers]); + const events = React.useMemo(() => { if (pendingAddEvents.length === 0) return reactiveEvents; const seen = new Set(reactiveEvents.map(e => e.id)); @@ -365,6 +391,15 @@ function useNgData(): FestipodDataContextValue { }); }, [reactiveEvents, pendingAddEvents]); + useEffect(() => { + if (pendingAddUsers.length === 0) return; + const live = new Set(reactiveUsers.map(u => u.id)); + setPendingAddUsers(prev => { + const next = prev.filter(u => !live.has(u.id)); + return next.length === prev.length ? prev : next; + }); + }, [reactiveUsers, pendingAddUsers]); + useEffect(() => { if (pendingAddParticipations.length === 0) return; const live = new Set(reactiveParticipations.map(p => p.id)); @@ -406,6 +441,17 @@ function useNgData(): FestipodDataContextValue { // and writes `participantCount` on THAT (owned) doc — never on someone else's. const [ownedEventIds, setOwnedEventIds] = useState([]); + // WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests. + // `null` means NOT ANSWERED YET (the listing has not landed, or it failed): + // "unknown" and "I own nothing" are indistinguishable as an empty array, and + // only one of them may lead to creating a profile. Nothing downstream may read + // `null` as an empty set. + const [myProtectedDocs, setMyProtectedDocs] = useState(null); + // The profile document THIS session created for the signed-in person. Set once, + // by the creation effect; it settles "which of my profile documents is mine" + // without looking at any field of any profile. + const [myProfileDocId, setMyProfileDocId] = useState(''); + /** * Fold documents THIS SESSION just created into the owned set. * @@ -501,7 +547,12 @@ function useNgData(): FestipodDataContextValue { if (hasTriedAutoSeed.current) return; if (!ready) return; if (!readReady) return; // still syncing — do NOT mistake pending for empty - const walletHasData = events.length > 0 || users.length > 0; + // MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a + // brand-new wallet, so counting it here would permanently disable the seed on + // exactly the wallets it exists for. The question the gate asks is "does this + // wallet already hold something worth preserving", and my own empty profile + // does not. + const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId); if (!shouldAutoSeed(walletHasData)) { console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`); return; @@ -516,40 +567,51 @@ function useNgData(): FestipodDataContextValue { // The reactive `watchShape` reads pick the seeded per-entity docs up on their // own (each createEntityDoc appends to the scope index → the container-index // subscription re-resolves → the new docs enter the read). No registerDoc/relist. - }, [ready, readReady, events.length, users.length, claimOwnedEventDocs]); + }, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]); // --- Derived --- // WHO AM I — answered in TWO id spaces that must not be confused. // // (1) `currentPrincipal` — what signing in returned. Known as soon as the one // identity await settles, i.e. before any document has been read. It names - // a PERSON. It is for display and log attribution; no data call takes it, - // and it is never written into an entity. - // (2) `currentUserId` — the app's own entity space: the `@id` of the profile - // DOCUMENT read back in the protected scope (a doc NURI). This is what a - // Participation's `fp:user` carries and what `resolveParticipantUser` - // matches directly, so it is the only value a mutation may write. It stays - // empty until the protected read lands — mutations that need it refuse - // rather than write an entity the read would drop. + // a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never + // written into an entity and never handed to a data call. It exists here + // for display of "am I signed in" and for log attribution. + // (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE + // DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries + // and what `resolveParticipantUser` matches directly, so it is the only + // value a mutation may write. // - // THE JOIN between the two is explicit and lives HERE, in one place: a profile - // belongs to the signed-in person when its username normalizes to the - // principal — the same bridge `resolveParticipantUser` uses for the legacy - // `urn:festipod:user:` space. Nothing merges the spaces: the principal selects - // a profile, it never stands in for one. + // THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is + // Festipod's own object; the identity says nothing about it. **My profile is + // the profile document I OWN** — `listMyEntityDocs('protected')` answers "which + // documents are mine", and the UserProfile among them is mine. No field of any + // profile takes part in the answer: no username comparison, no normalization, + // no positional pick. + // + // THREE OUTCOMES, and "somebody else's profile" is not one of them: + // • exactly one owned profile → that is me; + // • none → I have no profile yet, and the creation effect below makes one + // (until it lands, `currentUserId` is '' and the mutations that need it + // REJECT — they never write an entity keyed on nobody); + // • several, none of them created by this session → genuinely AMBIGUOUS (the + // opt-in fixture seed writes its profiles into my own protected scope), so + // the answer is NO PROFILE, said loudly. Picking one would be picking a + // person at random and calling them "you". const currentPrincipal = useCurrentPrincipal(); - const currentUser = - (currentPrincipal - ? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal) - : undefined) - // No profile answers to the signed-in person (the wallet holds fixtures, or - // the profile read has not landed): fall back to the demo-seed pick. KNOWN - // HAZARD — this GUESSES a profile, so the app can show the wrong person as - // "you" while the real answer has simply not been read yet. Note what is and - // is not guessed: the identity itself never is (it is exactly what - // `ensureIdentity()` returned); only the profile it selects can be wrong. - || users.find(u => u.username === '@mariedupont') - || users[0]; + /** The profiles I own: the reactive profiles whose document is one of mine. */ + const myOwnedProfiles = React.useMemo(() => { + if (myProtectedDocs === null) return null; // UNKNOWN — never "none" + const mine = new Set(myProtectedDocs); + return users.filter(u => mine.has(u.id)); + }, [users, myProtectedDocs]); + const currentUser = React.useMemo(() => { + // This session created it → no ambiguity possible, whatever else is owned. + if (myProfileDocId) return users.find(u => u.id === myProfileDocId); + if (myOwnedProfiles === null) return undefined; // not answered yet + if (myOwnedProfiles.length === 1) return myOwnedProfiles[0]; + return undefined; // none, or ambiguous + }, [users, myProfileDocId, myOwnedProfiles]); const currentUserId = 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 @@ -573,6 +635,106 @@ function useNgData(): FestipodDataContextValue { } const selectedUser = users.find(u => u.id === selectedUserId); + // --- MY PROFILE: which documents are mine ---------------------------------- + // Resolve the PROTECTED documents this identity owns. That set is the whole + // basis of "which profile is mine", and it is also what decides whether a + // profile has to be CREATED — so a failure here must never look like an answer: + // a rejection means UNKNOWN, and reading it as "I own nothing" would create a + // second profile for someone who already has one. Retried; if it still will not + // answer, the set stays `null` (no profile resolved, no profile created) and it + // is said loudly. + useEffect(() => { + if (!ready) return; + let cancelled = false; + (async () => { + for (let attempt = 0; !cancelled; attempt++) { + try { + const mine = await listMyEntityDocs('protected'); + if (cancelled) return; + setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]); + return; + } catch (err) { + const wait = OWNED_RETRY_BACKOFF_MS[attempt]; + if (wait === undefined) { + console.error( + `${logPrefix} my-protected-documents resolution FAILED after ` + + `${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` + + `not absent: no profile will be resolved and none will be created until it is known:`, + err, + ); + return; + } + console.warn( + `${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` + + `retrying in ${wait}ms:`, + err, + ); + await new Promise(r => setTimeout(r, wait)); + } + } + })(); + return () => { cancelled = true; }; + }, [ready]); + + // --- MY PROFILE: create one when I have none ------------------------------- + // A profile is Festipod's own object, and signing in produces none — so the + // first time a person signs in, the app makes theirs. Gated on BOTH the + // protected read having settled (`userQuery.isSuccess`: synced-and-empty, not + // still-syncing) and the owned-document set being KNOWN, because "I have no + // profile" is only true when both have answered. Single-shot per session; on + // failure the guard is released, so a later change to the owned set retries. + const hasTriedProfileCreate = useRef(false); + useEffect(() => { + if (!ready) return; + if (hasTriedProfileCreate.current) return; + if (!userQuery.isSuccess) return; + if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none" + if (myOwnedProfiles.length > 0) return; // I already have one (or more) + hasTriedProfileCreate.current = true; + (async () => { + console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`); + const graph = await createEntityDoc('protected'); + // The three fields the UserProfile shape makes mandatory, written with the + // "not filled in yet" placeholders — nothing here comes from the identity. + await writeEntity(graph, ENTITY_TYPE.user, { + name: str(UNSET_PROFILE.name), + initials: str(UNSET_PROFILE.initials), + username: str(UNSET_PROFILE.username), + }); + // This document is MINE — claimed explicitly, so it is recognized as my + // profile whatever else the protected scope holds (fixtures included). + setMyProfileDocId(graph); + setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]); + // OPTIMISTIC OVERLAY, same pattern as events/participations: surface the + // profile immediately so `currentUserId` resolves without waiting for the + // broker push; the reconciliation effect drops it once the read carries it. + const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE }; + setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile])); + })().catch(err => { + hasTriedProfileCreate.current = false; + console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err); + }); + }, [ready, userQuery.isSuccess, myOwnedProfiles]); + + // --- MY PROFILE: say it when the answer is ambiguous ------------------------ + // Several profile documents are mine and none was created by this session (the + // opt-in fixture seed writes its profiles into my own protected scope). There + // is no honest way to tell which one is the person at the keyboard, so nothing + // is picked — `currentUser` stays undefined and the mutations that need it + // refuse. Silence here would look exactly like "the read has not landed". + const warnedAmbiguousProfile = useRef(false); + useEffect(() => { + if (myProfileDocId) return; + if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return; + if (warnedAmbiguousProfile.current) return; + warnedAmbiguousProfile.current = true; + console.error( + `${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` + + `this session — which one is me cannot be told apart, so NO profile is resolved (a fixture ` + + `seed run on this wallet is the usual cause). Sign-up and profile edition will refuse.`, + ); + }, [myOwnedProfiles, myProfileDocId]); + // --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) ------- // The event OWNER's session materializes its OWN events' inbox deposits into // (1) the correct `participantCount` on its OWN event doc, and @@ -866,14 +1028,19 @@ function useNgData(): FestipodDataContextValue { const joinEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid); - // A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) — - // writing one without it produces an entity the ORM drops on read (the - // participation silently never round-trips). Refuse an empty principal rather - // than persist a broken participation. The caller resolves a real user id (the - // current user's IRI) before joining. + // A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) — + // writing one without it produces an entity the read drops (the participation + // silently never round-trips). REJECT rather than return: returning here made + // the sign-up a no-op that wrote nothing, threw nothing and let the screen + // congratulate the user. The cause is named, because it is actionable: no + // profile of mine is resolved yet. if (!uid) { - console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`); - return; + const msg = + `joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` + + `so a Participation would carry no fp:user and would never round-trip. ` + + `Wait for the profile to be created/read, or resolve the ambiguity reported above.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); } // IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set. // The reactive participation set can lag a just-written participation, so a @@ -921,16 +1088,24 @@ function useNgData(): FestipodDataContextValue { // The joiner's sole writes are: their OWN participation doc (above) + the // inbox DEPOSIT (below). While the owner is offline the count doesn't advance // for others — accepted eventual behaviour (brief §E.2); nothing is lost. - // 2) Notify the host: deposit into the event/host inbox via the GENERIC lib - // inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant - // when connected, anonymous (null) otherwise. Best-effort: a failed deposit - // must not roll back a successful registration. + // 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)` + // is what the surface publishes for reaching a document's owner — anyone + // may deposit, only the owner reads. The host's notification is then built + // on the OWNER's side, out of the deposits it reads from its own event's + // inbox (`readRegistrationNotifications`, in the materializer above). + // + // Nothing else is written here. A host FpNotification used to be minted at + // this point into the JOINER's own protected scope with `recipient` set to + // the event — a document the host cannot read, and never will: the joiner + // owns it and the surface has no way to hand it over. It also pushed that + // notification into THIS session's own list, so the joiner saw a + // "new participant" notice addressed to someone else. Both are gone; the + // deposit alone carries the news, and it reaches its reader. + // + // Best-effort: a failed deposit must not roll back a successful + // registration (the participation document is already written). try { const registrantId = uid || null; // no current user → anonymous deposit - // Recipient = the event host. The Event shape carries no host IRI yet, so - // we key the host inbox/notification on the eventId (the host of THAT - // event). This is the domain injection the generic lib deliberately omits. - const recipientId = eventId; // The event's `@id` IS its document NURI, and a deposit NAMES that document // — the joiner resolves no inbox and holds no address. // Carry the joiner's participation-doc NURI so the owner (if a connection) @@ -940,24 +1115,11 @@ function useNgData(): FestipodDataContextValue { `event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` + `materializing this deposit on its own doc, at its next connection)`, ); - const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph); + const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph); // Remember the join uid so a same-session leave can cancel it precisely. joinUidsRef.current.set(`${eventId}|${uid}`, depositUid); - const notif = buildNotification(recipientId, eventId, registrantId, ts); - // The host FpNotification is its OWN document in the PROTECTED scope (one - // doc per entity). The inbox materialization remains the source of truth; - // this direct write only pre-warms the reactive read — but a FAILED write is - // not swallowed: it used to be dropped silently, and the line below then - // surfaced a notification nothing had recorded. The rejection reaches the - // catch under this block, which names it. - const notifGraph = await createEntityDoc('protected'); - await insertNotification(notifGraph, notif); - // Surface immediately in reactive state (materialization also refreshes it). - // Use the stable per-deposit uid for the id (F5 dedup) so it matches the - // notification id from the inbox and same-ms/anon deposits never collide. - setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]); } catch (err) { - console.error(`${logPrefix} joinEvent inbox/notify failed:`, err); + console.error(`${logPrefix} joinEvent inbox deposit failed:`, err); } }, [events, currentUserId]); @@ -968,7 +1130,22 @@ function useNgData(): FestipodDataContextValue { // document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH // the subject IRI AND the graph NURI it lives in. const part = participations.find(p => p.eventId === eventId && p.userId === uid); - if (!part) return; + // REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see + // caveat_participation-deletion): the caller only reaches here because it + // believes a participation exists, so finding none is a real disagreement + // about the state — either no profile of mine is resolved (`uid` empty), or + // the participation the screen showed is not in the set. Returning silently + // deleted nothing while the screen announced a withdrawal, and the sign-up + // came back on the next read. + if (!part) { + const msg = uid + ? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` + + `in the participation set — nothing was deleted, so the withdrawal must not be announced.` + : `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` + + `so the participation to withdraw cannot even be named.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } // DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion // is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which // removes the Participation server-side so it does NOT resurrect after re-sync. @@ -1054,9 +1231,19 @@ function useNgData(): FestipodDataContextValue { const updateProfile = useCallback(async (updates: Partial) => { console.log(`${logPrefix} updateProfile (NG):`, updates); - // The current user's profile is its own document (subject IRI = doc NURI). - const target = currentUser ?? users[0]; - if (!target) return; + // MY profile is its own document (subject IRI = doc NURI), and it is the ONLY + // document these edits may land in. There used to be a `?? users[0]` fallback + // here: with no profile resolved, a person editing their own pseudo wrote it + // into a stranger's profile document. Refuse instead — and say which of the + // two reasons it is, since only one of them clears up on its own. + const target = currentUser; + if (!target) { + const msg = + `updateProfile refused: no profile of mine is resolved, and these edits must never land in ` + + `someone else's profile document.`; + console.error(`${logPrefix} ${msg}`); + throw new Error(msg); + } const graph = target.id; const persists: Promise[] = []; if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name))); @@ -1073,7 +1260,10 @@ function useNgData(): FestipodDataContextValue { // seed runs (marking the guard at the START, before the awaited seed, closes // the window where the auto-seed effect could also fire on a still-empty read). hasTriedAutoSeed.current = true; - const walletHasData = events.length > 0 || users.length > 0; + // Same reading as the auto-seed gate: my own profile is not "data in the + // wallet" — it exists on every wallet from the first sign-in, so counting it + // would make an explicit load a permanent no-op. + const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId); const result = await bootstrapWallet(walletHasData, createEntityDoc); // The seeded per-entity docs are appended to their scope indices, which // `watchShape` subscribes → they enter the reactive reads on the push. No @@ -1083,8 +1273,11 @@ function useNgData(): FestipodDataContextValue { // them — otherwise the owner-materializer never opens their inboxes and their // `participantCount` is never derived. claimOwnedEventDocs(result.createdDocs.public); + // The seeded PROTECTED docs are deliberately NOT claimed as mine: they are + // FIXTURES (nobody signed in as them), and folding them into the owned-profile + // set is exactly what would make "which profile is mine" ambiguous. return result; - }, [events.length, users.length, claimOwnedEventDocs]); + }, [events.length, users, myProfileDocId, claimOwnedEventDocs]); return { currentUserId, currentUser, currentPrincipal, diff --git a/src/shared/utils/currentPrincipal.ts b/src/shared/utils/currentPrincipal.ts index b0dcfd4..fe71c0b 100644 --- a/src/shared/utils/currentPrincipal.ts +++ b/src/shared/utils/currentPrincipal.ts @@ -14,10 +14,11 @@ * * WHAT IT IS NOT — it is NOT the id space the app's own entities live in. A * UserProfile's id is its document NURI, and a Participation's `fp:user` carries - * that NURI; this principal is a third space. The join between the two is - * explicit and lives in one place (`FestipodDataContext`, where the principal - * selects the current user's profile through `normalizeIdentifier(username)`). - * Never compare this value to an entity id directly. + * that NURI; this principal is a third space, and there is NO join between them. + * A profile is Festipod's own object and this identity says nothing about it: + * **my profile is the profile document I own** (`listMyEntityDocs('protected')`, + * resolved in `FestipodDataContext`). Never compare this value to an entity id, + * and never match it against a profile field to decide who the current user is. * * WHY A MODULE STORE and not a React context: `AuthGate` — the component that * makes the await — is mounted INSIDE `FestipodDataProvider`, so a context it