From 9169a5817c377700a54aedb72c578a5ae05543c0 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 6 Nov 2026 23:21:38 +0100 Subject: [PATCH] Report a Join on an event without an attendees list as not recorded, and trim redundant comments --- backend/services/registration.service.js | 23 +++++------ backend/services/registration.service.test.js | 10 +++++ .../event/screens/EventDetailScreen.tsx | 18 +++++---- src/shared/context/refineData.tsx | 40 ++++++++----------- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/backend/services/registration.service.js b/backend/services/registration.service.js index 445e8f0..dfdcf36 100644 --- a/backend/services/registration.service.js +++ b/backend/services/registration.service.js @@ -35,13 +35,10 @@ const firstStringOf = value => .find(v => typeof v === 'string' && v.trim().length > 0); /** - * What a Join/Leave targets: the stored object must be an `as:Event` (checked by `verifiedDelivery`, - * and by `meetingPointOf` on the creator's copy) — a meeting point when anchored to an event by - * `as:context`, otherwise an event signed up to directly, which is its own anchor — whose creator is - * its `dc:creator`, required to own the pod it lies in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod - * stamps the creator, and nobody can store a resource in someone else's pod); on the copy read - * through the participant's pod, it only names where to read that one. Resolves - * `{ ok, eventUri, creatorUri }` or a refusal. + * What a Join/Leave targets: an `as:Event` whose `dc:creator` owns the pod it lies in. A meeting point + * is anchored to its event by `as:context`; an event signed up to directly is its own anchor. Only on + * the copy the creator's pod serves (`meetingPointOf`) is that creator believed; on the copy read + * through the participant's pod, it only names where to read that one. Resolves `{ ok, eventUri, creatorUri }` or a refusal. */ function meetingPointTarget(meetingPoint, meetingPointUri) { const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri; @@ -55,9 +52,9 @@ function meetingPointTarget(meetingPoint, meetingPointUri) { /** * The notification template for the creator of a target (`pod-notifications.send`, rendered in the - * creator's language): always ANONYMOUS, the target named through `meetingPointName` or `eventName`, the link to the - * event (`eventUri`), or to its meeting points when the target is one. Every value travels as a template PARAMETER, never - * inside the template, so no user text is compiled. + * creator's language): always ANONYMOUS, linking to the event, or to its meeting points when the target + * is one. Every value travels as a template PARAMETER, never inside the template, so no user text is + * compiled. * * Never names the participant: naming would need a trustworthy signal that the creator is one of the * participant's contacts, and the only one the app can read — the participant's `apods:contacts` list — @@ -291,7 +288,7 @@ module.exports = { } const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri); if (!collectionUri) { - this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`); + this.logger.error(`${meetingPointUri} has no attendees collection: the ${change} ${act.activityUri} of ${participantUri} is NOT recorded, the next act retries`); return undefined; } if (signal.aborted) return undefined; @@ -362,8 +359,8 @@ module.exports = { await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining }); }, /** - * Tells the creator of the target (a meeting point, or an event signed up to directly) that a - * participant joined or left it — ALWAYS anonymously. + * Tells the creator of the target (a meeting point or an event) that a participant joined or left + * it — ALWAYS anonymously. * The participant is never named: the only membership signal the app can read (the participant's * `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported * upstream, see `notificationTemplate`). The notification carries no `activity`: given one, diff --git a/backend/services/registration.service.test.js b/backend/services/registration.service.test.js index d118549..f9d056b 100644 --- a/backend/services/registration.service.test.js +++ b/backend/services/registration.service.test.js @@ -866,6 +866,16 @@ describe('registration — a direct sign-up: a Join naming an event itself', () expect(w.logs.error).toEqual([]); }); + test('a Join on an event with no attendees collection is NOT recorded, loudly logged — nothing written, nothing notified', async () => { + const j = join(PLAIN); + const w = setup({ acts: [j], stored: { [PLAIN]: plainEvent(PLAIN, { 'apods:attendees': undefined }) } }); + await emitJoin(w, j); + expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes('no attendees collection'))).toBe(true); + expect(writes(w.calls)).toEqual([]); + expect(ledgerOf(w, PLAIN)).toBeUndefined(); + expect(participations(w)).toEqual([]); + }); + const readsOf = (w, uri) => w.calls.filter(c => ['pod-resources.get', 'pod-collections.getItems'].includes(c.action) && (c.params.resourceUri ?? c.params.collectionUri) === uri); const expectAnonymous = (w, n) => { expect(n.recipientUri).toBe(ALICE); diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx index c1db625..92e68d2 100644 --- a/src/modules/event/screens/EventDetailScreen.tsx +++ b/src/modules/event/screens/EventDetailScreen.tsx @@ -54,6 +54,7 @@ export function EventDetailScreen() { const registration = eventId ? getEventRegistration(eventId) : undefined; const registered = registration?.registered ?? false; const [confirmingLeave, setConfirmingLeave] = useState(false); + const [joining, setJoining] = useState(false); const [leaving, setLeaving] = useState(false); const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => { @@ -71,11 +72,14 @@ export function EventDetailScreen() { }; const handleJoinEvent = () => { - if (!eventId) return; - void Promise.resolve(joinEvent(eventId)).catch((err: unknown) => { - console.error('[EventDetail] event sign-up failed:', err); - showToast("L'inscription à l'événement n'a pas pu être enregistrée", 'error'); - }); + if (!eventId || joining) return; + setJoining(true); + Promise.resolve(joinEvent(eventId)) + .catch((err: unknown) => { + console.error('[EventDetail] event sign-up failed:', err); + showToast("L'inscription à l'événement n'a pas pu être enregistrée", 'error'); + }) + .finally(() => setJoining(false)); }; const withdrawFromEvent = () => { @@ -92,8 +96,6 @@ export function EventDetailScreen() { }); }; - // Withdrawing from an event also leaves its meeting points: ask first when - // there are any, otherwise withdraw at once. const handleLeaveEvent = () => { if (registration && registration.meetingPointIds.length > 0) setConfirmingLeave(true); else withdrawFromEvent(); @@ -217,7 +219,7 @@ export function EventDetailScreen() { ) : ( - )} diff --git a/src/shared/context/refineData.tsx b/src/shared/context/refineData.tsx index f16b72c..a7c8e2f 100644 --- a/src/shared/context/refineData.tsx +++ b/src/shared/context/refineData.tsx @@ -205,6 +205,18 @@ interface ContactKeys { } /** The memo key of one contact's member URN at one event. */ +/** The event a joined target is anchored to: its own event for a meeting point, itself for an event. */ +function anchorEventIdOf(bindings: RefineBackend['bindings'], targetId: string, record: unknown): string | undefined { + if (bindings.meetingPoint.isInstance?.(record) ?? false) { + try { + return bindings.meetingPoint.toDomain(record).eventId || undefined; + } catch { + return undefined; + } + } + return bindings.event.isInstance?.(record) ?? true ? targetId : undefined; +} + function memberUrnKey(contactId: string, eventId: string, secret: string): string { return `${contactId}\n${eventId}\n${secret}`; } @@ -464,17 +476,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { for (const mpId of joinedForeignMeetingPointIds) { const record = foreignMeetingPointsRead.records.get(mpId); if (record === undefined) continue; - // A joined target that reads as an event is a direct sign-up: its own anchor. - if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) { - if (bindings.event.isInstance?.(record) ?? true) ids.add(mpId); - continue; - } - try { - const eventId = bindings.meetingPoint.toDomain(record).eventId; - if (eventId) ids.add(eventId); - } catch { - // Unreadable by the binding — the pick below drops the record too. - } + const eventId = anchorEventIdOf(bindings, mpId, record); + if (eventId) ids.add(eventId); } return [...ids].filter(id => !ownEventIds.has(id)); }, [joinedForeignMeetingPointIds, foreignMeetingPointsRead.records, bindings, ownEventIds]); @@ -852,17 +855,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { for (const uri of contactMpUris) { const record = contactMpsRead.records.get(uri); if (record === undefined) continue; - // A listed target that reads as an event is a direct sign-up: its own anchor. - if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) { - if (bindings.event.isInstance?.(record) ?? true) ids.set(uri, uri); - continue; - } - try { - const eventId = bindings.meetingPoint.toDomain(record).eventId; - if (eventId) ids.set(uri, eventId); - } catch { - // Not a readable meeting point — the binding drops it, as everywhere. - } + const eventId = anchorEventIdOf(bindings, uri, record); + if (eventId) ids.set(uri, eventId); } return ids; }, [contactMpUris, contactMpsRead.records, bindings]); @@ -1248,8 +1242,6 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { afterMembershipAct(event.id, 'join'); }, [logPrefix, allEvents, session, myTargetIds, ensureParticipationSecret, afterMembershipAct]); - // One Leave per held target, in parallel; each accepted one is followed, the - // refused ones are named — the status keeps following the data. const leaveEvent = useCallback(async (eventId: string) => { if (!allEvents.some(e => e.id === eventId) || !session) { throw new Error(`leaveEvent refused: ${eventId} is not in the read set (or there is no session).`);