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}
+
+ {confirmLabel}
+ {cancelLabel}
+
+
+
+ );
+}
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);
}