Report a Join on an event without an attendees list as not recorded, and trim redundant comments

This commit is contained in:
Sylvain Duchesne
2026-11-06 23:21:38 +01:00
parent 5540d361c7
commit 9169a5817c
4 changed files with 46 additions and 45 deletions
+10 -13
View File
@@ -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,
@@ -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);
@@ -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() {
</Button>
</>
) : (
<Button variant="primary" onClick={handleJoinEvent} style={{ padding: '12px 0' }}>
<Button variant="primary" onClick={handleJoinEvent} disabled={joining} style={{ padding: '12px 0' }}>
S'inscrire à l'événement
</Button>
)}
+16 -24
View File
@@ -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).`);