From 5540d361c72937414e4360331d80fcccd9fa74d0 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 6 Nov 2026 23:16:29 +0100 Subject: [PATCH] Implement direct sign-up to an event with registration derived from meeting point memberships --- README.md | 2 +- backend/services/registration.service.js | 54 ++++---- backend/services/registration.service.test.js | 11 ++ .../event/screens/EventDetailScreen.tsx | 96 ++++++++++--- src/modules/event/steps/ui/event.steps.ts | 5 +- .../components/sketchy/ConfirmDialog.tsx | 41 ++++++ src/shared/components/sketchy/index.ts | 1 + src/shared/context/FestipodDataContext.tsx | 8 +- src/shared/context/festipodDataBase.ts | 72 ++++++++-- src/shared/context/ngData.tsx | 11 +- src/shared/context/refineData.tsx | 131 +++++++++++++----- src/shared/context/refineDerive.ts | 78 ++++++----- src/shared/data/outbox.ts | 22 +-- 13 files changed, 385 insertions(+), 147 deletions(-) create mode 100644 src/shared/components/sketchy/ConfirmDialog.tsx diff --git a/README.md b/README.md index 91b9df1..1ffdc50 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Tous les utilisateurs sont authentifiés — il n'y a pas d'accès anonyme à l' ### Concepts métier -- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit (on ne s'inscrit pas à un événement). Sans points de rencontre, un événement Festipod n'a pas d'intérêt. +- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. On s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement. Sans points de rencontre, un événement Festipod n'a pas d'intérêt. - **Événement** — l'ancrage. Un événement public réel (festival, conférence, salon, exposition…) référencé dans Festipod pour servir de support à des points de rencontre. C'est simplement un *prétexte* (titre, dates, lieu, thèmes) ; le déclarant n'est pas l'organisateur officiel de l'événement, juste celui qui l'a inscrit dans Festipod. - **Communauté d'intérêt** — un groupement thématique d'utilisateurs. Sert principalement à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement. - **Liste curated** — une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés » ou « les événements de la communauté ». Permet d'organiser/recommander. diff --git a/backend/services/registration.service.js b/backend/services/registration.service.js index 2298305..445e8f0 100644 --- a/backend/services/registration.service.js +++ b/backend/services/registration.service.js @@ -35,17 +35,16 @@ const firstStringOf = value => .find(v => typeof v === 'string' && v.trim().length > 0); /** - * What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by - * `verifiedDelivery`, and by `meetingPointOf` on the creator's copy) anchored to an event by - * `as:context` — whose creator is its `dc:creator`, required to own the pod the meeting point lies - * in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod + * 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. */ function meetingPointTarget(meetingPoint, meetingPointUri) { - const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`); - if (!eventUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single as:context (its event)` }; + const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri; const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR); if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` }; if (!isInActorPod(meetingPointUri, creatorUri)) { @@ -55,9 +54,9 @@ function meetingPointTarget(meetingPoint, meetingPointUri) { } /** - * The notification template for the creator of a meeting point (`pod-notifications.send`, rendered in - * the creator's language): always ANONYMOUS, the meeting point named through `meetingPointName`, the - * link to its event's meeting points (`eventUri`). Every value travels as a template PARAMETER, never + * 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. * * Never names the participant: naming would need a trustworthy signal that the creator is one of the @@ -66,21 +65,18 @@ function meetingPointTarget(meetingPoint, meetingPointUri) { * request checked; platform defect reported upstream). Until that list can be trusted, every * notification is anonymous. */ -function notificationTemplate(change) { - const title = - change === 'join' - ? { - en: 'A participant joined your meeting point "{{{meetingPointName}}}"', - fr: 'Un participant a rejoint votre point de rencontre « {{{meetingPointName}}} »' - } - : { - en: 'A participant left your meeting point "{{{meetingPointName}}}"', - fr: 'Un participant a quitté votre point de rencontre « {{{meetingPointName}}} »' - }; - return { - title, - actions: [{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }] +function notificationTemplate(change, isMeetingPoint) { + const joined = change === 'join'; + const nameParam = isMeetingPoint ? 'meetingPointName' : 'eventName'; + const subject = isMeetingPoint + ? { en: 'your meeting point', fr: 'votre point de rencontre' } + : { en: 'your event', fr: 'votre événement' }; + const title = { + en: `A participant ${joined ? 'joined' : 'left'} ${subject.en} "{{{${nameParam}}}}"`, + fr: `Un participant a ${joined ? 'rejoint' : 'quitté'} ${subject.fr} « {{{${nameParam}}}} »` }; + const link = isMeetingPoint ? '/events/{{encodeUri eventUri}}/meeting-points' : '/events/{{encodeUri eventUri}}'; + return { title, actions: [{ caption: { en: 'View', fr: 'Voir' }, link }] }; } // Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery: @@ -366,25 +362,27 @@ module.exports = { await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining }); }, /** - * Tells the creator of the meeting point that a participant joined or left it — ALWAYS anonymously. + * 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. * 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, * `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting - * point is not notified. A failure is logged, never thrown: the act stands. + * point or event is not notified. A failure is logged, never thrown: the act stands. */ async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) { + const isMeetingPoint = eventUri !== meetingPointUri; try { if (creatorUri === participantUri) { - this.logger.info(`${participantUri} acted on their own meeting point ${meetingPointUri}: no notification`); + this.logger.info(`${participantUri} acted on their own ${meetingPointUri}: no notification`); return; } this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`); await ctx.call('pod-notifications.send', { - template: notificationTemplate(change), + template: notificationTemplate(change, isMeetingPoint), recipientUri: creatorUri, context: meetingPointUri, - meetingPointName: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '', + [isMeetingPoint ? 'meetingPointName' : 'eventName']: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '', eventUri }); } catch (e) { diff --git a/backend/services/registration.service.test.js b/backend/services/registration.service.test.js index d8e5c7a..d118549 100644 --- a/backend/services/registration.service.test.js +++ b/backend/services/registration.service.test.js @@ -955,6 +955,17 @@ describe('registration — a direct sign-up: a Join naming an event itself', () } }); + test('a declarer signing up to, then leaving, their own event is not notified', async () => { + const j = join(PLAIN, T0, ALICE); + const l = leave(PLAIN, T1, ALICE); + const w = plainWorld([j, l]); + await emitJoin(w, j); + await emitLeave(w, l); + expect(attendees(w, PLAIN)).toEqual([]); + expect(ledgerOf(w, PLAIN, ALICE).change).toBe('leave'); + expect(w.state.notifications).toEqual([]); + }); + test('the same person carries a different URN on another event (R7e)', async () => { const OTHER = `${ALICE}/data/events/other`; const a = join(PLAIN, T0, BOB, 1); diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx index dd16e67..c1db625 100644 --- a/src/modules/event/screens/EventDetailScreen.tsx +++ b/src/modules/event/screens/EventDetailScreen.tsx @@ -1,6 +1,6 @@ -import { useLayoutEffect } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { ArrowLeft } from 'lucide-react'; -import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy'; +import { Button, Avatar, ConfirmDialog, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { eventPath, useNavigate, useParams, userPath } from '../../../app/router'; @@ -11,8 +11,11 @@ export function EventDetailScreen() { const { getEvent, isParticipating, + getEventRegistration, joinMeetingPoint, leaveMeetingPoint, + joinEvent, + leaveEvent, getEventParticipants, getEventMeetingPoints, getEventOwnership, @@ -42,15 +45,16 @@ export function EventDetailScreen() { participantCount: mp.participantCount, })); - // SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un - // point de rencontre, pas à un événement). There is no event-level join on - // this screen anymore: "joined" means a member of at least one of this - // event's meeting points, and it gates the Invite affordance the way the - // old event-level membership did. + // Signing up is per meeting point, or directly to the event: the registration + // to the event is derived by the data layer from both, and it gates the + // Invite affordance. const joinedMeetingPointIds = new Set( meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)), ); - const joined = joinedMeetingPointIds.size > 0; + const registration = eventId ? getEventRegistration(eventId) : undefined; + const registered = registration?.registered ?? false; + const [confirmingLeave, setConfirmingLeave] = useState(false); + const [leaving, setLeaving] = useState(false); const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => { const failed = (message: string) => (err: unknown) => { @@ -66,6 +70,39 @@ 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'); + }); + }; + + const withdrawFromEvent = () => { + if (!eventId) return; + setLeaving(true); + Promise.resolve(leaveEvent(eventId)) + .catch((err: unknown) => { + console.error('[EventDetail] event withdrawal failed:', err); + showToast("La désinscription de l'événement n'a pas pu être enregistrée", 'error'); + }) + .finally(() => { + setLeaving(false); + setConfirmingLeave(false); + }); + }; + + // 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(); + }; + + const leftMeetingPointTitles = meetingPointsRaw + .filter(mp => registration?.meetingPointIds.includes(mp.id)) + .map(mp => mp.title || mp.location); + // EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates // a write, so "may I edit this event" is answered entirely by whether this // session owns the event's document — no permission call, no extra read. @@ -165,19 +202,26 @@ export function EventDetailScreen() { )} - {/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The - join affordances live on the meeting points below; Invite follows - membership, the way the old event-level "Je participe" did. */} - {joined && ( -
- + + + ) : ( + -
- )} + )} + {meetingPoints.length > 0 && (
@@ -244,6 +288,18 @@ export function EventDetailScreen() { )}
+ {confirmingLeave && ( + setConfirmingLeave(false)} + busy={leaving} + > + Vous êtes inscrit aux points de rencontre suivants : {leftMeetingPointTitles.join(', ')}. En confirmant, vous en serez aussi désinscrit. + + )} ); } diff --git a/src/modules/event/steps/ui/event.steps.ts b/src/modules/event/steps/ui/event.steps.ts index 4095749..df99830 100644 --- a/src/modules/event/steps/ui/event.steps.ts +++ b/src/modules/event/steps/ui/event.steps.ts @@ -77,9 +77,8 @@ Then('les événements affichent leur lieu', async function (this: FestipodWorld 'Event cards should display a location (text or 📍 icon)').to.be.true; }); -// The product model signs up to a MEETING POINT, not to the event (arbitration -// of the event-join removal, T04.e): the signup affordance on this screen is the -// per-meeting-point "S'inscrire" toggle, not an event-level join button. +// Signing up is per meeting point or directly to the event: the detail screen +// exposes the event-level button and the per-meeting-point toggles. Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) { expect(this.currentScreenId).to.equal('event-detail'); const doc = this.renderedDoc; diff --git a/src/shared/components/sketchy/ConfirmDialog.tsx b/src/shared/components/sketchy/ConfirmDialog.tsx new file mode 100644 index 0000000..d2b6871 --- /dev/null +++ b/src/shared/components/sketchy/ConfirmDialog.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { Button } from './Button'; + +interface ConfirmDialogProps { + title: string; + children: ReactNode; + confirmLabel: string; + cancelLabel: string; + onConfirm: () => void; + onCancel: () => void; + busy?: boolean; +} + +export function ConfirmDialog({ title, children, confirmLabel, cancelLabel, onConfirm, onCancel, busy = false }: ConfirmDialogProps) { + return ( +
+
+
{title}
+
{children}
+
+ + +
+
+
+ ); +} diff --git a/src/shared/components/sketchy/index.ts b/src/shared/components/sketchy/index.ts index 4236f3f..e6e73fc 100644 --- a/src/shared/components/sketchy/index.ts +++ b/src/shared/components/sketchy/index.ts @@ -15,5 +15,6 @@ export { EventCover } from './EventCover'; export { ImagePicker } from './ImagePicker'; export { EventMeetingPoints } from './EventMeetingPoints'; export type { MeetingPointData } from './EventMeetingPoints'; +export { ConfirmDialog } from './ConfirmDialog'; export { Divider } from './Divider'; export { Spinner } from './Spinner'; diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index cd0ddbe..b6b3cec 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -108,6 +108,12 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { const leaveMeetingPoint = useCallback((_meetingPointId: string) => { console.log(`${logPrefix} leaveMeetingPoint (local, no-op)`); }, [logPrefix]); + const joinEvent = useCallback(async (_eventId: string) => { + console.log(`${logPrefix} joinEvent (local, no-op)`); + }, [logPrefix]); + const leaveEvent = useCallback(async (_eventId: string) => { + console.log(`${logPrefix} leaveEvent (local, no-op)`); + }, [logPrefix]); const addMeetingPoint = useCallback((_mp: Omit) => { console.log(`${logPrefix} addMeetingPoint (local, no-op)`); // Nothing is stored in the disconnected demo: say so instead of letting the @@ -145,7 +151,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { selectedUserId, setSelectedUserId, selectedUser, ...queries, getEventOwnership, - createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, + createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent, addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData, }; } diff --git a/src/shared/context/festipodDataBase.ts b/src/shared/context/festipodDataBase.ts index dcc2e93..4dbbc05 100644 --- a/src/shared/context/festipodDataBase.ts +++ b/src/shared/context/festipodDataBase.ts @@ -2,10 +2,13 @@ * FestipodDataContext — the screen-facing data contract, and the shared pieces * every provider implementation builds on. * - * The app signs up to a MEETING POINT, not to an event (product model): an - * event is only the public anchor meeting points graft onto. So the - * participation surface is `joinMeetingPoint` / `leaveMeetingPoint`; there is - * no event-level join. Provider implementations: + * The app signs up to a MEETING POINT, or directly to an event (product + * model): an event is the public anchor meeting points graft onto, and being in + * one of its meeting points registers you to it. So the participation surface + * is `joinMeetingPoint` / `leaveMeetingPoint` for a meeting point and + * `joinEvent` / `leaveEvent` for the event itself; the registration to an + * event (`getEventRegistration`) is derived from both, never stored. + * Provider implementations: * * - `LocalDataProvider` (`FestipodDataContext.tsx`) — the disconnected demo, * seed fixtures, mutations as no-ops. What the `@ui` layer renders against. @@ -32,6 +35,17 @@ import { normalizeIdentifier } from '../utils/identifier'; // Context interface // ============================================================================ +/** + * How the current user is registered to an event: `direct` (signed up to the + * event itself) and `meetingPointIds` (the event's meeting points they belong + * to). `registered` is derived — one of the two — never stored. + */ +export interface EventRegistration { + direct: boolean; + meetingPointIds: string[]; + registered: boolean; +} + /** * Whether this session may WRITE an event's document — asked the same way on * every backend: only a record's owner writes it. `'unknown'` is a REAL third @@ -82,6 +96,8 @@ export interface FestipodDataContextValue { getUserEvents(userId: string): FpEventData[]; /** Membership test against a MEETING POINT id (defaults to the current user). */ isParticipating(meetingPointId: string, userId?: string): boolean; + /** How the current user is registered to an event — see {@link EventRegistration}. */ + getEventRegistration(eventId: string): EventRegistration; getFriends(userId?: string): FpUserData[]; getEventMeetingPoints(eventId: string): FpMeetingPointData[]; /** @@ -131,6 +147,18 @@ export interface FestipodDataContextValue { joinMeetingPoint(meetingPointId: string, userId?: string): Promise | void; /** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */ leaveMeetingPoint(meetingPointId: string, userId?: string): Promise | void; + /** + * Sign up directly to an event, whether or not it has meeting points. + * Idempotent; rejects when the act was refused or could not be sent. + */ + joinEvent(eventId: string): Promise; + /** + * Withdraw from an event: the direct sign-up AND every membership of the + * event's meeting points. The confirmation the user owes before losing those + * memberships belongs to the screen. Resolves once every act was accepted; + * rejects naming the targets that failed (the status then follows the data). + */ + leaveEvent(eventId: string): Promise; /** * Create a meeting point on an event. Same contract as `addFriend`: resolves * once the meeting point was created and persisted — rejects when nothing @@ -262,12 +290,11 @@ export function safeUserInitials(user: FpUserData | undefined): string { * that provider's arrays. Cross-entity derivations only: nothing here touches * a backend. * - * THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID. On the - * local/NextGraph providers that is an event; on the ActivityPods provider it - * is a meeting point. `isParticipating` and `getUserEvents` filter on it - * as-is, so a provider whose participations are keyed differently (the - * ActivityPods reader derives event-level participants from the meeting - * points) overrides those two queries and reuses the rest. + * THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID: an event + * (a direct sign-up) or a meeting point. `getUserEvents`, `isParticipating` + * and `getEventRegistration` read it as such; a provider whose participations + * are not the whole truth about the current user (the ActivityPods reader + * overlays pending acts) overrides those queries and reuses the rest. */ export function buildQueries( events: FpEventData[], @@ -296,8 +323,9 @@ export function buildQueries( }; const getUserEvents = (userId: string) => { - const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId); - return events.filter(e => partEventIds.includes(e.id)); + const joined = new Set(participations.filter(p => p.userId === userId).map(p => p.eventId)); + const viaMeetingPoints = new Set(meetingPoints.filter(mp => joined.has(mp.id)).map(mp => mp.eventId)); + return events.filter(e => joined.has(e.id) || viaMeetingPoints.has(e.id)); }; const isParticipating = (meetingPointId: string, userId?: string) => { @@ -305,6 +333,10 @@ export function buildQueries( return participations.some(p => p.eventId === meetingPointId && p.userId === uid); }; + const getEventRegistration = (eventId: string): EventRegistration => + registrationOf(eventId, meetingPoints, target => + participations.some(p => p.eventId === target && p.userId === currentUserId)); + const getFriends = (userId?: string) => { const uid = userId || currentUserId; const friendIds = friendships @@ -323,13 +355,27 @@ export function buildQueries( events.some(e => e.id === id) ? 'ready' : 'unavailable'; return { - getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints, + getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getEventRegistration, getFriends, getEventMeetingPoints, openEvent: openEventInCompleteReadSet, openUserEvents: openUserEventsInCompleteReadSet, getEventReadState, }; } +/** + * The registration to an event, given who holds which target: registered iff + * signed up to the event itself or a member of one of its meeting points. + */ +export function registrationOf( + eventId: string, + meetingPoints: readonly FpMeetingPointData[], + holds: (targetId: string) => boolean, +): EventRegistration { + const direct = holds(eventId); + const meetingPointIds = meetingPoints.filter(mp => mp.eventId === eventId && holds(mp.id)).map(mp => mp.id); + return { direct, meetingPointIds, registered: direct || meetingPointIds.length > 0 }; +} + /** * `openEvent` for a provider whose read set is already everything it can read * (the local fixtures, the suspended NextGraph path): nothing to bring in. One diff --git a/src/shared/context/ngData.tsx b/src/shared/context/ngData.tsx index 2e28fa6..8b567cb 100644 --- a/src/shared/context/ngData.tsx +++ b/src/shared/context/ngData.tsx @@ -997,6 +997,15 @@ function useNgData(): FestipodDataContextValue { await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err)); }, []); + const joinEvent = useCallback( + () => Promise.reject(new Error('joinEvent: not supported on the suspended NextGraph path')), + [], + ); + const leaveEvent = useCallback( + () => Promise.reject(new Error('leaveEvent: not supported on the suspended NextGraph path')), + [], + ); + const joinMeetingPoint = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; console.log(`${logPrefix} joinMeetingPoint (NG):`, eventId, 'user:', uid); @@ -1267,7 +1276,7 @@ function useNgData(): FestipodDataContextValue { selectedUserId, setSelectedUserId, selectedUser, ...queries, getEventOwnership, - createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, + createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent, addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData, }; } diff --git a/src/shared/context/refineData.tsx b/src/shared/context/refineData.tsx index 360988b..f16b72c 100644 --- a/src/shared/context/refineData.tsx +++ b/src/shared/context/refineData.tsx @@ -112,6 +112,7 @@ import { FestipodDataContext, buildQueries, nextId, + registrationOf, type FestipodDataContextValue, type EventOwnership, type EventReadState, @@ -162,11 +163,14 @@ function inertDataValue(): FestipodDataContextValue { selectedEventId: '', selectedEvent: undefined, selectedUserId: '', selectedUser: undefined, getEvent: () => undefined, getUser: () => undefined, getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false, + getEventRegistration: () => ({ direct: false, meetingPointIds: [], registered: false }), getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown', openEvent: () => {}, openUserEvents: () => {}, getEventReadState: (): EventReadState => 'unavailable', setSelectedEventId: () => {}, setSelectedUserId: () => {}, createEvent: async (event: Omit) => ({ ...event, id: nextId('event') }), updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {}, + joinEvent: () => Promise.reject(new Error('joinEvent: no data provider')), + leaveEvent: () => Promise.reject(new Error('leaveEvent: no data provider')), addMeetingPoint: () => Promise.reject(new Error('addMeetingPoint: no data provider')), addFriend: () => Promise.reject(new Error('addFriend: no data provider')), inviteToEvent: () => Promise.reject(new Error('inviteToEvent: no data provider')), updateProfile: () => {}, loadTestData: async () => emptyBootstrap(), }; return none; @@ -251,6 +255,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { ); const ownMeetingPointIdsRef = useRef(ownMeetingPointIds); ownMeetingPointIdsRef.current = ownMeetingPointIds; + const ownEventIdsRef = useRef(ownEventIds); + ownEventIdsRef.current = ownEventIds; // --- MY PROFILE (the app's own entity id space) ------------------------------ // The actor (WebID) document links the `as:Profile` record via `url`; the @@ -359,7 +365,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { useEffect(() => { if (myProfileRead) readMyParticipations(); }, [myProfileRead, myParticipationsUri, readMyParticipations]); - const joinedMeetingPointIds = myParticipations.ids; + const joinedTargetIds = myParticipations.ids; // The events each CONTACT DECLARES, keyed by their actor URI — read from the // catalog with `?owner=` (the header's first contact channel): // the declaration is public by design, and the catalog item IS the event @@ -429,8 +435,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { // to every derivation. An own point (on an own event, or one I proposed on // another's) is already in the own list and never re-read by URI. const joinedForeignMeetingPointIds = useMemo( - () => joinedMeetingPointIds.filter(id => !ownMeetingPointIds.has(id)), - [joinedMeetingPointIds, ownMeetingPointIds], + () => joinedTargetIds.filter(id => !ownMeetingPointIds.has(id) && !ownEventIds.has(id)), + [joinedTargetIds, ownMeetingPointIds, ownEventIds], ); const foreignMeetingPointIds = useMemo(() => { const ids = new Set(); @@ -457,7 +463,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { const ids = new Set(); for (const mpId of joinedForeignMeetingPointIds) { const record = foreignMeetingPointsRead.records.get(mpId); - if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue; + 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); @@ -499,7 +510,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { foreignMeetingPointsRead.records, bindings]); // --- ATTENDEE LISTS, read by URI and embedded -------------------------------- - // Every meeting point's linked collection, own and foreign alike — public by + // Every meeting point's and event's linked collection, own and foreign alike — public by // design, read by URI with the same token policy, keyed by the URI the record // links (never by what the fetched document claims). The members are member // URNs: the read embeds them for the COUNT derivation and for recognising @@ -509,11 +520,11 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { const collectionOf = useMemo(() => { const links = new Map(); // meeting point id → collection URI for (const record of readRecords) { - if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) continue; const link = attendeesCollectionLink(record); if (!link) continue; + const kind = (bindings.meetingPoint.isInstance?.(record) ?? false) ? bindings.meetingPoint : bindings.event; try { - links.set(bindings.meetingPoint.domainId(record), link); + links.set(kind.domainId(record), link); } catch { // No identifier: the derive skips this record too. } @@ -580,28 +591,28 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { useEffect(() => { if (pendingActs.size === 0 || !webId) return; setPendingActs(prev => settleMembershipActs(prev, mpId => { - if (!derived.meetingPoints.some(mp => mp.id === mpId)) return undefined; + if (!derived.meetingPoints.some(mp => mp.id === mpId) && !derived.events.some(e => e.id === mpId)) return undefined; return { - member: joinedMeetingPointIds.includes(mpId), + member: joinedTargetIds.includes(mpId), generation: myParticipations.generation, attempted: myParticipations.attempted, count: derived.totals.get(mpId), }; })); - }, [pendingActs, derived, joinedMeetingPointIds, myParticipations, webId]); + }, [pendingActs, derived, joinedTargetIds, myParticipations, webId]); // MY meeting points as this session sees them: the collection's, with the // pending acts on top (an act asserts the state between the POST and the read // that confirms it; while it is pending it wins over the last read, in either // direction). - const myMeetingPointIds = useMemo(() => { - const ids = new Set(joinedMeetingPointIds); + const myTargetIds = useMemo(() => { + const ids = new Set(joinedTargetIds); for (const [mpId, op] of overlay) { if (op === 'join') ids.add(mpId); else ids.delete(mpId); } return ids; - }, [joinedMeetingPointIds, overlay]); + }, [joinedTargetIds, overlay]); // --- PARTICIPANT RESOLUTION (the Alice/Bob rule) ------------------------------ // WHO gets resolved: CONTACTS plus self only — never anyone else. A contact @@ -736,7 +747,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { // with the meeting points whose list holds it (one person carries the same // URN in every list of one event). const listedMembersByEvent = useMemo(() => { - const eventOf = new Map(derived.meetingPoints.map(mp => [mp.id, mp.eventId])); + const eventOf = new Map([ + ...derived.events.map(e => [e.id, e.id] as const), + ...derived.meetingPoints.map(mp => [mp.id, mp.eventId] as const), + ]); const byEvent = new Map>(); // event → URN → meeting points for (const p of derived.participations) { const eventId = eventOf.get(p.eventId); @@ -781,7 +795,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { // Per contact, the meeting points whose list holds their URN — a set lookup // per (contact, event): O(contacts + participations) per event. A contact // whose profile carries no usable secret is never recognised. - const recognisedMeetingPoints = useMemo(() => { + const recognisedTargets = useMemo(() => { const byContact = new Map>(); for (const [eventId, members] of listedMembersByEvent) { for (const [contactId, keys] of contactKeys) { @@ -834,10 +848,15 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { const contactMpUris = useMemo(() => [...new Set([...contactListedMps.values()].flat())], [contactListedMps]); const contactMpsRead = usePublicRecords(contactMpUris, { retry: userEventsAsks, tokenOf }); const contactMpEventIds = useMemo(() => { - const ids = new Map(); // meeting point URI → anchor event URI + const ids = new Map(); // listed target URI → anchor event URI for (const uri of contactMpUris) { const record = contactMpsRead.records.get(uri); - if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue; + 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); @@ -917,23 +936,23 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { // WHO is at an event: myself per my own meeting points, a contact per the // recognition above (their URN found in one of the event's lists). Nobody // else is ever identified — the count on the event stays the total. - const mpIds = new Set(mpIdsOf(eventId)); + const targetIds = [eventId, ...mpIdsOf(eventId)]; const ids = new Set(); for (const u of users) { - const theirs = u.id === webId ? myMeetingPointIds : recognisedMeetingPoints.get(u.id); - if (theirs && [...mpIds].some(id => theirs.has(id))) ids.add(u.id); + const theirs = u.id === webId ? myTargetIds : recognisedTargets.get(u.id); + if (theirs && targetIds.some(id => theirs.has(id))) ids.add(u.id); } return users.filter(u => ids.has(u.id)); - }, [allMeetingPoints, users, webId, myMeetingPointIds, recognisedMeetingPoints]); + }, [allMeetingPoints, users, webId, myTargetIds, recognisedTargets]); const getUserEvents = useCallback((userId: string) => { if (!webId) return []; if (userId === currentUserId) { - // Mine: my meeting points (my participations collection, with the pending - // acts on top), mapped to the events through the meeting points the read - // set carries. - const joinedEventIds = new Set(); - for (const mpId of myMeetingPointIds) { + // Mine: my direct sign-ups and my meeting points (my participations + // collection, with the pending acts on top), the latter mapped to their + // events through the meeting points the read set carries. + const joinedEventIds = new Set(myTargetIds); + for (const mpId of myTargetIds) { const mp = allMeetingPoints.find(m => m.id === mpId); if (mp) joinedEventIds.add(mp.eventId); } @@ -950,7 +969,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { for (const event of declaredEventsByContact.get(userId) ?? []) byId.set(event.id, event); for (const event of participatedEventsByContact.get(userId) ?? []) byId.set(event.id, event); return [...byId.values()]; - }, [currentUserId, webId, contactIdSet, declaredEventsByContact, participatedEventsByContact, myMeetingPointIds, allMeetingPoints, allEvents]); + }, [currentUserId, webId, contactIdSet, declaredEventsByContact, participatedEventsByContact, myTargetIds, allMeetingPoints, allEvents]); + + const getEventRegistration = useCallback( + (eventId: string) => registrationOf(eventId, allMeetingPoints, id => myTargetIds.has(id)), + [allMeetingPoints, myTargetIds], + ); // Own participation — my meeting points, pending acts included. Another user: // a contact participates iff recognised in this meeting point's list; anyone @@ -958,10 +982,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { const isParticipating = useCallback((meetingPointId: string, userId?: string) => { if (userId !== undefined && userId !== currentUserId && userId !== webId) { if (!contactIdSet.has(userId)) return false; - return recognisedMeetingPoints.get(userId)?.has(meetingPointId) ?? false; + return recognisedTargets.get(userId)?.has(meetingPointId) ?? false; } - return myMeetingPointIds.has(meetingPointId); - }, [currentUserId, webId, contactIdSet, recognisedMeetingPoints, myMeetingPointIds]); + return myTargetIds.has(meetingPointId); + }, [currentUserId, webId, contactIdSet, recognisedTargets, myTargetIds]); // --- Ownership: editing is owning ------------------------------------------------ // The list is own-pod scoped, so membership in it IS ownership. A failed @@ -1025,6 +1049,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { actTimers.current.clear(); actBaseTotals.current.clear(); }, []); + const isOwnTarget = (id: string) => ownMeetingPointIdsRef.current.has(id) || ownEventIdsRef.current.has(id); const afterMembershipAct = useCallback((mpId: string, op: MembershipOp) => { const invalidateOwnList = () => void invalidate({ resource: bindings.event.resource, invalidates: ['list'] }); // The count: re-read the list the backend updates. With no list linked @@ -1032,7 +1057,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { const readCountAgain = (): void => { const collection = collectionOfRef.current.get(mpId); if (collection !== undefined) { readCollectionAgain(collection); return; } - if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList(); + if (isOwnTarget(mpId)) invalidateOwnList(); else readMeetingPointAgain(mpId); }; readCountAgain(); @@ -1043,7 +1068,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { const confirmFrom = readMyParticipations(true); actBaseTotals.current.set(mpId, totalsRef.current.get(mpId)); setPendingActs(prev => recordMembershipAct(prev, mpId, op, confirmFrom, totalsRef.current.get(mpId))); - if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList(); + if (isOwnTarget(mpId)) invalidateOwnList(); for (const timer of actTimers.current.get(mpId) ?? []) clearTimeout(timer); const last = MEMBERSHIP_REREAD_DELAYS_MS.length - 1; actTimers.current.set(mpId, MEMBERSHIP_REREAD_DELAYS_MS.map((delay, step) => setTimeout(() => { @@ -1200,7 +1225,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { // The membership check is MY meeting points' word — my participations // collection with the pending acts on top, so a join still unconfirmed can // be withdrawn. - if (!myMeetingPointIds.has(mp.id)) { + if (!myTargetIds.has(mp.id)) { const msg = `leaveMeetingPoint refused: no membership of mine on ${mp.id} — nothing was withdrawn.`; console.error(`${logPrefix} ${msg}`); throw new Error(msg); @@ -1208,7 +1233,42 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId); await postLeaveActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() }); afterMembershipAct(mp.id, 'leave'); - }, [logPrefix, allMeetingPoints, myMeetingPointIds, session, webId, afterMembershipAct]); + }, [logPrefix, allMeetingPoints, myTargetIds, session, webId, afterMembershipAct]); + + const joinEvent = useCallback(async (eventId: string) => { + const event = allEvents.find(e => e.id === eventId); + if (!event || !session) { + throw new Error(`joinEvent refused: ${eventId} is not in the read set ` + + '(or there is no session) — the write would target a reference this session cannot see.'); + } + if (myTargetIds.has(event.id)) return; + console.log(`${logPrefix} joinEvent (refine):`, eventId); + await ensureParticipationSecret(); + await postJoinActivity({ meetingPointUri: event.id, session, appActorUri: appActorUri() }); + 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).`); + } + const { direct, meetingPointIds } = getEventRegistration(eventId); + const targets = direct ? [eventId, ...meetingPointIds] : meetingPointIds; + if (targets.length === 0) { + throw new Error(`leaveEvent refused: no registration of mine on ${eventId} — nothing was withdrawn.`); + } + console.log(`${logPrefix} leaveEvent (refine):`, eventId, targets.length); + const outcomes = await Promise.allSettled(targets.map(async target => { + await postLeaveActivity({ meetingPointUri: target, session, appActorUri: appActorUri() }); + afterMembershipAct(target, 'leave'); + })); + const failed = targets.filter((_, i) => outcomes[i]?.status === 'rejected'); + if (failed.length > 0) { + throw new Error(`leaveEvent: the withdrawal was refused for ${failed.join(', ')}`); + } + }, [logPrefix, allEvents, session, getEventRegistration, afterMembershipAct]); // --- The rest of the contract ------------------------------------------------------- // A contact request, not a friendship: the Pod's social graph is mutual and @@ -1257,13 +1317,14 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue { getEventParticipants, getUserEvents, isParticipating, + getEventRegistration, getFriends: shared.getFriends, getEventMeetingPoints: shared.getEventMeetingPoints, getEventOwnership, openEvent, openUserEvents, getEventReadState, - createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, + createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent, addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData, }; } diff --git a/src/shared/context/refineDerive.ts b/src/shared/context/refineDerive.ts index 4baa723..96f8847 100644 --- a/src/shared/context/refineDerive.ts +++ b/src/shared/context/refineDerive.ts @@ -7,17 +7,20 @@ * here, and they are the two places the ActivityPods model meets the app's: * * 1. `deriveContainer` SPLITS one `as/Event` container into events and meeting - * points and reads each meeting point's `apods:attendees` collection. Events - * and meeting points share the container (the bindings' `isInstance` is the - * only distinction), so the split has to happen exactly once, at read time. - * The event's `participantCount` is the number of DISTINCT member values - * across its meeting points' memberships — never a stored, incremented - * counter, and never the number of RECOGNISED people: the members are the - * member URNs of decision_2026-10-29_participation-privacy-mechanism, one - * per (person, EVENT), so the same person in two meeting points of one event - * carries the SAME URN in both collections and counts ONCE — the product + * points and reads the `apods:attendees` collection of each — a meeting + * point's members are its memberships, an event's own members are its direct + * sign-ups. Events and meeting points share the container (the bindings' + * `isInstance` is the only distinction), so the split has to happen exactly + * once, at read time. The event's `participantCount` is the number of + * DISTINCT member values across its own list and its meeting points' lists — + * never a stored, incremented counter, and never the number of RECOGNISED + * people: the members are the member URNs of + * decision_2026-10-29_participation-privacy-mechanism, one per (person, + * EVENT), so the same person directly signed up and in meeting points of one + * event carries the SAME URN in every list and counts ONCE — the product * rule is publicly computable from opaque data, with no recognition and no - * backend. + * backend. Being registered to an event is never stored: it is a direct + * sign-up or a membership of one of its meeting points. * * 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through * the outbox) before the backend's update of the collections is read. `countsWithOverlay` @@ -40,14 +43,15 @@ export interface DerivedContainer { events: FpEventData[]; meetingPoints: FpMeetingPointData[]; /** - * Participations keyed BY MEETING POINT: `eventId` carries the meeting point - * id and `userId` the attendee-list member — the member URN of + * Participations keyed BY TARGET: `eventId` carries the id of the meeting + * point — or of the event itself for a direct sign-up — whose list holds the + * member, and `userId` the attendee-list member — the member URN of * decision_2026-10-29_participation-privacy-mechanism (one per (person, * EVENT)), which only a holder of the person's secret recognises (see * `refineData.tsx`). */ participations: FpParticipationData[]; - /** Per-meeting-point collection totals, keyed by meeting point id. */ + /** Per-list collection totals, keyed by meeting point or event id. */ totals: Map; } @@ -55,10 +59,10 @@ export interface DerivedContainer { * Split a container's raw records with the backend's own bindings and derive * the participations and the participant counts. * - * A meeting point's attendees collection travels inside its record; each member - * is mapped to a participation stamped with the HOST meeting point's id (a - * member carries no link back to what it attends), and the collection's total is - * kept as-is — a member the reader cannot resolve still counts. + * An attendees collection travels inside its record; each member is mapped to a + * participation stamped with the HOST record's id (a member carries no link back + * to what it attends), and the collection's total is kept as-is — a member the + * reader cannot resolve still counts. */ export function deriveContainer( records: readonly unknown[], @@ -72,26 +76,32 @@ export function deriveContainer( // pods too (events opened by URI, their meeting points, the collections those // link to): one that the bindings reject is skipped with a warning — it never // takes the whole read down with it. + const readMembers = (record: unknown, targetId: string): number | undefined => { + const attendees = bindings.participation.membersOf?.(record); + if (!attendees) return undefined; + for (const member of attendees.members) { + try { + const mapped = bindings.participation.toDomain(member); + participations.push({ ...mapped, id: `${targetId}|${mapped.userId}`, eventId: targetId }); + } catch (error) { + console.warn(`[refine] skipped an unreadable attendee of ${targetId}`, error); + } + } + return attendees.total ?? attendees.members.length; + }; for (const record of records) { try { if (bindings.meetingPoint.isInstance?.(record) ?? false) { const mp = bindings.meetingPoint.toDomain(record); meetingPoints.push(mp); - const attendees = bindings.participation.membersOf?.(record); - const members = attendees?.members ?? []; - for (const member of members) { - try { - const mapped = bindings.participation.toDomain(member); - participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id }); - } catch (error) { - console.warn(`[refine] skipped an unreadable attendee of ${mp.id}`, error); - } - } - const total = attendees?.total ?? members.length; + const total = readMembers(record, mp.id) ?? 0; totals.set(mp.id, total); mp.participantCount = total; } else if (bindings.event.isInstance?.(record) ?? true) { - events.push(bindings.event.toDomain(record)); + const event = bindings.event.toDomain(record); + events.push(event); + const total = readMembers(record, event.id); + if (total !== undefined) totals.set(event.id, total); } } catch (error) { console.warn('[refine] skipped a record the bindings cannot read', error); @@ -105,11 +115,11 @@ export function deriveContainer( /** * The event's count: the number of DISTINCT member values among the - * participations of its meeting points — derived at read time, never a stored + * participations of its own list and of its meeting points — derived at read time, never a stored * counter. The members are the member URNs of * decision_2026-10-29_participation-privacy-mechanism: one URN per (person, EVENT), - * so a person in two of the event's meeting points carries the same URN in both - * collections and counts ONCE — distinct PERSONS, publicly computable from + * so a person in several of the event's lists carries the same URN in each and + * counts ONCE — distinct PERSONS, publicly computable from * opaque data, while each meeting point's own count stays the collection total * (its card shows per-point inscriptions). */ @@ -118,10 +128,10 @@ function eventParticipantCount( meetingPoints: readonly FpMeetingPointData[], participations: readonly FpParticipationData[], ): number { - const mpIds = new Set(meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id)); + const targetIds = new Set([eventId, ...meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id)]); const userIds = new Set(); for (const p of participations) { - if (mpIds.has(p.eventId)) userIds.add(p.userId); + if (targetIds.has(p.eventId)) userIds.add(p.userId); } return userIds.size; } diff --git a/src/shared/data/outbox.ts b/src/shared/data/outbox.ts index dfc1f0b..3f0e53f 100644 --- a/src/shared/data/outbox.ts +++ b/src/shared/data/outbox.ts @@ -1,5 +1,5 @@ /** - * Outbox posting — how a user joins or leaves a meeting point. + * Outbox posting — how a user joins or leaves a meeting point or an event. * * WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no * `custom` method: there is no escape hatch through the data provider, and a @@ -7,13 +7,13 @@ * posted to the USER'S OWN outbox with the user's token, a withdrawal an * `as:Leave` on the same path (decision_2026-10-29_participation-privacy-mechanism). * - * THE ACTIVITY. Its `object` is the meeting point itself, and its only - * addressee is the Festipod app actor (`to`): not public, not addressed to the - * meeting point's creator. A non-public activity is readable only by its author - * and its addressees, so the target stays hidden from everyone else. The user's - * pod delivers it to the app's inbox, where the Festipod backend handles it — - * it records the author's member URN in the meeting point's attendee list and - * the meeting point in the author's participations collection + * THE ACTIVITY. Its `object` is the meeting point — or the event, for a direct + * sign-up — itself, and its only addressee is the Festipod app actor (`to`): + * not public, not addressed to the target's creator. A non-public activity is + * readable only by its author and its addressees, so the target stays hidden + * from everyone else. The user's pod delivers it to the app's inbox, where the + * Festipod backend handles it — it records the author's member URN in the + * target's attendee list and the target in the author's participations collection * (decision_2026-11-03_participation-delivered-to-the-app). * * THE FLOW. The outbox URI is read off the user's own actor (WebID) document @@ -34,7 +34,7 @@ import { fetchActorDocument } from './actor'; const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams'; export interface OutboxActivityInput { - /** URI of the meeting point the activity names. */ + /** URI of the target the activity names: a meeting point, or an event for a direct sign-up. */ meetingPointUri: string; /** The authenticated session — whose outbox receives the activity. */ session: CurrentSession; @@ -71,12 +71,12 @@ async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): } } -/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point. */ +/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point or an event. */ export function postJoinActivity(input: OutboxActivityInput): Promise { return postActivity('Join', input); } -/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */ +/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point or an event. */ export function postLeaveActivity(input: OutboxActivityInput): Promise { return postActivity('Leave', input); }