diff --git a/.project/concepts/functional-domain/_overview.md b/.project/concepts/functional-domain/_overview.md index 96e9b16..6f6b234 100644 --- a/.project/concepts/functional-domain/_overview.md +++ b/.project/concepts/functional-domain/_overview.md @@ -29,3 +29,4 @@ The product model of **who sees what** — personal data reserved to one's netwo - [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing it on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection) - [[knowledge_roadmap]] — current features vs upcoming evolutions - [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P +- [[brief_2026-11-06_direct-event-registration]] — proposed (FP-001): direct sign-up to an event, registration derived from it or from a meeting point membership, confirmed cascade on leaving the event diff --git a/.project/concepts/functional-domain/brief_2026-11-06_direct-event-registration.md b/.project/concepts/functional-domain/brief_2026-11-06_direct-event-registration.md new file mode 100644 index 0000000..fe7ebe5 --- /dev/null +++ b/.project/concepts/functional-domain/brief_2026-11-06_direct-event-registration.md @@ -0,0 +1,196 @@ +--- +type: brief +summary: FP-001 design — a user may also sign up directly to an event; event registration = direct sign-up OR membership of one of its meeting points, derived at read time and never stored; leaving an event with meeting points asks for confirmation and withdraws them too +--- + +# Direct sign-up to an event (FP-001) + +**Status:** Proposed — not yet validated by the user. Becomes a `decision_` (and amends [[knowledge_business-model]]) once validated. +**Captured:** 2026-11-06, ticket FP-001. + +## The request + +Today a user signs up only to meeting points; being in at least one of an event's meeting points makes them count as registered to the event. The user asks for a **direct sign-up to an event**, keeping the automatic link: + +- sign-up to a meeting point ⇒ registered to its event; +- withdrawal from a meeting point ⇒ depends on whether the user is still in another meeting point of that event; +- withdrawal from an event ⇒ if the user is in meeting points of it, ask for confirmation, saying those memberships go too. + +This **amends the pivotal sentence** "you sign up to a meeting point, never to an event" ([[knowledge_business-model]], `functional-domain` overview, `AGENTS.md`, the header of `festipodDataBase.ts`): the meeting point stays the unit of value, but an event can now be joined on its own — "I'll be there" before, or without, any meeting point. + +## Model: two acts stored, one status derived + +| Fact | Stored? | Where | +|---|---|---| +| **Direct sign-up** to event E | yes — an act of the user | an `as:Join` naming **E itself**, through the same channel as a meeting point Join | +| **Membership** of meeting point M | yes — unchanged | the existing `as:Join` naming M | +| **Registered to E** | **no — derived at read** | `direct(E) ∨ ∃ M of E : member(M)` | + +Deriving the registration, rather than writing an event-level Join as a side effect of every meeting point Join, is the core choice: + +- R2 holds by construction — no second write that can fail, lag or drift from the first. +- R3 needs no decision logic at withdrawal: after the last meeting point is left, the status falls back to whatever the direct sign-up says. This is exactly the default rule given with the ticket (stays registered if signed up directly, otherwise not). +- **Existing data needs no migration**: every registration stored today is a meeting point membership, hence "derived, not direct" — after the change each user is registered to exactly the events they are registered to now. + +Rejected: *writing an event-level Join on every meeting point Join* (two non-atomic writes per act, and on withdrawal of the last meeting point the app could no longer tell a direct sign-up from an automatic one without storing a flag — re-creating the derived status by hand). Rejected: *a "direct" flag on the participation* (no participation document exists on ActivityPods; the act is the record). + +### On ActivityPods (the active backend) + +Everything below reuses the participation mechanism unchanged ([[decision_2026-10-29_participation-privacy-mechanism]], [[decision_2026-11-03_participation-delivered-to-the-app]]); only the **target** of the act widens from "a meeting point" to "a meeting point or an event". + +- **The act.** `as:Join` / `as:Leave` posted to the user's outbox, `to` = the app actor alone, `object` = the event URI. Same privacy: readable by its author and the app only. +- **The backend's target resolution** (`registration.service.js`, `meetingPointTarget` / `meetingPointOf`) accepts a stored `as:Event` **without** `as:context` as an event target whose anchor is itself. Everything else applies as is: re-read in the declarer's pod, `dc:creator` must own that pod, serialized per target, newer-act-wins ledger keyed by (target, participant), both collections read back before the act counts. +- **The member URN is the same** as in the event's meeting points: `sha256(" ")` with event URI = the event itself. So one person carries one URN across the event's own list and all its meeting points' lists — the count stays "distinct URNs", publicly computable. +- **The event's own `apods:attendees` collection** already exists: `attendees.service.js` attaches one to every `as:Event` created, plain events included (observed 2026-10-12). It receives the direct sign-ups' URNs, under the same public-read reconciliation as a meeting point's list. +- **The participations collection** receives the event URI on a direct Join (removed on its Leave). Its audience is unchanged (the `/contacts` group): listing someone's direct sign-ups stays contacts-only ([[behavior_participation-listing-is-contacts-only]]). +- **No notification for a direct sign-up.** The declarer of an event is not its host and is not required to attend ([[knowledge_actors-and-concepts]]); `notifyCreator` runs for meeting point targets only. (Open point, see Uncertainties.) + +### Reads (`refineDerive.ts`, `refineData.tsx`) + +- `deriveContainer` reads the attendee list of **events** too (today only meeting points' lists are embedded and mapped); a direct sign-up becomes a participation keyed by the event id. +- `eventParticipantCount` = distinct member values over the event's own list **∪** its meeting points' lists. +- "Mine": my participations collection now holds meeting point URIs **and** event URIs; an event URI there is my direct sign-up. `getUserEvents(me)` unions both; a contact's `participatedEventsByContact` likewise. +- Recognition of contacts on an event (`getEventParticipants`) includes the event's own list. +- Pending acts / read-your-writes (`PendingActs`, `settleMembershipActs`, `countsWithOverlay`) are keyed by **target id** instead of meeting point id — an event id is one more key; the logic does not change. + +### Local demo and the suspended NextGraph path + +- `LocalDataProvider`: the seed's `seedParticipations` are already keyed by **event** id — they read naturally as direct sign-ups; mutations stay no-ops as today ([[knowledge_context-internals]] §local). +- `NgDataProvider` (suspended, not mounted at the root): out of scope; the new mutations **reject** with an explicit "not supported on the suspended NextGraph path" error, never a silent success. + +## Screen-facing contract (`festipodDataBase.ts`) + +Added to `FestipodDataContextValue`, implemented by each provider, shared derivation in `buildQueries`: + +```ts +interface EventRegistration { + direct: boolean; // signed up to the event itself + meetingPointIds: string[]; // the event's meeting points I am a member of + registered: boolean; // direct || meetingPointIds.length > 0 (R2, R3) +} +getEventRegistration(eventId: string): EventRegistration; +joinEvent(eventId: string): Promise; // direct sign-up (R1), idempotent +leaveEvent(eventId: string): Promise; // withdraws the direct sign-up AND every membership of the event's meeting points (R4) +``` + +- The **cascade belongs to the data context** (one domain rule, used by the event detail and by Home), the **confirmation to the screen** (a UI concern). +- `leaveEvent` posts one Leave per held target (the event if `direct`, each joined meeting point), in parallel; it resolves only when all were accepted and **rejects naming the targets that failed** — the derived status then truthfully shows the user still registered through what remains (R7). +- `registered` replaces the screens' local `joinedMeetingPointIds.size > 0` (which gates « Inviter »). + +## UI (`EventDetailScreen`, Home) + +- Event detail: an event-level toggle under the info block — « S'inscrire à l'événement » when not registered; « ✓ Inscrit à l'événement » + « Se désinscrire de l'événement » when registered (direct or derived). +- Withdrawal with memberships ⇒ a confirmation dialog: title « Se désinscrire de l'événement ? », body « Vous êtes inscrit aux points de rencontre suivants : . En confirmant, vous en serez aussi désinscrit. », actions « Confirmer la désinscription » / « Annuler ». Without memberships: no dialog, immediate withdrawal. +- No confirm dialog exists in `src/shared/components/sketchy/` today: add a `ConfirmDialog` component there (shared, importable by any module — `rule_module-imports`). +- Meeting point toggles are unchanged (no confirmation when leaving a meeting point — R3). +- Home's « mes événements » comes from `getUserEvents(me)`, so directly joined events appear there with no screen change. + +## Business rules + +- **R1 — Direct sign-up.** A user can sign up to an event directly, whether or not it has meeting points. Signing up twice changes nothing (idempotent). +- **R2 — Meeting point ⇒ event.** A member of at least one meeting point of an event is registered to that event, without any further act. +- **R3 — Leaving a meeting point.** Leaving a meeting point never asks for confirmation and never touches the other memberships. Afterwards the user is still registered to the event iff they are a member of another of its meeting points **or** signed up to it directly; otherwise they are no longer registered. +- **R4 — Leaving an event.** If the user is a member of at least one of its meeting points, a confirmation names those meeting points and says they will be left too; confirming withdraws the direct sign-up (if any) and every such membership; cancelling changes nothing. Without memberships, the withdrawal is immediate, with no confirmation. +- **R5 — One person counts once.** The event's participant count is the number of distinct persons registered to it, directly or through meeting points; a meeting point's count is its own members only (a direct sign-up does not count in any meeting point). +- **R6 — Same confidentiality.** A direct sign-up is as unlinkable as a meeting point membership: the act is readable by its author and the app only; the event's list holds an opaque member URN; listing someone's events (direct ones included) is contacts-only; the declarer learns a count, not a person. +- **R7 — Nothing succeeds in silence.** A refused or failed act shows an error; the displayed status follows the data (a partially failed withdrawal leaves the user shown as registered through what remains). +- **R8 — Withdrawal is authoritative.** After a withdrawal (from a meeting point or an event) and a real reload, the user is not registered back ([[caveat_participation-deletion]]). + +## Acceptance criteria + +| # | Given | When | Then | Rules | +|---|---|---|---|---| +| AC1 | not registered to E (E with or without meeting points) | taps « S'inscrire à l'événement » | shown « ✓ Inscrit à l'événement »; E in Home's events; E's count +1 | R1, R5 | +| AC2 | signed up directly to E | signs up again (API) | no second entry; count unchanged | R1 | +| AC3 | not registered to E | joins meeting point M of E | shown registered to E, with no event-level act sent | R2 | +| AC4 | member of M1 and M2 of E, not direct | leaves M1 | still registered to E; no dialog | R3 | +| AC5 | member of M1 only, not direct | leaves M1 | no longer registered to E; E leaves Home's events | R3 | +| AC6 | member of M1 only, **and** direct | leaves M1 | still registered to E | R3 | +| AC7 | member of M1 and M2 of E | taps « Se désinscrire de l'événement » | dialog lists M1 and M2 and warns they will be left | R4 | +| AC8 | AC7's dialog | « Annuler » | nothing sent, still member of M1, M2, still registered | R4 | +| AC9 | AC7's dialog | « Confirmer la désinscription » | not registered to E, member of neither M1 nor M2 | R4 | +| AC10 | signed up directly, no membership | taps « Se désinscrire de l'événement » | no dialog; no longer registered | R4 | +| AC11 | direct + member of M1, same person | — | E's count counts them once; M1's count counts them once | R5 | +| AC12 | direct sign-up by A | B, not A's contact, opens E / reads the lists | B sees a count and opaque URNs, never A; A's participations collection is unreadable to B | R6 | +| AC13 | A's contact C | opens E and A's profile | C recognises A among E's participants and sees E in A's events | R6 | +| AC14 | the Join/Leave is refused (e.g. no session) | any act | an error toast; the status shown is unchanged | R7 | +| AC15 | after AC5, AC9 or AC10 | real reload | still not registered | R8 | +| AC16 | data stored before the change (memberships only) | first load after | every user registered to exactly the same events as before; nobody shown as directly registered | migration | + +## Test plan + +The `@data` and `@e2e` suites of the NextGraph broker are **suspended** (`@suspendu`, excluded by `cucumber.json`) along with that workstream; on the active backend the data layer is exercised by `@e2e-activitypods` (real local stack, no browser unless `@browser`) and by the rights matrix (`bun run test:e2e-rights`). The layers below follow [[rule_test-layer-contracts]] mapped onto that reality: **`@ui`** = rendering over the seed; **"data"** = unit tests (`bun test`) of the pure derivations and the backend handler, plus `@e2e-activitypods` scenarios on the Pod collections; **`@e2e`** = one `@e2e-activitypods @browser` journey. All runs obey [[rule_tests-never-break-the-dev-environment]]: throwaway accounts only, cleaned up; nothing resets the stack; if the local stack is down, the live scenarios are reported as not run, never started by the test. + +### `@ui` — `src/modules/event/features/us-7-inscription-evenement.feature` (rewritten: the `@data @suspendu` scenarios of the old event-level model stay suspended) + +The seed gives the fixtures: an event Marie (current user) is directly registered to (seed participations are event-keyed), one with meeting points she is a member of (add one membership fixture to `seedParticipations`/meeting points), one she is not registered to. + +- « Voir le bouton d'inscription à l'événement » — not registered → « S'inscrire à l'événement » (R1) +- « Un inscrit direct voit son inscription à l'événement » (R1) +- « Être inscrit à un point de rencontre affiche l'inscription à l'événement » (R2) +- « Se désinscrire d'un événement avec points de rencontre demande confirmation » — dialog lists the meeting point titles (R4) +- « La confirmation de désinscription précise les points de rencontre quittés » (R4) +- « Annuler la confirmation ne change rien » (R4) +- « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation » (R4) +- « Quitter un point de rencontre ne demande pas de confirmation » (R3) +- « Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre » (R2) + +`@ui` renders against `LocalDataProvider`, whose mutations are no-ops: dialog content and opening are `@ui`; the outcome of confirming is data (below). + +### Data — unit tests (`bun test`) + +- `src/shared/context/refineDerive.test.ts`: event's own list mapped to event-keyed participations; count = distinct URNs over event list ∪ meeting point lists (same URN in both counts once — R5); a direct sign-up does not count in a meeting point (R5); overlay of a pending event act. +- `src/shared/context/festipodDataBase.test.ts`: `getEventRegistration` truth table — none / direct / one membership / two memberships / direct + membership, and the status after removing each membership (R2, R3). +- `src/shared/context/refineData.test.tsx`: `joinEvent` posts one Join naming the event, secret first; `leaveEvent` posts Leave for the event (if direct) and for each joined meeting point, and rejects naming the failed targets on a partial failure (R4, R7); `leaveEvent` with nothing held rejects. +- `backend/services/registration.service.test.js`: a Join naming a plain event records the URN computed with the event as anchor in the event's list and the event URI in the participations collection; no notification; the same participant's URN equals the one recorded in the event's meeting points (R5, R6); a Join naming a non-`as:Event` or a foreign-pod event stays refused. + +### Data — `@e2e-activitypods` (`src/modules/event/features/e2e-activitypods.feature`, live stack) + +- « Rejoindre puis quitter un événement met à jour sa collection apods:attendees » (R1, R8) +- « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre » (R5, R6) +- « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement » (R3) +- « Quitter le dernier point de rencontre garde l'inscription directe » (R3) +- « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre » (R4, R8) + +Rights matrix (`scripts/e2e-rights-matrix.ts`): add the lines « a direct sign-up is not listable by a non-contact » and « a contact lists the direct sign-ups » (R6, AC12–13); the existing lines must still pass. + +### `@e2e` — one journey, `@e2e-activitypods @browser` + +- « Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation » — sign up directly, join one meeting point, leave the event, see the dialog, confirm, reload, still not registered (R1, R2, R4, R7, R8). + +### Traceability + +| Rule | Scenarios | +|---|---| +| R1 | « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », journey | +| R2 | « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Le bouton Inviter suit l'inscription… », `getEventRegistration` unit, journey | +| R3 | « Quitter un point de rencontre ne demande pas de confirmation », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », `getEventRegistration` unit | +| R4 | « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation… précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », journey | +| R5 | « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », `refineDerive` units | +| R6 | same URN scenario, rights matrix lines, backend unit | +| R7 | `refineData` partial-failure unit, journey | +| R8 | « Rejoindre puis quitter un événement… », « Se désinscrire de l'événement retire… », journey (reload) | + +## Files to touch + +- **Doctrine** (on validation): this brief → `decision_`; [[knowledge_business-model]], [[knowledge_actors-and-concepts]] (participant row), the `functional-domain` overview, `AGENTS.md` (the "on s'inscrit à un point de rencontre, pas à un événement" line), [[knowledge_roadmap]]. +- **Contract & queries**: `src/shared/context/festipodDataBase.ts` (interface, `buildQueries`, header), `src/shared/context/FestipodDataContext.tsx` (local provider), `src/shared/context/ngData.tsx` (explicit rejections). +- **ActivityPods reads/writes**: `src/shared/context/refineDerive.ts`, `src/shared/context/refineData.tsx`, `src/shared/data/outbox.ts` (target URI rather than `meetingPointUri`), `src/shared/refine/providers/activitypods.ts` (event attendees `membersOf`). +- **Backend**: `backend/services/registration.service.js` (event target, no notification for it). +- **UI**: `src/modules/event/screens/EventDetailScreen.tsx`, `src/modules/home/screens/HomeScreen.tsx` (registration status from the context), new `src/shared/components/sketchy/ConfirmDialog.tsx` + `index.ts` export, stories for both screens. +- **Fixtures & tests**: `src/shared/data/seedData.ts` (one meeting point membership), the features and tests listed above, their steps in `src/modules/event/steps/{ui,e2e}/`, and `src/shared/test-harness/harness.tsx` if a `@ui` step needs a new bridge helper. + +## Risks + +- **Events without an attendee list.** A direct Join is skipped ("No attendees collection") on an event created before the attendee service attached lists to plain events, or whose attach failed. Check on the local stack before implementing; if such events exist, attach the list on demand in the handler (the attach helper already exists) — not designed here. +- **Partial cascade.** `leaveEvent` is N independent acts; a failure leaves some memberships. Mitigated by R7 (rejection naming the targets, derived status truthful) — not atomic, as every act already is ([[decision_2026-10-29_participation-privacy-mechanism]] §Accepted residuals). +- **Read cost.** One more collection read per event (its own list), on top of one per meeting point. +- **Doctrine drift.** "You sign up to a meeting point, never to an event" is repeated in several places, code headers included; leaving one unamended will steer later agents back to the old model. +- **Product dilution.** A direct sign-up lets an event be "joined" with no meeting point at all — the thing the business model says has no value. Accepted by the request; worth watching in the UI (e.g. still putting meeting points forward after a direct sign-up). +- **Delivery latency.** Each act holds the participant's pod request for 2.6–6.2 s ([[decision_2026-11-03_participation-delivered-to-the-app]] §Costs); a cascade over several meeting points multiplies the acts (they run in parallel, the app's inbox queue runs 5 at a time). + +## Uncertainties (for the user) + +- **R3 default** (stay registered after the last meeting point only if signed up directly): adopted; it is also what the derived model gives for free. The alternative ("always stay registered") would require writing an event-level act on every meeting point Join, which this design rejects. +- **Declarer notification on a direct sign-up**: none proposed (no host role at event level). To confirm. +- **Does a direct sign-up appear in a meeting point's count?** No (R5). To confirm.