Report a Join on an event without an attendees list as not recorded, and trim redundant comments
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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).`);
|
||||
|
||||
Reference in New Issue
Block a user