Compare commits
8 Commits
main
...
9671345634
| Author | SHA1 | Date | |
|---|---|---|---|
| 9671345634 | |||
| ebcf5f4266 | |||
| 9169a5817c | |||
| 5540d361c7 | |||
| af0cba6920 | |||
| 9cbe78948b | |||
| 4abab5a55a | |||
| f45fc564ec |
@@ -17,7 +17,7 @@ last_checked: 2026-08-17
|
||||
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
|
||||
| `FpFriendshipData` | **derived** on ActivityPods (mirror of the Pod's `apods:contacts`, see [[decision_2026-10-21_contacts-are-the-pod-social-graph]]); absent elsewhere | userId + friendId |
|
||||
|
||||
**On ActivityPods the participation is not a document of its own.** A sign-up is a member URN in the meeting point's `apods:attendees` collection plus the meeting point's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
|
||||
**On ActivityPods the participation is not a document of its own.** A sign-up is a member URN in the `apods:attendees` collection of its target — a meeting point, or the event itself for a direct sign-up (concept `functional-domain`, [[decision_2026-11-06_direct-event-registration]]) — plus the target's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
|
||||
|
||||
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Festipod's **functional domain**: what the product promises and the business voc
|
||||
|
||||
## Pivotal idea
|
||||
|
||||
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
|
||||
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event** ([[decision_2026-11-06_direct-event-registration]]).
|
||||
|
||||
## Scope & confidentiality
|
||||
|
||||
@@ -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
|
||||
- [[decision_2026-11-06_direct-event-registration]] — direct sign-up to an event (FP-001): registration derived from it or from a meeting point membership, anonymous notification of the declarer, confidentiality unchanged, confirmed cascade on leaving the event
|
||||
|
||||
@@ -29,5 +29,6 @@ The rule states the **what**, never the **how** — and that is intended. Which
|
||||
|
||||
- [[knowledge_data-scopes-and-discovery]] — the scopes model this rule tightens (participation: public as an object, the identity link is protected)
|
||||
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing this rule on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
|
||||
- [[decision_2026-11-06_direct-event-registration]] — a direct sign-up to an event is a participation under this rule, unchanged
|
||||
- [[decision_2026-11-03_participation-delivered-to-the-app]] — how that private Join reaches the backend: addressed to the app actor alone, delivered to the app's inbox
|
||||
- [[decision_2026-10-21_contacts-are-the-pod-social-graph]] — what "contact" means (the Pod's bilateral `apods:contacts`)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ summary: How sign-ups stay unlinkable on ActivityPods — a private Join handled
|
||||
|
||||
# Decision (2026-10-29): the participation privacy mechanism
|
||||
|
||||
User-validated 2026-10-29. Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
|
||||
User-validated 2026-10-29. Widened by [[decision_2026-11-06_direct-event-registration]]: a sign-up may also name an event itself, under this mechanism unchanged (its own list, the same URN, the event URI in the participations collection, its declarer notified anonymously). Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
|
||||
|
||||
## What it protects: two links
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
type: decision
|
||||
summary: A user signs up to a meeting point or directly to an event; registration to an event = direct sign-up OR membership of one of its meeting points, derived at read and never stored; a direct sign-up notifies the declarer anonymously and follows the participation confidentiality rules unchanged; leaving an event with meeting points asks for confirmation and withdraws them too
|
||||
---
|
||||
|
||||
# Decision (2026-11-06): direct sign-up to an event (FP-001)
|
||||
|
||||
User-validated 2026-11-06 (ticket FP-001). It **amends the pivotal sentence** of the product model: the old "you sign up to a meeting point, never to an event" becomes **"you sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event"** — [[knowledge_business-model]], the `functional-domain` overview, [[knowledge_actors-and-concepts]] and `AGENTS.md` carry the new sentence. The meeting point stays the **unit of value**; an event can now be joined on its own — "I'll be there" before, or without, any meeting point.
|
||||
|
||||
Every rule of the participation mechanism stands ([[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 confidentiality rules apply **without change** ([[behavior_participation-listing-is-contacts-only]]): a direct sign-up is a participation like any other.
|
||||
|
||||
## The request
|
||||
|
||||
- sign-up to a meeting point ⇒ registered to its event;
|
||||
- withdrawal from a meeting point ⇒ still registered iff in another meeting point of that event **or** signed up to it directly (user-confirmed);
|
||||
- withdrawal from an event ⇒ if the user is in meeting points of it, ask for confirmation, saying those memberships go too;
|
||||
- a direct sign-up counts in no meeting point (user-confirmed);
|
||||
- a direct sign-up **notifies the event's declarer** (user decision, replacing the "no notification" first proposed).
|
||||
|
||||
## 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.
|
||||
- **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; 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)
|
||||
|
||||
- **The act.** `as:Join` / `as:Leave` posted to the user's outbox, `to` = the app actor alone, `object` = the event URI, delivered to the app's inbox ([[decision_2026-11-03_participation-delivered-to-the-app]]). 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("<WebID> <event URI> <secret>")` with event URI = the event itself. 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 (readable anonymously while the event is public, closed when it is switched private).
|
||||
- **The participations collection** receives the event URI on a direct Join (removed on its Leave). Its audience is unchanged (the participant's `/contacts` group).
|
||||
- **The declarer is notified**, exactly as a meeting point's host is: by `notifyCreator` (`pod-notifications.send`), recipient = the event's `dc:creator`, on a direct sign-up **and** on its withdrawal, only when the ledger state flips (a repeated or stale act notifies no one), **always anonymous** — no name, no WebID, no activity, the participant's contacts list never read. The notification names the event (template parameter, not the participant) and its link opens the event. A notification failure does not fail the act. Notifying gives the declarer no read right: they stay "anyone else" towards the participant unless a contact.
|
||||
|
||||
### Reads (`refineDerive.ts`, `refineData.tsx`)
|
||||
|
||||
- `deriveContainer` reads the attendee list of **events** too; 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 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 — with the contacts' secrets only, as for meeting points.
|
||||
- Pending acts / read-your-writes (`PendingActs`, `settleMembershipActs`, `countsWithOverlay`) are keyed by **target id** instead of meeting point id.
|
||||
|
||||
### Local demo and the suspended NextGraph path
|
||||
|
||||
- `LocalDataProvider`: the seed's `seedParticipations` are keyed by **event** id — they read as direct sign-ups; mutations stay no-ops.
|
||||
- `NgDataProvider` (suspended): 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`)
|
||||
|
||||
```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<void>; // direct sign-up (R1), idempotent
|
||||
leaveEvent(eventId: string): Promise<void>; // withdraws the direct sign-up AND every membership of the event's meeting points (R4)
|
||||
```
|
||||
|
||||
- The **cascade belongs to the data context**, the **confirmation to the screen**.
|
||||
- `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** (R8).
|
||||
- `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 : <titres>. En confirmant, vous en serez aussi désinscrit. », actions « Confirmer la désinscription » / « Annuler ». Without memberships: no dialog, immediate withdrawal.
|
||||
- A shared `ConfirmDialog` in `src/shared/components/sketchy/` (none exists today).
|
||||
- Meeting point toggles are unchanged (no confirmation when leaving a meeting point — R3).
|
||||
- Home's « mes événements » comes from `getUserEvents(me)`: directly joined events appear there with no screen change.
|
||||
- The participants shown on an event remain the viewer's **contacts** only, whatever their path (direct or meeting point); anyone else is counted, never shown.
|
||||
|
||||
## 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 counts in no meeting point.
|
||||
- **R6 — The declarer is notified, anonymously.** A direct sign-up and its withdrawal notify the event's declarer, as a meeting point's sign-up notifies its host: once per change of state, never naming the participant (no name, no WebID). Leaving an event through the cascade notifies the declarer (if the sign-up was direct) and each left meeting point's host, each once. A failed notification does not fail the act.
|
||||
- **R7 — Same confidentiality, unchanged.** A direct sign-up is a participation and follows exactly the rules of a meeting point participation ([[behavior_participation-listing-is-contacts-only]], [[decision_2026-10-29_participation-privacy-mechanism]]):
|
||||
- **R7a — The act is private**: readable by its author and the app only — not by the declarer, not by anyone else.
|
||||
- **R7b — Event → participants**: the event's own list holds opaque member URNs, never a WebID or a name; anyone reads a count; only a **contact** of the participant recognises them among the event's participants.
|
||||
- **R7c — Person → events**: listing someone's events, direct sign-ups included, is reserved to the person and their contacts; anyone else — **the declarer included** — is refused.
|
||||
- **R7d — No particular right for the declarer**: declaring an event grants no read on who signed up to it, as creating a meeting point grants none to its host; the notification is their only signal, and it is anonymous.
|
||||
- **R7e — No cross-event correlation**: the URN of one person differs from one event to another; the same person on the event and on its meeting points carries one URN (counted once — R5).
|
||||
- **R8 — 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).
|
||||
- **R9 — 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; no second notification | R1, R6 |
|
||||
| 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 | D declared E, A is not D's contact | A signs up directly to E | D is notified of a new participant to E, without A's name or WebID | R6, R7d |
|
||||
| AC13 | AC12 | A withdraws from E | D is notified of a withdrawal, anonymously | R6 |
|
||||
| AC14 | A direct + member of M1 (host H) | A confirms leaving E | D notified once (leave), H notified once (leave), both anonymous | R6 |
|
||||
| AC15 | the notification cannot be sent | A signs up directly | the sign-up stands | R6 |
|
||||
| AC16 | direct sign-up by A | anyone reads A's Join | refused, the declarer included | R7a |
|
||||
| AC17 | direct sign-up by A | B, not A's contact, opens E / reads E's list | B sees a count and opaque URNs, never A | R7b |
|
||||
| AC18 | direct sign-up by A | B, not A's contact (declarer D included), reads A's participations collection | refused | R7c, R7d |
|
||||
| AC19 | A's contact C | opens E and A's profile | C recognises A among E's participants and sees E in A's events | R7b, R7c |
|
||||
| AC20 | A direct to E and to another event E' | anyone reads both lists | the two URNs differ | R7e |
|
||||
| AC21 | the Join/Leave is refused (e.g. no session) | any act | an error toast; the status shown is unchanged | R8 |
|
||||
| AC22 | after AC5, AC9 or AC10 | real reload | still not registered | R9 |
|
||||
| AC23 | 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`); on the active backend the data layer is exercised by `@e2e-activitypods` and by the rights matrix (`bun run test:e2e-rights`). Mapping of [[rule_test-layer-contracts]]: **`@ui`** = rendering over the seed; **data** = unit tests (`bun test`) of the derivations and the backend handler, plus `@e2e-activitypods` scenarios; **`@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; a down stack means "not run", never started by the test.
|
||||
|
||||
### Already written (commit 4abab5a) and still valid
|
||||
|
||||
- `@ui` (`us-7-inscription-evenement.feature`): « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation de désinscription 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 », « Quitter un point de rencontre ne demande pas de confirmation », « Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre ».
|
||||
- `@e2e-activitypods` (`e2e-activitypods.feature`): « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », « 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 », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », and the journey « Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation ».
|
||||
- Units: `refineDerive.test.ts`, `festipodDataBase.test.ts`, `refineData.test.tsx` as written; in `registration.service.test.js` the direct sign-up block **except** its notification test (below).
|
||||
- Rights matrix group `direct sign-up to E`: `direct.join`, `direct.non-contact-refused`, `direct.contact-lists`, `direct.leave`.
|
||||
|
||||
### Delta for the QA — to modify
|
||||
|
||||
| Where | Test | Change | Rules |
|
||||
|---|---|---|---|
|
||||
| `backend/services/registration.service.test.js` | `a direct sign-up notifies nobody: the declarer of an event is not its host` | **replace** by `a direct sign-up notifies the event's declarer, anonymously`: one notification, recipient = the event's `dc:creator`, join wording, no name / WebID / activity / `participantName`, the participant's contacts list never read, the event named by template parameter, the link opening the event | R6, R7d |
|
||||
|
||||
### Delta for the QA — to add
|
||||
|
||||
**Backend units** (`registration.service.test.js`, in the direct sign-up block):
|
||||
|
||||
- `a Leave naming the event notifies the declarer, anonymously` — leave wording, same anonymity checks (R6).
|
||||
- `a repeated direct Join notifies once; a Leave of someone never signed up notifies no one` — ledger-driven, as for meeting points (R1, R6).
|
||||
- `a failed declarer notification does not fail the direct sign-up` (R6).
|
||||
- `leaving the event and its meeting point notifies the declarer and the host, each once` — Leave on E then on M1: two anonymous notifications, one per recipient (R6).
|
||||
- `a direct Join's URN on one event differs from the same person's URN on another event` (R7e).
|
||||
|
||||
**Rights matrix** (`scripts/e2e-rights-matrix.ts`, group `direct sign-up to E`; E's declarer must **not** be Alice's contact for the anonymity lines — Carol's event `CE` fits, or a declarer created for the group):
|
||||
|
||||
- `direct.notify-anonymous` — the declarer is notified of the direct Join in their own inbox, naming neither her name nor her WebID (R6, R7d).
|
||||
- `direct.leave-notify-anonymous` — the same for the Leave (R6).
|
||||
- `direct.activity-declarer-refused` — the declarer GETs Alice's Join naming E (URI handed out of band): refused 403 (R7a).
|
||||
- `direct.list-opaque` — E's own attendee list (anonymous read) holds Alice's member URN — the one computed from her secret with E as anchor — and no WebID (R7b).
|
||||
- `direct.declarer-refused-participations` — the declarer, not her contact, is refused Alice's participations collection although it lists E (R7c, R7d).
|
||||
|
||||
**`@ui`** (`src/modules/event/features/us-7-inscription-evenement.feature`, seed: a contact of Marie directly registered to an event, no meeting point membership):
|
||||
|
||||
- « Un contact inscrit directement apparaît parmi les participants de l'événement » (R7b).
|
||||
- « Un participant inscrit directement qui n'est pas un contact n'est que compté » — counted, not shown (R7b).
|
||||
|
||||
**`@ui`** (`src/modules/user/features/us-10-profil-participant.feature`):
|
||||
|
||||
- « Le profil d'un contact liste les événements auxquels il est inscrit directement » (R7c).
|
||||
|
||||
**`@e2e-activitypods`** (`e2e-activitypods.feature`, `@inscription-evenement`):
|
||||
|
||||
- « Une inscription directe notifie le déclarant de l'événement sans nommer le participant » — the declarer's inbox holds a join notification about E, anonymous; then the Leave's (R6). Only if the declarer is another throwaway account; otherwise the rights matrix lines above suffice and this scenario is dropped.
|
||||
|
||||
### 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, repeated-Join unit |
|
||||
| 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 | the 5 notification units, `direct.notify-anonymous`, `direct.leave-notify-anonymous`, « Une inscription directe notifie le déclarant… » |
|
||||
| R7 | `direct.activity-declarer-refused` (a), `direct.list-opaque` + the two `@ui` participants scenarios (b), `direct.non-contact-refused` + `direct.declarer-refused-participations` + `direct.contact-lists` + « Le profil d'un contact liste… » (c, d), URN-per-event unit (e) |
|
||||
| R8 | `refineData` partial-failure unit, journey |
|
||||
| R9 | « Rejoindre puis quitter un événement… », « Se désinscrire de l'événement retire… », journey (reload) |
|
||||
|
||||
## Files to touch
|
||||
|
||||
- **Doctrine** (done with this decision): this file, [[knowledge_business-model]], [[knowledge_actors-and-concepts]], [[knowledge_data-scopes-and-discovery]], the `functional-domain` overview, `AGENTS.md`.
|
||||
- **Code comments carrying the old sentence** (for the dev): the header of `src/shared/context/festipodDataBase.ts` (l. 5), `src/modules/event/screens/EventDetailScreen.tsx` (l. 45–46), `src/modules/event/steps/ui/event.steps.ts` (l. 80–82); `README.md §Modèle fonctionnel` (l. 24) says the same.
|
||||
- **Contract & queries**: `src/shared/context/festipodDataBase.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/context/ngData.tsx`.
|
||||
- **ActivityPods reads/writes**: `src/shared/context/refineDerive.ts`, `src/shared/context/refineData.tsx`, `src/shared/data/outbox.ts`, `src/shared/refine/providers/activitypods.ts`.
|
||||
- **Backend**: `backend/services/registration.service.js` (event target; `notifyCreator` for an event target: recipient the declarer, template naming the event, link to the event).
|
||||
- **UI**: `EventDetailScreen.tsx`, `HomeScreen.tsx`, new `src/shared/components/sketchy/ConfirmDialog.tsx` + export, stories.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Events without an attendee list.** A direct Join is skipped ("No attendees collection") on an event whose list was never attached. Check on the local stack before implementing; if such events exist, attach the list on demand in the handler.
|
||||
- **Partial cascade.** `leaveEvent` is N independent acts; mitigated by R8 — not atomic, as every act already is.
|
||||
- **Read cost.** One more collection read per event (its own list).
|
||||
- **Notification volume for the declarer.** Every direct sign-up of a popular event reaches its declarer, who may be a mere referencer. Accepted by the user; a per-event mute belongs to the notification settings (`us-18`), not designed here.
|
||||
- **Product dilution.** A direct sign-up lets an event be joined with no meeting point at all. Accepted by the request; keep meeting points forward in the UI after a direct sign-up.
|
||||
- **Delivery latency.** 2.6–6.2 s per act ([[decision_2026-11-03_participation-delivered-to-the-app]] §Costs); a cascade multiplies the acts.
|
||||
|
||||
## Settled with the user (2026-11-06)
|
||||
|
||||
- R3 default: stays registered after the last meeting point only if signed up directly.
|
||||
- A direct sign-up counts in no meeting point (R5).
|
||||
- A direct sign-up notifies the declarer (R6) — the first proposal said no notification.
|
||||
- Confidentiality applies unchanged (R7).
|
||||
@@ -13,16 +13,17 @@ Vocabulary reference. Every actor is a specialization of an authenticated **user
|
||||
|---|---|
|
||||
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
|
||||
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. The declarer **is notified, anonymously,** of direct sign-ups to the event and their withdrawals, and gains no read right over who signed up ([[decision_2026-11-06_direct-event-registration]]). |
|
||||
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
|
||||
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
|
||||
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become registered to the parent event. |
|
||||
| **Participant in an event** | A user registered to an event: signed up to it directly, or a participant in one of its meeting points (derived, never stored — [[decision_2026-11-06_direct-event-registration]]). A direct sign-up notifies the declarer anonymously and follows the same confidentiality rules as a meeting point participation. |
|
||||
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
|
||||
|
||||
## Business concepts
|
||||
|
||||
| Concept | Definition |
|
||||
|---|---|
|
||||
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
|
||||
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you mainly sign up to — an event can also be joined directly. |
|
||||
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
|
||||
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
|
||||
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
|
||||
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to a meeting point or directly to the event, and a meeting point registers you to its event
|
||||
---
|
||||
|
||||
# Business model: the grafted meeting point
|
||||
@@ -13,7 +13,7 @@ The **public event** (festival, conference, trade show, exhibition…) is **only
|
||||
|
||||
Direct consequences for modelling:
|
||||
|
||||
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
|
||||
- **You sign up to a meeting point, or directly to an event.** Being in one of an event's meeting points registers you to that event; a direct sign-up says "I'll be there" before, or without, any meeting point, and counts in no meeting point ([[decision_2026-11-06_direct-event-registration]]). The meeting point stays the value: without meeting points, a Festipod event brings little more than a head count.
|
||||
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
|
||||
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Every entity is stored in the **scope** matching who must be able to read it:
|
||||
| Event (the anchor) | **public** | everyone |
|
||||
| Meeting point | **public** | everyone |
|
||||
| Network profile (name, avatar, bio, city, interests) | **protected** | the holder + their connections |
|
||||
| Participation / sign-up to a meeting point | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
|
||||
| Participation / sign-up to a meeting point or directly to an event | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
|
||||
| Connection index | **protected** | the holder + their connections |
|
||||
| Private profile (settings, email, preferences) | **private** | the holder alone |
|
||||
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
|
||||
@@ -40,7 +40,7 @@ The intent is the same on both backends — every public event is findable by ev
|
||||
- **ActivityPods (the default backend): the Festipod backend's catalog.** An event or meeting point is public when Festipod creates it addressed to the ActivityStreams Public collection (`to: as:Public`). Such a record is readable by anyone at its URI, and the Festipod backend lists it in its catalog at once — no curation. The listing follows the record: the owner's edits update it; a deletion, or a switch to private, removes it and makes the record and its attendee list unreadable anonymously again. Only a record's owner can change its listing. Découvrir shows the user's own events plus the catalog's; opening another user's event reads that event, its meeting points and their attendee counts anonymously, by URI. Costs, accepted: a record created while the backend was not receiving the pod's notifications is never listed (no backfill), and the catalog lists meeting points too, so its own total is not an event count. Verified end to end by `bun run test:e2e-rights` (groups catalog, discovery, visibility).
|
||||
- **NextGraph (backend suspended): a curated shared-index document** — [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`), no code consumes it yet. An ordinary public document the app reaches by a reference it hardcodes; anyone may deposit a reference to their event, only the index's owner curates deposits into visible entries, ordered by start date. Its accepted costs: an event is findable only once **someone curates** the index (an operator role, no fixed schedule); an event declared before its document could carry the indexed field never becomes findable; a withdrawn or corrected event **stays listed**, so a reader must tolerate a reference that no longer resolves or resolves to something changed; an entry's position is **frozen at curation**.
|
||||
|
||||
> **Sign-up notification.** Signing up to a meeting point, and withdrawing, notifies its host. The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
|
||||
> **Sign-up notification.** Signing up to a meeting point, and withdrawing, notifies its host; signing up directly to an event, and withdrawing, notifies its declarer the same way ([[decision_2026-11-06_direct-event-registration]]). The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
|
||||
|
||||
## Settled: the event write model is OWNER-ONLY
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Festipod
|
||||
|
||||
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre, pas à un événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
|
||||
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
|
||||
|
||||
## Invariants à toujours garder
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -35,17 +35,13 @@ 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
|
||||
* stamps the creator, and nobody can store a resource in someone else's pod); on the copy read
|
||||
* through the participant's pod, it only names where to read that one. Resolves
|
||||
* `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
* What a Join/Leave targets: an `as:Event` whose `dc:creator` owns the pod it lies in. A meeting point
|
||||
* is anchored to its event by `as:context`; an event signed up to directly is its own anchor. Only on
|
||||
* the copy the creator's pod serves (`meetingPointOf`) is that creator believed; on the copy read
|
||||
* through the participant's pod, it only names where to read that one. Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
function meetingPointTarget(meetingPoint, meetingPointUri) {
|
||||
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`);
|
||||
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,10 +51,10 @@ 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
|
||||
* inside the template, so no user text is compiled.
|
||||
* The notification template for the creator of a target (`pod-notifications.send`, rendered in the
|
||||
* creator's language): always ANONYMOUS, linking to the event, or to its meeting points when the target
|
||||
* is one. Every value travels as a template PARAMETER, never inside the template, so no user text is
|
||||
* compiled.
|
||||
*
|
||||
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
|
||||
* participant's contacts, and the only one the app can read — the participant's `apods:contacts` list —
|
||||
@@ -66,21 +62,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:
|
||||
@@ -295,7 +288,7 @@ module.exports = {
|
||||
}
|
||||
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri);
|
||||
if (!collectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
|
||||
this.logger.error(`${meetingPointUri} has no attendees collection: the ${change} ${act.activityUri} of ${participantUri} is NOT recorded, the next act retries`);
|
||||
return undefined;
|
||||
}
|
||||
if (signal.aborted) return undefined;
|
||||
@@ -366,25 +359,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) 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) {
|
||||
|
||||
@@ -185,11 +185,6 @@ describe('registration — refusals write nothing and record nothing', () => {
|
||||
await refused(setup({ acts: [j], stored: { [MP1]: { ...meetingPoint(MP1), type: 'Note' } } }), j, { contains: 'is not a Event' });
|
||||
});
|
||||
|
||||
test('an Event with no as:context (no event to anchor the URN)', async () => {
|
||||
const j = join(MP1);
|
||||
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { context: undefined }) } }), j, { contains: 'as:context' });
|
||||
});
|
||||
|
||||
test('a meeting point not in the pod of its dc:creator, or naming no creator', async () => {
|
||||
const j = join(MP1);
|
||||
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': MALLORY }) } }), j, { contains: 'not in the pod of its creator' });
|
||||
@@ -847,3 +842,212 @@ describe('attendance-ledger — the order of acts', () => {
|
||||
expect(() => lastAttendanceQuery(MP1, BOB)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — a direct sign-up: a Join naming an event itself', () => {
|
||||
const DAVE = ALICE.replace('/alice', '/dave');
|
||||
const PLAIN = `${ALICE}/data/events/plain`;
|
||||
const plainEvent = (uri, extra = {}) => meetingPoint(uri, { context: undefined, name: `Evénement ${uri.split('/').pop()}`, ...extra });
|
||||
const plainWorld = (acts, options = {}) => setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) }, ...options });
|
||||
const plainUrn = (who = BOB, event = PLAIN) => memberUrn({ participantUri: who, eventUri: event, secret: SECRETS[who] });
|
||||
|
||||
test("the member URN, computed with the event as anchor, joins the event's own list and the event joins the participant's collection", async () => {
|
||||
const j = join(PLAIN);
|
||||
const w = plainWorld([j]);
|
||||
await emitJoin(w, j);
|
||||
|
||||
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
|
||||
expect(attendees(w, PLAIN)).not.toContain(BOB);
|
||||
const add = w.calls.find(c => c.action === 'attendees.add');
|
||||
expect(add.params).toEqual({ collectionUri: attendeesOf(PLAIN), itemUri: plainUrn(), actorUri: ALICE });
|
||||
expect(participations(w)).toEqual([PLAIN]);
|
||||
const added = w.calls.find(c => c.action === 'pod-collections.add');
|
||||
expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: PLAIN, actorUri: BOB });
|
||||
expect(ledgerOf(w, PLAIN)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: plainUrn() });
|
||||
expect(w.logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test('a Join on an event with no attendees collection is NOT recorded, loudly logged — nothing written, nothing notified', async () => {
|
||||
const j = join(PLAIN);
|
||||
const w = setup({ acts: [j], stored: { [PLAIN]: plainEvent(PLAIN, { 'apods:attendees': undefined }) } });
|
||||
await emitJoin(w, j);
|
||||
expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes('no attendees collection'))).toBe(true);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(ledgerOf(w, PLAIN)).toBeUndefined();
|
||||
expect(participations(w)).toEqual([]);
|
||||
});
|
||||
|
||||
const readsOf = (w, uri) => w.calls.filter(c => ['pod-resources.get', 'pod-collections.getItems'].includes(c.action) && (c.params.resourceUri ?? c.params.collectionUri) === uri);
|
||||
const expectAnonymous = (w, n) => {
|
||||
expect(n.recipientUri).toBe(ALICE);
|
||||
expect(JSON.stringify(n.template)).not.toContain('participantName');
|
||||
expect(n).not.toHaveProperty('participantName');
|
||||
expect(n).not.toHaveProperty('activity');
|
||||
expect(JSON.stringify(n)).not.toContain(BOB);
|
||||
expect(readsOf(w, contactsOf(BOB))).toEqual([]);
|
||||
};
|
||||
|
||||
test("a direct sign-up notifies the event's declarer, anonymously", async () => {
|
||||
const j = join(PLAIN);
|
||||
const w = plainWorld([j]);
|
||||
await emitJoin(w, j);
|
||||
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
|
||||
expect(w.state.notifications).toHaveLength(1);
|
||||
const [n] = w.state.notifications;
|
||||
expectAnonymous(w, n);
|
||||
expect(n.template.title.fr).toContain('Un participant a rejoint');
|
||||
expect(n.template.title.en).toContain('A participant joined');
|
||||
expect(n.eventUri).toBe(PLAIN);
|
||||
expect(Object.values(n)).toContain('Evénement plain');
|
||||
expect(JSON.stringify(n.template)).not.toContain('Evénement plain');
|
||||
expect(n.template.title.fr).toContain('{{{');
|
||||
expect(n.template.actions).toHaveLength(1);
|
||||
expect(n.template.actions[0].link).toContain('/events/{{encodeUri eventUri}}');
|
||||
expect(n.template.actions[0].link).not.toContain('meeting-points');
|
||||
});
|
||||
|
||||
test('a Leave naming the event notifies the declarer, anonymously', async () => {
|
||||
const j = join(PLAIN);
|
||||
const l = leave(PLAIN);
|
||||
const w = plainWorld([j, l]);
|
||||
await emitJoin(w, j);
|
||||
await emitLeave(w, l);
|
||||
expect(w.state.notifications).toHaveLength(2);
|
||||
const n = w.state.notifications[1];
|
||||
expectAnonymous(w, n);
|
||||
expect(n.template.title.fr).toContain('Un participant a quitté');
|
||||
expect(n.template.title.en).toContain('A participant left');
|
||||
expect(n.eventUri).toBe(PLAIN);
|
||||
});
|
||||
|
||||
test('a repeated direct Join notifies once; a Leave of someone never signed up notifies no one', async () => {
|
||||
const first = join(PLAIN, T0, BOB, 1);
|
||||
const again = join(PLAIN, T1, BOB, 2);
|
||||
const stray = leave(PLAIN, T1, CAROL, 1);
|
||||
const w = plainWorld([first, again, stray]);
|
||||
await emitJoin(w, first);
|
||||
await emitJoin(w, again);
|
||||
expect(w.state.notifications).toHaveLength(1);
|
||||
await emitLeave(w, stray);
|
||||
expect(w.state.notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a failed declarer notification does not fail the direct sign-up', async () => {
|
||||
const j = join(PLAIN);
|
||||
const w = plainWorld([j], { fail: { notify: true } });
|
||||
await emitJoin(w, j);
|
||||
expect(ledgerOf(w, PLAIN).change).toBe('join');
|
||||
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
|
||||
expect(participations(w)).toEqual([PLAIN]);
|
||||
expect(w.logs.error.some(m => m.includes('Could not notify'))).toBe(true);
|
||||
});
|
||||
|
||||
test('leaving the event and its meeting point notifies the declarer and the host, each once', async () => {
|
||||
const DAVE_EVENT = `${DAVE}/data/events/festival`;
|
||||
const acts = [join(DAVE_EVENT), join(MP1), leave(DAVE_EVENT, T1), leave(MP1, T1)];
|
||||
const w = setup({
|
||||
acts,
|
||||
stored: {
|
||||
[DAVE_EVENT]: plainEvent(DAVE_EVENT, { 'dc:creator': DAVE }),
|
||||
[MP1]: meetingPoint(MP1, { context: DAVE_EVENT })
|
||||
}
|
||||
});
|
||||
await emitJoin(w, acts[0]);
|
||||
await emitJoin(w, acts[1]);
|
||||
w.state.notifications.length = 0;
|
||||
await emitLeave(w, acts[2]);
|
||||
await emitLeave(w, acts[3]);
|
||||
expect(w.state.notifications).toHaveLength(2);
|
||||
expect(w.state.notifications.map(n => n.recipientUri).sort()).toEqual([ALICE, DAVE].sort());
|
||||
for (const n of w.state.notifications) {
|
||||
expect(n.template.title.fr).toContain('Un participant a quitté');
|
||||
expect(n).not.toHaveProperty('participantName');
|
||||
expect(JSON.stringify(n)).not.toContain(BOB);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
const b = join(OTHER, T0, BOB, 2);
|
||||
const w = setup({ acts: [a, b], stored: { [PLAIN]: plainEvent(PLAIN), [OTHER]: plainEvent(OTHER) } });
|
||||
await emitJoin(w, a);
|
||||
await emitJoin(w, b);
|
||||
expect(attendees(w, PLAIN)).toEqual([plainUrn(BOB, PLAIN)]);
|
||||
expect(attendees(w, OTHER)).toEqual([plainUrn(BOB, OTHER)]);
|
||||
expect(attendees(w, PLAIN)[0]).not.toBe(attendees(w, OTHER)[0]);
|
||||
});
|
||||
|
||||
test("the participant carries the SAME URN in the event's own list and in its meeting points' lists (R5, R6)", async () => {
|
||||
const festival = plainEvent(EVENT1, { 'dc:creator': DAVE });
|
||||
const acts = [join(EVENT1), join(MP1), join(MP2)];
|
||||
const w = setup({ acts, stored: { [EVENT1]: festival } });
|
||||
for (const a of acts) await emitJoin(w, a);
|
||||
expect(attendees(w, EVENT1)).toEqual([urnOf(BOB, MP1)]);
|
||||
expect(attendees(w, MP1)).toEqual(attendees(w, EVENT1));
|
||||
expect(attendees(w, MP2)).toEqual(attendees(w, EVENT1));
|
||||
expect(participations(w).sort()).toEqual([EVENT1, MP1, MP2].sort());
|
||||
});
|
||||
|
||||
test('signing up twice leaves one entry in the list and one in the collection (R1)', async () => {
|
||||
const first = join(PLAIN, T0, BOB, 1);
|
||||
const again = join(PLAIN, T1, BOB, 2);
|
||||
const w = plainWorld([first, again]);
|
||||
await emitJoin(w, first);
|
||||
await emitJoin(w, again);
|
||||
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
|
||||
expect(participations(w)).toEqual([PLAIN]);
|
||||
expect(w.logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test("a Leave naming the event removes the URN from its list and the event from the participant's collection (R8)", async () => {
|
||||
const j = join(PLAIN);
|
||||
const l = leave(PLAIN);
|
||||
const w = plainWorld([j, l]);
|
||||
await emitJoin(w, j);
|
||||
await emitLeave(w, l);
|
||||
expect(attendees(w, PLAIN)).toEqual([]);
|
||||
expect(participations(w)).toEqual([]);
|
||||
expect(ledgerOf(w, PLAIN).change).toBe('leave');
|
||||
});
|
||||
|
||||
test("leaving the event leaves the participant's meeting point memberships alone, and the other way round", async () => {
|
||||
const acts = [join(PLAIN), join(MP1), leave(PLAIN, T1)];
|
||||
const w = setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) } });
|
||||
await emitJoin(w, acts[0]);
|
||||
await emitJoin(w, acts[1]);
|
||||
expect(participations(w).sort()).toEqual([PLAIN, MP1].sort());
|
||||
await emitLeave(w, acts[2]);
|
||||
expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]);
|
||||
expect(participations(w)).toEqual([MP1]);
|
||||
});
|
||||
|
||||
test('a stored object that is not an Event stays refused', async () => {
|
||||
const j = join(PLAIN);
|
||||
const w = plainWorld([j], { stored: { [PLAIN]: { ...plainEvent(PLAIN), type: 'Note' } } });
|
||||
await emitJoin(w, j);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(w.logs.warn.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("an event not in the pod of its dc:creator, or naming no creator, stays refused", async () => {
|
||||
const j = join(PLAIN);
|
||||
for (const extra of [{ 'dc:creator': MALLORY }, { 'dc:creator': undefined }]) {
|
||||
const w = plainWorld([j], { stored: { [PLAIN]: plainEvent(PLAIN, extra) } });
|
||||
await emitJoin(w, j);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(attendees(w, PLAIN)).toEqual([]);
|
||||
expect(w.logs.warn.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,12 +118,14 @@ import { tmpdir } from 'node:os';
|
||||
import { join as joinPath } from 'node:path';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import {
|
||||
appActorUri,
|
||||
attendeesCollectionUri,
|
||||
linkId,
|
||||
MEMBER_URN_PREFIX,
|
||||
memberUrnOf,
|
||||
participationSecretOf,
|
||||
participationsCollectionOf,
|
||||
postOutboxActivity,
|
||||
recordUri,
|
||||
asRecord,
|
||||
type ApodsSession,
|
||||
@@ -1291,6 +1293,90 @@ async function leaveAfterContact(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A direct sign-up to the event itself (FP-001): Alice posts a Join naming E, the way
|
||||
* the app does; her participations collection then lists E, under the same audience as
|
||||
* a meeting point membership — her /contacts group only.
|
||||
*/
|
||||
async function directSignup(): Promise<void> {
|
||||
const G = 'direct sign-up to E (Alice; Bob her contact, Carol not)';
|
||||
const E = (): string => need(S.E, 'E');
|
||||
const listsE = async (): Promise<{ ok: boolean; items: string[] }> => {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||||
const r = await refIn(uri, E(), true, alice.token);
|
||||
return { ok: r.ok, items: r.items };
|
||||
};
|
||||
await m.hard('direct.join', G, 'Alice posts a Join naming E, to the app actor alone; her own participations collection lists E', 'items ∋ E within 30 s (her read)', async () => {
|
||||
await postOutboxActivity(need(S.alice, 'Alice'), 'Join', E(), appActorUri(config));
|
||||
const r = await listsE();
|
||||
return { ok: r.ok, actual: `items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
await m.hard('direct.non-contact-refused', G, "a direct sign-up is not listable by a non-contact: Carol is refused Alice's participations collection although it now lists E (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
const listed = await listsE();
|
||||
need(listed.ok, 'the direct sign-up landed (direct.join)');
|
||||
const refusal = await collectionRefusal(need(await ownParticipations(alice), 'her participations collection'), need(S.carol, 'Carol').token, alice, AUTHN);
|
||||
return { ok: refusal.ok, actual: refusal.actual };
|
||||
});
|
||||
await m.hard('direct.contact-lists', G, "a contact lists the direct sign-ups: Bob, her contact, reads Alice's participations collection (linked from her profile) and finds E", '200, items ∋ E', async () => {
|
||||
need(S.contacts, 'the contact pair (contact.accepted)');
|
||||
const profile = need(await profileUriOf(need(S.alice, 'Alice').webId), "the profile Alice's public actor document links");
|
||||
const link = need(participationsCollectionOf(parseJson((await http('GET', profile, { token: need(S.bob, 'Bob').token })).text)), "the participations link on Alice's profile, read by Bob");
|
||||
const r = await poll(() => http('GET', link, { token: need(S.bob, 'Bob').token }), v => v.status === 200 && itemUris(parseJson(v.text)).includes(E()), 30000);
|
||||
const items = itemUris(parseJson(r.text));
|
||||
return { ok: r.status === 200 && items.includes(E()), actual: `HTTP ${r.status} items=${JSON.stringify(items)}` };
|
||||
});
|
||||
await m.hard('direct.leave', G, 'Alice posts a Leave naming E: her participations collection no longer lists it (the group leaves the event as it found it)', 'items ∌ E within 30 s (her read)', async () => {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
await postOutboxActivity(alice, 'Leave', E(), appActorUri(config));
|
||||
const uri = need(await ownParticipations(alice), 'her participations collection');
|
||||
const r = await refIn(uri, E(), false, alice.token);
|
||||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A direct sign-up to an event whose declarer is NOT the participant's contact
|
||||
* (FP-001, R6/R7): Alice signs up to Carol's event CE, Carol being no contact of
|
||||
* hers. The declarer is told, anonymously, and gains no right to read the act, the
|
||||
* list or the participations. Runs once CE exists (forged.setup-carol-meeting-point).
|
||||
*/
|
||||
async function directSignupToDeclared(): Promise<void> {
|
||||
const G = "direct sign-up to CE (Alice; Carol declared CE and is not Alice's contact)";
|
||||
const alice = (): ApodsSession => need(S.alice, 'Alice');
|
||||
const carol = (): ApodsSession => need(S.carol, 'Carol');
|
||||
const CE = (): string => need(S.CE, "Carol's event CE");
|
||||
const noticed = (change: 'join' | 'leave'): Promise<CheckOutcome> =>
|
||||
notifiedLine(CE(), change, false, { creator: carol(), participant: alice(), name: USERS.alice });
|
||||
await m.hard('direct.notify-anonymous', G, 'Alice posts a Join naming CE: Carol, its declarer, is notified in her own inbox, naming neither her name nor her WebID', 'a join notification about CE naming neither the display name nor the WebID, within 30 s', async () => {
|
||||
await postOutboxActivity(alice(), 'Join', CE(), appActorUri(config));
|
||||
return noticed('join');
|
||||
});
|
||||
await m.hard('direct.activity-declarer-refused', G, "Carol — CE's declarer, not her contact — GETs Alice's Join naming CE (its URI handed out of band)", 'refused 403', async () => {
|
||||
const act = need(await poll(() => ownActivity(alice(), 'Join', CE()), v => !!v, 15000), "Alice's Join naming CE");
|
||||
const r = await http('GET', need(recordUri(act), "the Join's URI"), { token: carol().token });
|
||||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) };
|
||||
});
|
||||
await m.hard('direct.list-opaque', G, "CE's own attendee list (anonymous read) holds Alice's member URN, computed from her secret with CE as anchor, and no WebID", 'her URN ∈ items, her WebID ∉ items', async () => {
|
||||
const list = need(await attachedCollection(CE(), carol()), "CE's attendees collection");
|
||||
const urn = await ownMemberUrn(alice(), CE());
|
||||
const r = await refIn(list, urn, true);
|
||||
return { ok: r.ok && !r.items.includes(alice().webId), actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
await m.hard('direct.declarer-refused-participations', G, "Carol, the declarer and no contact of Alice, is refused Alice's participations collection although it lists CE (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
|
||||
const uri = need(await ownParticipations(alice()), 'her participations collection');
|
||||
const listed = await refIn(uri, CE(), true, alice().token);
|
||||
need(listed.ok, 'the direct sign-up landed (direct.notify-anonymous)');
|
||||
const refusal = await collectionRefusal(uri, carol().token, alice(), AUTHN);
|
||||
return { ok: refusal.ok, actual: refusal.actual };
|
||||
});
|
||||
await m.hard('direct.leave-notify-anonymous', G, 'Alice posts a Leave naming CE (the group leaves the event as it found it): Carol is notified of the withdrawal, anonymously', 'a leave notification about CE naming neither the display name nor the WebID, within 30 s', async () => {
|
||||
await postOutboxActivity(alice(), 'Leave', CE(), appActorUri(config));
|
||||
return noticed('leave');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Who reads a person's participations is her /contacts WAC group, through ONE
|
||||
@@ -1845,8 +1931,10 @@ async function cleanupAccounts(): Promise<void> {
|
||||
await contactChannel();
|
||||
await afterContact();
|
||||
await leaveAfterContact();
|
||||
await directSignup();
|
||||
await visibility();
|
||||
await forgedContact();
|
||||
await directSignupToDeclared();
|
||||
await contactChanges();
|
||||
} finally {
|
||||
await uiHygiene();
|
||||
|
||||
@@ -59,3 +59,96 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
|
||||
Quand la session rejoint le point de rencontre
|
||||
Alors le compteur du point de rencontre compte cette inscription
|
||||
Et la liste des participants rend ce participant comme anonyme
|
||||
|
||||
# --- Inscription directe à un événement (FP-001) ---
|
||||
#
|
||||
# Un `as:Join` dont l'objet est l'événement lui-même suit le même canal qu'un Join de
|
||||
# point de rencontre : l'URN de membre (ancrée sur l'événement) entre dans la
|
||||
# collection `apods:attendees` de l'événement, et l'URI de l'événement dans la
|
||||
# collection des participations de la session. L'inscription à l'événement est
|
||||
# dérivée : directe, ou membre d'un de ses points de rencontre. Chaque scénario tourne sur un compte
|
||||
# jetable, créé puis supprimé (Pod compris) même en cas d'échec : rien ne reste dans
|
||||
# le pod d'un compte persistant.
|
||||
|
||||
@inscription-evenement
|
||||
Scénario: Rejoindre puis quitter un événement met à jour sa collection apods:attendees
|
||||
Étant donné une session Pod jetable avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un événement "Concert direct" avec sa collection apods:attendees
|
||||
Quand la session s'inscrit à l'événement
|
||||
Alors la collection apods:attendees de l'événement contient la session
|
||||
Et la collection des participations de la session contient l'événement
|
||||
Quand la session se désinscrit de l'événement
|
||||
Alors la collection apods:attendees de l'événement ne contient plus la session
|
||||
Et la collection des participations de la session ne contient plus l'événement
|
||||
Et une relecture ultérieure ne réinscrit pas la session
|
||||
|
||||
@inscription-evenement
|
||||
Scénario: L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre
|
||||
Étant donné une session Pod jetable avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
|
||||
Et l'événement a aussi sa collection apods:attendees
|
||||
Quand la session s'inscrit à l'événement
|
||||
Et la session rejoint le point de rencontre
|
||||
Alors la collection apods:attendees de l'événement contient la session
|
||||
Et la collection apods:attendees contient la session
|
||||
Et l'URN de la session est la même dans la liste de l'événement et dans celle du point de rencontre
|
||||
|
||||
@inscription-evenement
|
||||
Scénario: Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement
|
||||
Étant donné une session Pod jetable avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
|
||||
Quand la session rejoint le point de rencontre
|
||||
Alors la session est inscrite à l'événement
|
||||
Quand la session quitte le point de rencontre
|
||||
Alors la session n'est plus inscrite à l'événement
|
||||
Et la collection des participations de la session ne contient pas l'événement
|
||||
|
||||
@inscription-evenement
|
||||
Scénario: Quitter le dernier point de rencontre garde l'inscription directe
|
||||
Étant donné une session Pod jetable avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
|
||||
Et l'événement a aussi sa collection apods:attendees
|
||||
Quand la session s'inscrit à l'événement
|
||||
Et la session rejoint le point de rencontre
|
||||
Et la session quitte le point de rencontre
|
||||
Alors la collection des participations de la session ne contient plus le point de rencontre
|
||||
Et la session est inscrite à l'événement
|
||||
Et la collection apods:attendees de l'événement contient la session
|
||||
|
||||
@inscription-evenement
|
||||
Scénario: Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre
|
||||
Étant donné une session Pod jetable avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
|
||||
Et l'événement a aussi sa collection apods:attendees
|
||||
Quand la session s'inscrit à l'événement
|
||||
Et la session rejoint le point de rencontre
|
||||
Et la session quitte l'événement et chacun de ses points de rencontre
|
||||
Alors la session n'est plus inscrite à l'événement
|
||||
Et la collection apods:attendees de l'événement ne contient plus la session
|
||||
Et la collection apods:attendees ne contient plus la session
|
||||
Et une relecture ultérieure ne réinscrit pas la session
|
||||
|
||||
@browser @inscription-evenement
|
||||
Scénario: Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation
|
||||
Étant donné une session Pod jetable avec le mot de passe "Test1test"
|
||||
Et l'application Festipod est installée sur le Pod de la session
|
||||
Et un point de rencontre "Rdv parcours" rattaché à un événement, avec sa collection apods:attendees
|
||||
Et l'événement a aussi sa collection apods:attendees
|
||||
Et la session est connectée dans l'application avec le mot de passe "Test1test"
|
||||
Quand dans l'application, j'ouvre l'événement
|
||||
Et dans l'application, j'appuie sur "S'inscrire à l'événement"
|
||||
Alors l'application affiche "✓ Inscrit à l'événement"
|
||||
Quand dans l'application, j'appuie sur "S'inscrire" du point de rencontre "Rdv parcours"
|
||||
Et dans l'application, j'appuie sur "Se désinscrire de l'événement"
|
||||
Alors l'application affiche "Se désinscrire de l'événement ?"
|
||||
Et l'application affiche "Rdv parcours"
|
||||
Quand dans l'application, j'appuie sur "Confirmer la désinscription"
|
||||
Alors l'application affiche "S'inscrire à l'événement"
|
||||
Quand je recharge l'application
|
||||
Alors l'application affiche "S'inscrire à l'événement"
|
||||
Et la session n'est plus inscrite à l'événement
|
||||
|
||||
@@ -15,9 +15,72 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
Étant donné que je suis sur la page "détail événement"
|
||||
Alors l'écran affiche les informations de l'événement
|
||||
|
||||
Scénario: Voir le bouton d'inscription sur l'écran
|
||||
Étant donné que je suis sur la page "détail événement"
|
||||
Alors je peux m'inscrire à l'événement
|
||||
Scénario: Voir le bouton d'inscription à l'événement
|
||||
Étant donné que je suis sur le détail de l'événement "Formation CNV"
|
||||
Alors je vois le bouton "S'inscrire à l'événement"
|
||||
Et je ne vois pas le bouton "Se désinscrire de l'événement"
|
||||
|
||||
Scénario: Un inscrit direct voit son inscription à l'événement
|
||||
Étant donné que je suis sur le détail de l'événement "Atelier low-tech"
|
||||
Alors je vois le bouton "Se désinscrire de l'événement"
|
||||
Et l'écran affiche "✓ Inscrit à l'événement"
|
||||
Et je ne vois pas le bouton "S'inscrire à l'événement"
|
||||
|
||||
Scénario: Être inscrit à un point de rencontre affiche l'inscription à l'événement
|
||||
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
|
||||
Alors l'écran affiche "✓ Inscrit à l'événement"
|
||||
Et je vois le bouton "Se désinscrire de l'événement"
|
||||
Et je ne vois pas le bouton "S'inscrire à l'événement"
|
||||
|
||||
Scénario: Se désinscrire d'un événement avec points de rencontre demande confirmation
|
||||
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
|
||||
Quand j'appuie sur "Se désinscrire de l'événement"
|
||||
Alors une fenêtre de confirmation "Se désinscrire de l'événement ?" est ouverte
|
||||
Et la fenêtre de confirmation propose "Confirmer la désinscription" et "Annuler"
|
||||
|
||||
Scénario: La confirmation de désinscription précise les points de rencontre quittés
|
||||
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
|
||||
Quand j'appuie sur "Se désinscrire de l'événement"
|
||||
Alors la fenêtre de confirmation liste les points de rencontre "Covoiturage depuis la gare" et "Départ à pied de la place"
|
||||
Et la fenêtre de confirmation avertit qu'ils seront aussi quittés
|
||||
|
||||
Scénario: Annuler la confirmation ne change rien
|
||||
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
|
||||
Quand j'appuie sur "Se désinscrire de l'événement"
|
||||
Et j'appuie sur "Annuler"
|
||||
Alors aucune fenêtre de confirmation n'est ouverte
|
||||
Et l'écran affiche "✓ Inscrit à l'événement"
|
||||
Et je suis toujours inscrit aux points de rencontre "Covoiturage depuis la gare" et "Départ à pied de la place"
|
||||
|
||||
Scénario: Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation
|
||||
Étant donné que je suis sur le détail de l'événement "Atelier low-tech"
|
||||
Quand j'appuie sur "Se désinscrire de l'événement"
|
||||
Alors aucune fenêtre de confirmation n'est ouverte
|
||||
|
||||
Scénario: Quitter un point de rencontre ne demande pas de confirmation
|
||||
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
|
||||
Quand j'appuie sur le bouton "✓ Inscrit" d'un point de rencontre
|
||||
Alors aucune fenêtre de confirmation n'est ouverte
|
||||
Et je vois le bouton "Se désinscrire de l'événement"
|
||||
|
||||
Scénario: Un contact inscrit directement apparaît parmi les participants de l'événement
|
||||
Étant donné que je suis sur le détail de l'événement "Résidence Reconnexion"
|
||||
Alors la liste des participants nomme "Jean Durand"
|
||||
|
||||
Scénario: Un participant inscrit directement qui n'est pas un contact n'est que compté
|
||||
Étant donné que je suis sur le détail de l'événement "Formation CNV"
|
||||
Alors la liste des participants compte des participants inconnus
|
||||
Et la liste des participants ne nomme pas "user-hors-reseau"
|
||||
|
||||
Plan du Scénario: Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre
|
||||
Étant donné que je suis sur le détail de l'événement "<événement>"
|
||||
Alors le bouton "Inviter" est <visibilité>
|
||||
|
||||
Exemples:
|
||||
| événement | visibilité |
|
||||
| Rencontre des Colibris | affiché |
|
||||
| Atelier low-tech | affiché |
|
||||
| Formation CNV | absent |
|
||||
|
||||
Scénario: Rechercher un événement existant
|
||||
Étant donné que je suis sur la page "découvrir"
|
||||
|
||||
@@ -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,17 @@ 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 [joining, setJoining] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => {
|
||||
const failed = (message: string) => (err: unknown) => {
|
||||
@@ -66,6 +71,40 @@ export function EventDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinEvent = () => {
|
||||
if (!eventId || joining) return;
|
||||
setJoining(true);
|
||||
Promise.resolve(joinEvent(eventId))
|
||||
.catch((err: unknown) => {
|
||||
console.error('[EventDetail] event sign-up failed:', err);
|
||||
showToast("L'inscription à l'événement n'a pas pu être enregistrée", 'error');
|
||||
})
|
||||
.finally(() => setJoining(false));
|
||||
};
|
||||
|
||||
const withdrawFromEvent = () => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
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 +204,26 @@ export function EventDetailScreen() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={() => navigate(eventPath(event.id, 'invite'))}
|
||||
style={{ flex: 1, padding: '12px 0' }}
|
||||
>
|
||||
Inviter
|
||||
<div style={{ margin: '4px 16px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{registered ? (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--app-accent-dark)' }}>✓ Inscrit à l'événement</div>
|
||||
<Button onClick={handleLeaveEvent} disabled={leaving} style={{ padding: '12px 0' }}>
|
||||
Se désinscrire de l'événement
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(eventPath(event.id, 'invite'))}
|
||||
style={{ padding: '12px 0' }}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" onClick={handleJoinEvent} disabled={joining} style={{ padding: '12px 0' }}>
|
||||
S'inscrire à l'événement
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meetingPoints.length > 0 && (
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
@@ -244,6 +290,18 @@ export function EventDetailScreen() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{confirmingLeave && (
|
||||
<ConfirmDialog
|
||||
title="Se désinscrire de l'événement ?"
|
||||
confirmLabel="Confirmer la désinscription"
|
||||
cancelLabel="Annuler"
|
||||
onConfirm={withdrawFromEvent}
|
||||
onCancel={() => setConfirmingLeave(false)}
|
||||
busy={leaving}
|
||||
>
|
||||
Vous êtes inscrit aux points de rencontre suivants : {leftMeetingPointTitles.join(', ')}. En confirmant, vous en serez aussi désinscrit.
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { After, Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import type { ApodsSession } from '../../../../shared/support/activitypodsStack';
|
||||
import {
|
||||
apodsStackConfig,
|
||||
appActorUri,
|
||||
attendeesCollectionUri,
|
||||
ensureParticipationSecret,
|
||||
linkId,
|
||||
memberUrnOf,
|
||||
participationSecretOf,
|
||||
participationsCollectionOf,
|
||||
podLogin,
|
||||
postOutboxActivity,
|
||||
readAttendees,
|
||||
readResource,
|
||||
} from '../../../../shared/support/activitypodsStack';
|
||||
import { driveLogin, type DriveContext } from '../../../../../scripts/e2e-rights-matrix-support';
|
||||
|
||||
const JSON_CONTEXT = 'https://activitypods.org/context.json';
|
||||
const POLL_MS = 500;
|
||||
|
||||
function session(world: FestipodWorld): ApodsSession {
|
||||
if (!world.apods.session) throw new Error('No Pod session — a "session Pod locale" step must run first');
|
||||
return world.apods.session;
|
||||
}
|
||||
|
||||
function required(value: string | undefined, what: string): string {
|
||||
if (!value) throw new Error(`${what} is unset — a prior step must have created it`);
|
||||
return value;
|
||||
}
|
||||
|
||||
const pause = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function until<T>(read: () => Promise<T>, done: (value: T) => boolean, timeoutMs = 20000): Promise<T> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
let value = await read();
|
||||
while (!done(value) && Date.now() < end) {
|
||||
await pause(POLL_MS);
|
||||
value = await read();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function attachedCollection(pod: ApodsSession, resourceUri: string): Promise<string> {
|
||||
const found = await until(
|
||||
async () => attendeesCollectionUri(await readResource(pod, resourceUri)),
|
||||
uri => uri !== undefined,
|
||||
);
|
||||
if (!found) throw new Error(`${resourceUri} never gained an apods:attendees collection`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async function memberUrnAtEvent(world: FestipodWorld): Promise<string> {
|
||||
const pod = session(world);
|
||||
const profileUri = required(linkId((await readResource(pod, pod.webId))['url']), "the session's profile");
|
||||
const secret = required(participationSecretOf(await readResource(pod, profileUri)), "the session's participation secret");
|
||||
return memberUrnOf(pod.webId, required(world.apods.anchorUri, 'event URI'), secret);
|
||||
}
|
||||
|
||||
async function membersOf(pod: ApodsSession, collectionUri: string): Promise<string[]> {
|
||||
return (await readAttendees(pod, collectionUri)).map(linkId).filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
|
||||
async function participationsOf(pod: ApodsSession): Promise<string[]> {
|
||||
const profileUri = linkId((await readResource(pod, pod.webId))['url']);
|
||||
const collection = profileUri ? participationsCollectionOf(await readResource(pod, profileUri)) : undefined;
|
||||
return collection ? membersOf(pod, collection) : [];
|
||||
}
|
||||
|
||||
function targets(world: FestipodWorld): string[] {
|
||||
return [world.apods.anchorUri, world.apods.meetingPointUri].filter((uri): uri is string => uri !== undefined);
|
||||
}
|
||||
|
||||
async function expectMember(world: FestipodWorld, collectionUri: string, urn: string, present: boolean): Promise<void> {
|
||||
const pod = session(world);
|
||||
const members = await until(() => membersOf(pod, collectionUri), found => found.includes(urn) === present);
|
||||
expect(members.includes(urn), present ? 'the member URN is listed' : 'the member URN is gone').to.equal(present);
|
||||
expect(members, 'a list never holds a WebID').to.not.include(pod.webId);
|
||||
}
|
||||
|
||||
async function expectParticipation(world: FestipodWorld, uri: string, present: boolean): Promise<void> {
|
||||
const pod = session(world);
|
||||
const items = await until(() => participationsOf(pod), found => found.includes(uri) === present);
|
||||
expect(items.includes(uri), present ? `${uri} is in my participations` : `${uri} is not in my participations`).to.equal(present);
|
||||
}
|
||||
|
||||
async function registered(world: FestipodWorld): Promise<boolean> {
|
||||
const items = await participationsOf(session(world));
|
||||
return targets(world).some(uri => items.includes(uri));
|
||||
}
|
||||
|
||||
/**
|
||||
* The scenarios run on a throwaway account the run creates and deletes: nothing they
|
||||
* write (events, meeting points, activities, secrets) may stay in a persistent
|
||||
* account's pod (rule_tests-never-break-the-dev-environment). Deleting the account
|
||||
* is the cleanup — the platform drops its dataset — and it runs whatever failed.
|
||||
*/
|
||||
const POD_PASSWORD = 'Test1test';
|
||||
|
||||
async function podApi(path: string, init: RequestInit): Promise<Response> {
|
||||
return fetch(`${apodsStackConfig().podProviderUrl}${path}`, { ...init, signal: AbortSignal.timeout(30000) });
|
||||
}
|
||||
|
||||
async function deleteAccount(username: string, token: string | undefined): Promise<void> {
|
||||
let bearer = token;
|
||||
if (!bearer) {
|
||||
// The sign-up may have succeeded before the login failed: log in again to get a token.
|
||||
const login = await podApi('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password: POD_PASSWORD }),
|
||||
}).catch(() => undefined);
|
||||
if (!login?.ok) return;
|
||||
bearer = ((await login.json().catch(() => ({}))) as { token?: string }).token;
|
||||
}
|
||||
if (!bearer) return;
|
||||
await podApi(`/.account/${username}`, { method: 'DELETE', headers: { Authorization: `Bearer ${bearer}` } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
Given('une session Pod jetable avec le mot de passe {string}', async function (this: FestipodWorld, password: string) {
|
||||
const username = `fpjet${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
expect(password, 'the cleanup logs in with the shared test password').to.equal(POD_PASSWORD);
|
||||
this.apods.username = username; // recorded before the sign-up so the After hook deletes it even if the login fails
|
||||
this.apods.session = await podLogin(apodsStackConfig(), username, password);
|
||||
});
|
||||
|
||||
After({ tags: '@inscription-evenement', timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
const username = this.apods.username;
|
||||
if (!username) return;
|
||||
await deleteAccount(username, this.apods.session?.token);
|
||||
});
|
||||
|
||||
Given('un événement {string} avec sa collection apods:attendees', async function (this: FestipodWorld, title: string) {
|
||||
const pod = session(this);
|
||||
const container = `${pod.storage}/as/event`;
|
||||
const response = await fetch(container, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json', Accept: 'application/ld+json', Authorization: `Bearer ${pod.token}` },
|
||||
body: JSON.stringify({ '@context': JSON_CONTEXT, type: 'Event', name: title, startTime: '2026-11-01T18:00:00Z' }),
|
||||
});
|
||||
const location = response.headers.get('Location') ?? response.headers.get('location');
|
||||
if (!response.ok || !location) throw new Error(`Create in ${container} failed: HTTP ${response.status}`);
|
||||
this.apods.anchorUri = location;
|
||||
this.apods.eventCollectionUri = await attachedCollection(pod, location);
|
||||
});
|
||||
|
||||
Given("l'événement a aussi sa collection apods:attendees", async function (this: FestipodWorld) {
|
||||
this.apods.eventCollectionUri = await attachedCollection(session(this), required(this.apods.anchorUri, 'event URI'));
|
||||
});
|
||||
|
||||
When("la session s'inscrit à l'événement", async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
await ensureParticipationSecret(pod);
|
||||
await postOutboxActivity(pod, 'Join', required(this.apods.anchorUri, 'event URI'), appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
When("la session se désinscrit de l'événement", async function (this: FestipodWorld) {
|
||||
await postOutboxActivity(session(this), 'Leave', required(this.apods.anchorUri, 'event URI'), appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
When("la session quitte l'événement et chacun de ses points de rencontre", async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const to = appActorUri(apodsStackConfig());
|
||||
await Promise.all(targets(this).map(uri => postOutboxActivity(pod, 'Leave', uri, to)));
|
||||
});
|
||||
|
||||
Then("la collection apods:attendees de l'événement contient la session", async function (this: FestipodWorld) {
|
||||
await expectMember(this, required(this.apods.eventCollectionUri, "the event's attendees collection"), await memberUrnAtEvent(this), true);
|
||||
});
|
||||
|
||||
Then("la collection apods:attendees de l'événement ne contient plus la session", async function (this: FestipodWorld) {
|
||||
await expectMember(this, required(this.apods.eventCollectionUri, "the event's attendees collection"), await memberUrnAtEvent(this), false);
|
||||
});
|
||||
|
||||
Then("la collection des participations de la session contient l'événement", async function (this: FestipodWorld) {
|
||||
await expectParticipation(this, required(this.apods.anchorUri, 'event URI'), true);
|
||||
});
|
||||
|
||||
Then(/^la collection des participations de la session ne contient (?:plus|pas) l'événement$/, async function (this: FestipodWorld) {
|
||||
await expectParticipation(this, required(this.apods.anchorUri, 'event URI'), false);
|
||||
});
|
||||
|
||||
Then('la collection des participations de la session ne contient plus le point de rencontre', async function (this: FestipodWorld) {
|
||||
await expectParticipation(this, required(this.apods.meetingPointUri, 'meeting point URI'), false);
|
||||
});
|
||||
|
||||
Then("la session est inscrite à l'événement", async function (this: FestipodWorld) {
|
||||
const found = await until(() => registered(this), value => value);
|
||||
expect(found, 'the session is registered to the event, directly or through a meeting point').to.be.true;
|
||||
});
|
||||
|
||||
Then("la session n'est plus inscrite à l'événement", async function (this: FestipodWorld) {
|
||||
const found = await until(() => registered(this), value => !value);
|
||||
expect(found, 'the session is registered to neither the event nor any of its meeting points').to.be.false;
|
||||
});
|
||||
|
||||
Then("l'URN de la session est la même dans la liste de l'événement et dans celle du point de rencontre", async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const urn = await memberUrnAtEvent(this);
|
||||
const inEvent = await membersOf(pod, required(this.apods.eventCollectionUri, "the event's attendees collection"));
|
||||
const inMeetingPoint = await membersOf(pod, required(this.apods.collectionUri, "the meeting point's attendees collection"));
|
||||
expect(inEvent).to.include(urn);
|
||||
expect(inMeetingPoint).to.include(urn);
|
||||
});
|
||||
|
||||
Then('une relecture ultérieure ne réinscrit pas la session', async function (this: FestipodWorld) {
|
||||
await pause(3000);
|
||||
expect(await registered(this), 'a later read does not register the session back').to.be.false;
|
||||
const urn = await memberUrnAtEvent(this);
|
||||
const pod = session(this);
|
||||
expect(await membersOf(pod, required(this.apods.eventCollectionUri, "the event's attendees collection"))).to.not.include(urn);
|
||||
});
|
||||
|
||||
// ── The journey, in the real app ───────────────────────────────────────────
|
||||
|
||||
const stackUrl = (value: string | undefined, fallback: string) => (value || fallback).replace(/\/+$/, '');
|
||||
|
||||
Given('la session est connectée dans l\'application avec le mot de passe {string}', async function (this: FestipodWorld, password: string) {
|
||||
const username = required(this.apods.username, 'the throwaway account name');
|
||||
const ctx: DriveContext = {
|
||||
app: required(this.apods.appUrl, 'app URL'),
|
||||
podFront: stackUrl(process.env.POD_FRONTEND_URL, 'http://localhost:5000'),
|
||||
hops: [],
|
||||
errors: [],
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
deadline: Date.now() + 5 * 60 * 1000,
|
||||
};
|
||||
const outcome = await driveLogin(this.page!, username, username, password, ctx);
|
||||
expect(outcome.ok, outcome.detail).to.be.true;
|
||||
});
|
||||
|
||||
When('dans l\'application, j\'ouvre l\'événement', async function (this: FestipodWorld) {
|
||||
const url = `${required(this.apods.appUrl, 'app URL')}/events/${encodeURIComponent(required(this.apods.anchorUri, 'event URI'))}`;
|
||||
await this.page!.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
When('dans l\'application, j\'appuie sur {string}', async function (this: FestipodWorld, label: string) {
|
||||
await this.page!.getByRole('button', { name: label, exact: true }).first().click({ timeout: 30000 });
|
||||
});
|
||||
|
||||
When('dans l\'application, j\'appuie sur {string} du point de rencontre {string}', async function (this: FestipodWorld, label: string, title: string) {
|
||||
const card = this.page!.locator('div', { hasText: title }).filter({ has: this.page!.getByRole('button', { name: label, exact: true }) }).last();
|
||||
await card.getByRole('button', { name: label, exact: true }).click({ timeout: 30000 });
|
||||
});
|
||||
|
||||
Then('l\'application affiche {string}', async function (this: FestipodWorld, text: string) {
|
||||
await this.page!.getByText(text, { exact: false }).first().waitFor({ state: 'visible', timeout: 30000 });
|
||||
});
|
||||
|
||||
When("je recharge l'application", async function (this: FestipodWorld) {
|
||||
await this.page!.reload({ waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { renderScreen } from '../../../../shared/test-harness/renderHelper';
|
||||
import { seedEvents } from '../../../../shared/data/seedData';
|
||||
|
||||
const DIALOG_TITLE = "Se désinscrire de l'événement ?";
|
||||
|
||||
const settle = () => new Promise<void>(resolve => setTimeout(resolve, 50));
|
||||
|
||||
function buttons(world: FestipodWorld): HTMLButtonElement[] {
|
||||
expect(world.renderedDoc, 'Screen should be rendered').to.not.be.null;
|
||||
return Array.from(world.renderedDoc!.querySelectorAll('button')) as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function buttonLabelled(world: FestipodWorld, label: string): HTMLButtonElement | undefined {
|
||||
return buttons(world).find(b => (b.textContent ?? '').trim() === label);
|
||||
}
|
||||
|
||||
function dialogOpen(world: FestipodWorld): boolean {
|
||||
return world.getDomText().includes(DIALOG_TITLE);
|
||||
}
|
||||
|
||||
Given('je suis sur le détail de l\'événement {string}', async function (this: FestipodWorld, title: string) {
|
||||
const event = seedEvents.find(e => e.title === title);
|
||||
expect(event, `No seed event titled "${title}"`).to.not.be.undefined;
|
||||
this.currentScreenId = 'event-detail';
|
||||
this.renderedDoc = await renderScreen('event-detail', `/events/${event!.id}`);
|
||||
});
|
||||
|
||||
When('j\'appuie sur {string}', async function (this: FestipodWorld, label: string) {
|
||||
const button = buttonLabelled(this, label);
|
||||
expect(button, `Button "${label}" should be on screen`).to.not.be.undefined;
|
||||
button!.click();
|
||||
await settle();
|
||||
});
|
||||
|
||||
When('j\'appuie sur le bouton {string} d\'un point de rencontre', async function (this: FestipodWorld, label: string) {
|
||||
const button = buttonLabelled(this, label);
|
||||
expect(button, `Meeting point button "${label}" should be on screen`).to.not.be.undefined;
|
||||
button!.click();
|
||||
await settle();
|
||||
});
|
||||
|
||||
Then('je vois le bouton {string}', async function (this: FestipodWorld, label: string) {
|
||||
expect(buttonLabelled(this, label), `Button "${label}" should be on screen`).to.not.be.undefined;
|
||||
});
|
||||
|
||||
Then('je ne vois pas le bouton {string}', async function (this: FestipodWorld, label: string) {
|
||||
expect(buttonLabelled(this, label), `Button "${label}" should not be on screen`).to.be.undefined;
|
||||
});
|
||||
|
||||
Then('le bouton {string} est affiché', async function (this: FestipodWorld, label: string) {
|
||||
expect(buttonLabelled(this, label), `Button "${label}" should be on screen`).to.not.be.undefined;
|
||||
});
|
||||
|
||||
Then('le bouton {string} est absent', async function (this: FestipodWorld, label: string) {
|
||||
expect(buttonLabelled(this, label), `Button "${label}" should not be on screen`).to.be.undefined;
|
||||
});
|
||||
|
||||
Then('l\'écran affiche {string}', async function (this: FestipodWorld, text: string) {
|
||||
expect(this.getDomText(), `Screen should show "${text}"`).to.include(text);
|
||||
});
|
||||
|
||||
Then('une fenêtre de confirmation {string} est ouverte', async function (this: FestipodWorld, title: string) {
|
||||
expect(this.getDomText(), `Confirmation "${title}" should be open`).to.include(title);
|
||||
});
|
||||
|
||||
Then('la fenêtre de confirmation propose {string} et {string}', async function (this: FestipodWorld, confirm: string, cancel: string) {
|
||||
expect(buttonLabelled(this, confirm), `Button "${confirm}" should be in the dialog`).to.not.be.undefined;
|
||||
expect(buttonLabelled(this, cancel), `Button "${cancel}" should be in the dialog`).to.not.be.undefined;
|
||||
});
|
||||
|
||||
Then('la fenêtre de confirmation liste les points de rencontre {string} et {string}', async function (this: FestipodWorld, first: string, second: string) {
|
||||
expect(dialogOpen(this), 'The confirmation should be open').to.be.true;
|
||||
const text = this.getDomText();
|
||||
expect(text, 'The dialog should name the first meeting point').to.include(`suivants : ${first}`);
|
||||
expect(text, 'The dialog should name the second meeting point').to.include(second);
|
||||
});
|
||||
|
||||
Then('la fenêtre de confirmation avertit qu\'ils seront aussi quittés', async function (this: FestipodWorld) {
|
||||
expect(this.getDomText()).to.include('En confirmant, vous en serez aussi désinscrit.');
|
||||
});
|
||||
|
||||
Then('aucune fenêtre de confirmation n\'est ouverte', async function (this: FestipodWorld) {
|
||||
expect(dialogOpen(this), 'No confirmation should be open').to.be.false;
|
||||
});
|
||||
|
||||
Then('je suis toujours inscrit aux points de rencontre {string} et {string}', async function (this: FestipodWorld, first: string, second: string) {
|
||||
const text = this.getDomText();
|
||||
expect(text).to.include(first);
|
||||
expect(text).to.include(second);
|
||||
const joined = buttons(this).filter(b => (b.textContent ?? '').trim() === '✓ Inscrit');
|
||||
expect(joined.length, 'Both meeting points should still show as joined').to.equal(2);
|
||||
});
|
||||
|
||||
Then('la liste des participants nomme {string}', async function (this: FestipodWorld, name: string) {
|
||||
expect(this.getDomText(), `The participants should name "${name}"`).to.include(name);
|
||||
});
|
||||
|
||||
Then('la liste des participants ne nomme pas {string}', async function (this: FestipodWorld, name: string) {
|
||||
expect(this.getDomText(), `The participants should not name "${name}"`).to.not.include(name);
|
||||
});
|
||||
|
||||
Then('la liste des participants compte des participants inconnus', async function (this: FestipodWorld) {
|
||||
expect(this.getDomText(), 'Someone who is not a contact should be counted, not shown').to.include('Participant inconnu');
|
||||
});
|
||||
@@ -17,6 +17,10 @@ Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
|
||||
Étant donné que je suis sur la page "profil utilisateur"
|
||||
Alors je peux voir les événements auxquels l'utilisateur a participé
|
||||
|
||||
Scénario: Le profil d'un contact liste les événements auxquels il est inscrit directement
|
||||
Étant donné que je suis sur le profil de "Jean Durand"
|
||||
Alors le profil liste l'événement "Résidence Reconnexion"
|
||||
|
||||
Scénario: Voir la localisation des événements
|
||||
Étant donné que je suis sur la page "profil utilisateur"
|
||||
Alors les événements affichent leur localisation et distance
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { renderScreen } from '../../../../shared/test-harness/renderHelper';
|
||||
import { seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
When('je clique sur un participant', async function (this: FestipodWorld) {
|
||||
await this.navigateTo('#/demo/user-profile');
|
||||
@@ -96,3 +98,15 @@ function expectProfileDom(
|
||||
expect((avatar?.textContent ?? '').trim(), `Avatar should show initials "${expected.initials}"`)
|
||||
.to.equal(expected.initials);
|
||||
}
|
||||
|
||||
Given('je suis sur le profil de {string}', async function (this: FestipodWorld, name: string) {
|
||||
const user = seedUsers.find(u => u.name === name);
|
||||
expect(user, `No seed user named "${name}"`).to.not.be.undefined;
|
||||
this.currentScreenId = 'user-profile';
|
||||
this.renderedDoc = await renderScreen('user-profile', `/users/${user!.id}`);
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
Then('le profil liste l\'événement {string}', async function (this: FestipodWorld, title: string) {
|
||||
expect(this.getDomText(), `The profile should list the event "${title}"`).to.include(title);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
zIndex: 900,
|
||||
}}
|
||||
>
|
||||
<div style={{ background: '#fff', borderRadius: 14, padding: 20, maxWidth: 360, width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700 }}>{title}</div>
|
||||
<div style={{ fontSize: 14, color: '#555', lineHeight: 1.5 }}>{children}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Button variant="primary" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<FpMeetingPointData, 'id'>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,3 +183,81 @@ describe('safeUserUsername', () => {
|
||||
expect(safeUserUsername(user(''))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventRegistration', () => {
|
||||
const E = 'event-1';
|
||||
const M1 = 'mp-1';
|
||||
const M2 = 'mp-2';
|
||||
const events: FpEventData[] = [{ id: E, title: 'Concert', description: '', date: '2026-10-10', location: 'Lyon', participantCount: 0 }];
|
||||
const users: FpUserData[] = [{ id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' }];
|
||||
const meetingPoints: FpMeetingPointData[] = [
|
||||
{ id: M1, eventId: E, location: 'Café', time: '' },
|
||||
{ id: M2, eventId: E, location: 'Gare', time: '' },
|
||||
];
|
||||
const part = (target: string, userId = 'user-1'): FpParticipationData => ({ id: `${target}|${userId}`, eventId: target, userId, isConfirmed: true });
|
||||
const registration = (participations: FpParticipationData[]) =>
|
||||
buildQueries(events, users, participations, meetingPoints, [] as FpFriendshipData[], 'user-1').getEventRegistration(E);
|
||||
|
||||
test('signed up to nothing: not registered', () => {
|
||||
expect(registration([])).toEqual({ direct: false, meetingPointIds: [], registered: false });
|
||||
});
|
||||
|
||||
test('signed up directly: registered, through no meeting point (R1)', () => {
|
||||
expect(registration([part(E)])).toEqual({ direct: true, meetingPointIds: [], registered: true });
|
||||
});
|
||||
|
||||
test('a member of one meeting point: registered without any direct sign-up (R2)', () => {
|
||||
expect(registration([part(M1)])).toEqual({ direct: false, meetingPointIds: [M1], registered: true });
|
||||
});
|
||||
|
||||
test('a member of two meeting points lists both (R2)', () => {
|
||||
const found = registration([part(M1), part(M2)]);
|
||||
expect(found.direct).toBe(false);
|
||||
expect([...found.meetingPointIds].sort()).toEqual([M1, M2]);
|
||||
expect(found.registered).toBe(true);
|
||||
});
|
||||
|
||||
test('direct and a member of a meeting point: both facts are kept', () => {
|
||||
expect(registration([part(E), part(M1)])).toEqual({ direct: true, meetingPointIds: [M1], registered: true });
|
||||
});
|
||||
|
||||
test('leaving one of two meeting points keeps the registration (R3)', () => {
|
||||
expect(registration([part(M2)])).toEqual({ direct: false, meetingPointIds: [M2], registered: true });
|
||||
});
|
||||
|
||||
test('leaving the last meeting point without a direct sign-up ends the registration (R3)', () => {
|
||||
expect(registration([])).toEqual({ direct: false, meetingPointIds: [], registered: false });
|
||||
});
|
||||
|
||||
test('leaving the last meeting point while signed up directly keeps the registration (R3)', () => {
|
||||
expect(registration([part(E)])).toEqual({ direct: true, meetingPointIds: [], registered: true });
|
||||
});
|
||||
|
||||
test("someone else's sign-ups never count as mine", () => {
|
||||
expect(registration([part(E, 'user-2'), part(M1, 'user-2')])).toEqual({ direct: false, meetingPointIds: [], registered: false });
|
||||
});
|
||||
|
||||
test('a meeting point of another event never registers me to this one', () => {
|
||||
const other: FpMeetingPointData[] = [{ id: 'mp-9', eventId: 'event-2', location: 'Ailleurs', time: '' }];
|
||||
const found = buildQueries(events, users, [part('mp-9')], [...meetingPoints, ...other], [] as FpFriendshipData[], 'user-1').getEventRegistration(E);
|
||||
expect(found.registered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserEvents with direct sign-ups and meeting point memberships', () => {
|
||||
const events: FpEventData[] = [
|
||||
{ id: 'event-1', title: 'Direct', description: '', date: '', location: '', participantCount: 0 },
|
||||
{ id: 'event-2', title: 'Par un point', description: '', date: '', location: '', participantCount: 0 },
|
||||
{ id: 'event-3', title: 'Aucun', description: '', date: '', location: '', participantCount: 0 },
|
||||
];
|
||||
const meetingPoints: FpMeetingPointData[] = [{ id: 'mp-2', eventId: 'event-2', location: 'Café', time: '' }];
|
||||
const participations: FpParticipationData[] = [
|
||||
{ id: 'p1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
|
||||
{ id: 'p2', eventId: 'mp-2', userId: 'user-1', isConfirmed: true },
|
||||
];
|
||||
const queries = buildQueries(events, [], participations, meetingPoints, [] as FpFriendshipData[], 'user-1');
|
||||
|
||||
test("my events are those I signed up to directly and those of the meeting points I belong to (R1, R2)", () => {
|
||||
expect(queries.getUserEvents('user-1').map(e => e.id).sort()).toEqual(['event-1', 'event-2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> | void;
|
||||
/** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */
|
||||
leaveMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ const CAROL = 'http://carol.pod.test/carol';
|
||||
const E = 'http://bob.pod.test/bob/data/events/concert';
|
||||
const M = 'http://bob.pod.test/bob/data/events/concert-bar';
|
||||
const C = 'http://bob.pod.test/bob/data/events/concert-bar/attendees';
|
||||
/** CE: the event's OWN attendee list — where the direct sign-ups are listed. */
|
||||
const CE = 'http://bob.pod.test/bob/data/events/concert/attendees';
|
||||
/** M2: the event's SECOND meeting point, with its own attendee list C2. */
|
||||
const M2 = 'http://bob.pod.test/bob/data/events/concert-terrasse';
|
||||
const C2 = 'http://bob.pod.test/bob/data/events/concert-terrasse/attendees';
|
||||
@@ -80,6 +82,7 @@ const CAROL_URN_E = urnOf(CAROL, E, CAROL_SECRET);
|
||||
const DAVE_URN_E = urnOf(DAVE, E, DAVE_SECRET);
|
||||
|
||||
const EVENT_RECORD = { id: E, type: 'Event', name: 'Concert', startTime: '2026-11-01T20:00:00', 'dc:creator': BOB, to: 'as:Public' };
|
||||
const EVENT_RECORD_LISTED = { ...EVENT_RECORD, 'apods:attendees': CE };
|
||||
const MP_RECORD = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C, to: 'as:Public' };
|
||||
const MP2_RECORD = { id: M2, type: 'Event', location: 'En terrasse', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C2, to: 'as:Public' };
|
||||
/** E2: an event Bob DECLARED — known to this session only through the catalog's owner filter. */
|
||||
@@ -106,6 +109,11 @@ const server = {
|
||||
/** Attendee lists of M and M2, and how many reads of either were served. */
|
||||
members: [] as string[],
|
||||
members2: [] as string[],
|
||||
/** The event's own attendee list, and whether the event is served with its link. */
|
||||
eventMembers: [] as string[],
|
||||
eventListed: false,
|
||||
/** Objects whose Join/Leave the outbox refuses. */
|
||||
refused: [] as string[],
|
||||
reads: 0,
|
||||
/** Alice's own list (`own`, with M's list link once `ownLinked`). */
|
||||
own: [] as JsonRecord[],
|
||||
@@ -140,6 +148,8 @@ function json(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/ld+json' } });
|
||||
}
|
||||
|
||||
const eventRecord = () => (server.eventListed ? EVENT_RECORD_LISTED : EVENT_RECORD);
|
||||
|
||||
async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (init?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
|
||||
@@ -147,6 +157,7 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom
|
||||
if (method === 'POST') {
|
||||
if (url === ALICE_OUTBOX) {
|
||||
const body = JSON.parse(String(init?.body)) as JsonRecord;
|
||||
if (server.refused.includes(String(body['object']))) return new Response(null, { status: 500 });
|
||||
server.posted.push(body);
|
||||
server.log.push(String(body['type']));
|
||||
return new Response(null, { status: 201 });
|
||||
@@ -158,7 +169,7 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom
|
||||
const owner = new URL(url).searchParams.get('owner');
|
||||
const items = owner
|
||||
? (owner === BOB ? server.bobDeclared : [])
|
||||
: (server.secondMp ? [EVENT_RECORD, MP_RECORD, MP2_RECORD] : [EVENT_RECORD, MP_RECORD]);
|
||||
: (server.secondMp ? [eventRecord(), MP_RECORD, MP2_RECORD] : [eventRecord(), MP_RECORD]);
|
||||
return json({ type: 'OrderedCollection', items });
|
||||
}
|
||||
switch (url) {
|
||||
@@ -177,7 +188,8 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom
|
||||
case BOB_PARTICIPATIONS:
|
||||
server.bobParticipationsReads++;
|
||||
return json({ id: BOB_PARTICIPATIONS, type: 'Collection', items: [...server.bobParticipations] });
|
||||
case E: return json(EVENT_RECORD);
|
||||
case E: return json(eventRecord());
|
||||
case CE: return json({ id: CE, type: 'Collection', items: [...server.eventMembers] });
|
||||
case E2: return json(E2_RECORD);
|
||||
case M: return json(server.mpLinked ? MP_RECORD : MP_RECORD_UNLINKED);
|
||||
case M2: return json(MP2_RECORD);
|
||||
@@ -224,7 +236,7 @@ const alicePod = {
|
||||
* collection — created and linked from her profile at her first Join.
|
||||
*/
|
||||
function backendProcesses(op: 'join' | 'leave', mp: string): void {
|
||||
const list = mp === M ? 'members' : 'members2';
|
||||
const list = mp === E ? 'eventMembers' : mp === M ? 'members' : 'members2';
|
||||
if (op === 'join') {
|
||||
if (!server[list].includes(ALICE_URN_E)) server[list] = [...server[list], ALICE_URN_E];
|
||||
if (!server.myParticipations.includes(mp)) server.myParticipations = [...server.myParticipations, mp];
|
||||
@@ -323,11 +335,20 @@ async function settledView(match: (v: ReturnType<typeof view>) => boolean, timeo
|
||||
return current;
|
||||
}
|
||||
|
||||
type Registration = { direct: boolean; meetingPointIds: string[]; registered: boolean };
|
||||
const registration = (): Registration => {
|
||||
const found = latest?.getEventRegistration(E);
|
||||
return found ? { ...found, meetingPointIds: [...found.meetingPointIds].sort() } : { direct: false, meetingPointIds: [], registered: false };
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
interface Scenario {
|
||||
members?: string[];
|
||||
members2?: string[];
|
||||
/** The event's own attendee list; the event is served with its link only when this is given. */
|
||||
eventMembers?: string[];
|
||||
refused?: string[];
|
||||
own?: JsonRecord[];
|
||||
mpLinked?: boolean;
|
||||
secondMp?: boolean;
|
||||
@@ -345,6 +366,9 @@ interface Scenario {
|
||||
function render(s: Scenario = {}): void {
|
||||
server.members = s.members ?? [];
|
||||
server.members2 = s.members2 ?? [];
|
||||
server.eventMembers = s.eventMembers ?? [];
|
||||
server.eventListed = s.eventMembers !== undefined;
|
||||
server.refused = s.refused ?? [];
|
||||
server.reads = 0;
|
||||
server.own = s.own ?? [];
|
||||
server.ownLinked = false;
|
||||
@@ -729,3 +753,115 @@ test('a contact whose profile links no participations collection: asking for the
|
||||
expect(latest!.getUserEvents(BOB)).toEqual([]);
|
||||
expect(server.bobParticipationsReads).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
// --- signing up to the event itself -------------------------------------------------
|
||||
|
||||
const NOT_REGISTERED: Registration = { direct: false, meetingPointIds: [], registered: false };
|
||||
|
||||
test('joining an event directly: the secret first, then ONE Join naming the event, addressed to the app actor alone; the event shows on my home (R1)', async () => {
|
||||
await mount({ eventMembers: [] });
|
||||
|
||||
await latest!.joinEvent(E);
|
||||
|
||||
expect(server.log).toEqual(['profile-write', 'Join']);
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: E, to: `${BACKEND}/as/actor/app` }]);
|
||||
await until(() => registration().direct, 'the direct sign-up reflected at once');
|
||||
expect(registration()).toEqual({ direct: true, meetingPointIds: [], registered: true });
|
||||
|
||||
backendProcesses('join', E);
|
||||
await until(() => view().event === 1 && view().mp === 0, 'the event counting my sign-up, M listed with no member');
|
||||
expect(view().mp).toBe(0);
|
||||
expect(registration()).toEqual({ direct: true, meetingPointIds: [], registered: true });
|
||||
await until(() => latest?.getUserEvents(ALICE).some(e => e.id === E) === true, 'the event on my home');
|
||||
}, 30000);
|
||||
|
||||
test('a refused direct Join rejects, and the status shown stays "not registered" (R7)', async () => {
|
||||
await mount({ eventMembers: [], secret: ALICE_SECRET, myParticipations: [], refused: [E] });
|
||||
|
||||
await expect(latest!.joinEvent(E)).rejects.toThrow();
|
||||
await land();
|
||||
|
||||
expect(server.posted).toEqual([]);
|
||||
expect(registration()).toEqual(NOT_REGISTERED);
|
||||
}, 30000);
|
||||
|
||||
test('an event joined only through a meeting point: registered with no direct sign-up and no act naming the event (R2)', async () => {
|
||||
render({ eventMembers: [], members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M] });
|
||||
|
||||
await until(() => registration().registered, 'registered through the meeting point');
|
||||
|
||||
expect(registration()).toEqual({ direct: false, meetingPointIds: [M], registered: true });
|
||||
expect(server.posted).toEqual([]);
|
||||
}, 30000);
|
||||
|
||||
test("another member's direct sign-up in the event's list counts at the event and in no meeting point (R5)", async () => {
|
||||
render({ eventMembers: [BOB_URN_E, CAROL_URN_E], members: [CAROL_URN_E] });
|
||||
|
||||
await until(() => view().event === 2 && view().mp === 1, 'the event counting both persons and M counting its one member');
|
||||
|
||||
expect(view()).toEqual({ event: 2, mp: 1, joined: false });
|
||||
}, 30000);
|
||||
|
||||
test("a contact signed up directly is recognised in the event's own list, and a contact's participations naming the event list it among their events (R6)", async () => {
|
||||
render({
|
||||
eventMembers: [BOB_URN_E],
|
||||
contacts: [BOB],
|
||||
contactProfiles: bobProfile({ secret: BOB_SECRET, participations: BOB_PARTICIPATIONS }),
|
||||
bobParticipations: [E],
|
||||
});
|
||||
|
||||
await until(() => latest?.getEventParticipants(E).some(u => u.id === BOB) === true, 'Bob recognised from the event list');
|
||||
latest!.openUserEvents(BOB);
|
||||
await until(() => latest?.getUserEvents(BOB).some(e => e.id === E) === true, "the event among Bob's events");
|
||||
}, 30000);
|
||||
|
||||
// --- leaving the event: the cascade belongs to the data context -----------------------
|
||||
|
||||
async function mountRegistered(s: Scenario): Promise<void> {
|
||||
render({ eventMembers: [ALICE_URN_E], members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [E, M, M2], ...s });
|
||||
await until(() => registration().direct && registration().meetingPointIds.length === 2, 'registered directly and through both meeting points');
|
||||
}
|
||||
|
||||
test('leaving an event posts a Leave for the direct sign-up and one for each meeting point joined, and shows the user no longer registered (R4)', async () => {
|
||||
await mountRegistered({});
|
||||
|
||||
await latest!.leaveEvent(E);
|
||||
|
||||
expect(server.posted.map(body => body['type'])).toEqual(['Leave', 'Leave', 'Leave']);
|
||||
expect(server.posted.map(body => body['object']).sort()).toEqual([E, M, M2].sort());
|
||||
expect(server.profileWrites).toEqual([]);
|
||||
await until(() => !registration().registered, 'the status following the withdrawal');
|
||||
expect(registration()).toEqual(NOT_REGISTERED);
|
||||
}, 30000);
|
||||
|
||||
test('leaving an event held only through meeting points posts no Leave naming the event (R4)', async () => {
|
||||
render({ eventMembers: [], members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [M, M2] });
|
||||
await until(() => registration().meetingPointIds.length === 2, 'registered through both meeting points');
|
||||
|
||||
await latest!.leaveEvent(E);
|
||||
|
||||
expect(server.posted.map(body => body['object']).sort()).toEqual([M, M2].sort());
|
||||
}, 30000);
|
||||
|
||||
test('a partly failed withdrawal rejects naming the targets that failed, and the status shows what remains (R7)', async () => {
|
||||
await mountRegistered({ refused: [M2] });
|
||||
|
||||
const failure = await latest!.leaveEvent(E).then(() => undefined, (error: unknown) => error as Error);
|
||||
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure!.message).toContain(M2);
|
||||
expect(failure!.message).not.toContain(M);
|
||||
expect(server.posted.map(body => body['object']).sort()).toEqual([E, M].sort());
|
||||
backendProcesses('leave', E);
|
||||
backendProcesses('leave', M);
|
||||
await until(() => !registration().direct && registration().meetingPointIds.length === 1, 'only M2 remaining');
|
||||
expect(registration()).toEqual({ direct: false, meetingPointIds: [M2], registered: true });
|
||||
}, 30000);
|
||||
|
||||
test('leaving an event the user holds nothing of rejects and posts nothing', async () => {
|
||||
await mount({ eventMembers: [] });
|
||||
|
||||
await expect(latest!.leaveEvent(E)).rejects.toThrow();
|
||||
|
||||
expect(server.posted).toEqual([]);
|
||||
}, 30000);
|
||||
|
||||
@@ -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<FpEventData, 'id'>) => ({ ...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;
|
||||
@@ -201,6 +205,18 @@ interface ContactKeys {
|
||||
}
|
||||
|
||||
/** The memo key of one contact's member URN at one event. */
|
||||
/** The event a joined target is anchored to: its own event for a meeting point, itself for an event. */
|
||||
function anchorEventIdOf(bindings: RefineBackend['bindings'], targetId: string, record: unknown): string | undefined {
|
||||
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
|
||||
try {
|
||||
return bindings.meetingPoint.toDomain(record).eventId || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return bindings.event.isInstance?.(record) ?? true ? targetId : undefined;
|
||||
}
|
||||
|
||||
function memberUrnKey(contactId: string, eventId: string, secret: string): string {
|
||||
return `${contactId}\n${eventId}\n${secret}`;
|
||||
}
|
||||
@@ -251,6 +267,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 +377,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=<their WebID>` (the header's first contact channel):
|
||||
// the declaration is public by design, and the catalog item IS the event
|
||||
@@ -429,8 +447,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<string>();
|
||||
@@ -457,13 +475,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const ids = new Set<string>();
|
||||
for (const mpId of joinedForeignMeetingPointIds) {
|
||||
const record = foreignMeetingPointsRead.records.get(mpId);
|
||||
if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
|
||||
try {
|
||||
const eventId = bindings.meetingPoint.toDomain(record).eventId;
|
||||
if (eventId) ids.add(eventId);
|
||||
} catch {
|
||||
// Unreadable by the binding — the pick below drops the record too.
|
||||
}
|
||||
if (record === undefined) continue;
|
||||
const eventId = anchorEventIdOf(bindings, mpId, record);
|
||||
if (eventId) ids.add(eventId);
|
||||
}
|
||||
return [...ids].filter(id => !ownEventIds.has(id));
|
||||
}, [joinedForeignMeetingPointIds, foreignMeetingPointsRead.records, bindings, ownEventIds]);
|
||||
@@ -499,7 +513,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 +523,11 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const collectionOf = useMemo(() => {
|
||||
const links = new Map<string, string>(); // 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 +594,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 +750,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<string, Map<string, string[]>>(); // event → URN → meeting points
|
||||
for (const p of derived.participations) {
|
||||
const eventId = eventOf.get(p.eventId);
|
||||
@@ -781,7 +798,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<string, Set<string>>();
|
||||
for (const [eventId, members] of listedMembersByEvent) {
|
||||
for (const [contactId, keys] of contactKeys) {
|
||||
@@ -834,16 +851,12 @@ 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<string, string>(); // meeting point URI → anchor event URI
|
||||
const ids = new Map<string, string>(); // 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;
|
||||
try {
|
||||
const eventId = bindings.meetingPoint.toDomain(record).eventId;
|
||||
if (eventId) ids.set(uri, eventId);
|
||||
} catch {
|
||||
// Not a readable meeting point — the binding drops it, as everywhere.
|
||||
}
|
||||
if (record === undefined) continue;
|
||||
const eventId = anchorEventIdOf(bindings, uri, record);
|
||||
if (eventId) ids.set(uri, eventId);
|
||||
}
|
||||
return ids;
|
||||
}, [contactMpUris, contactMpsRead.records, bindings]);
|
||||
@@ -917,23 +930,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<string>();
|
||||
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<string>();
|
||||
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<string>(myTargetIds);
|
||||
for (const mpId of myTargetIds) {
|
||||
const mp = allMeetingPoints.find(m => m.id === mpId);
|
||||
if (mp) joinedEventIds.add(mp.eventId);
|
||||
}
|
||||
@@ -950,7 +963,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 +976,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 +1043,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 +1051,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 +1062,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 +1219,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 +1227,40 @@ 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]);
|
||||
|
||||
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 +1309,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -380,3 +380,70 @@ test('the re-read schedule is finite and increasing', () => {
|
||||
const previous = [0, ...MEMBERSHIP_REREAD_DELAYS_MS];
|
||||
expect(MEMBERSHIP_REREAD_DELAYS_MS.every((delay, i) => delay > (previous[i] ?? delay))).toBe(true);
|
||||
});
|
||||
|
||||
const eventList = (items: unknown[]) => ({ id: `${EVENT}/attendees`, totalItems: items.length, items });
|
||||
|
||||
test("the event's own attendee list becomes participations keyed by the event", () => {
|
||||
const aliceUrn = URN('a'.repeat(64));
|
||||
const derived = deriveContainer(
|
||||
[{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn]) }],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.participations).toEqual([{ id: `${EVENT}|${aliceUrn}`, eventId: EVENT, userId: aliceUrn, isConfirmed: true }]);
|
||||
expect(derived.totals.get(EVENT)).toBe(1);
|
||||
});
|
||||
|
||||
test("the event's count is the distinct URNs over its own list and its meeting points' lists", () => {
|
||||
const mp2 = 'http://localhost:3000/alice/data/mp-2';
|
||||
const aliceUrn = URN('a'.repeat(64));
|
||||
const bobUrn = URN('b'.repeat(64));
|
||||
const carolUrn = URN('c'.repeat(64));
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn, carolUrn]) },
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [aliceUrn, bobUrn] } },
|
||||
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [bobUrn] } },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.events[0]?.participantCount).toBe(3);
|
||||
});
|
||||
|
||||
test('a person signed up directly and in a meeting point counts once, at the event and in the meeting point', () => {
|
||||
const aliceUrn = URN('a'.repeat(64));
|
||||
const bobUrn = URN('b'.repeat(64));
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn, bobUrn]) },
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [aliceUrn] } },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.events[0]?.participantCount).toBe(2);
|
||||
expect(derived.meetingPoints[0]?.participantCount).toBe(1);
|
||||
});
|
||||
|
||||
test("a direct sign-up counts at the event but in no meeting point", () => {
|
||||
const aliceUrn = URN('a'.repeat(64));
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn]) },
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 0, items: [] } },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.events[0]?.participantCount).toBe(1);
|
||||
expect(derived.meetingPoints[0]?.participantCount).toBe(0);
|
||||
});
|
||||
|
||||
test('a pending direct sign-up adds one to the event and shows a participation keyed by the event', () => {
|
||||
const derived = deriveContainer(
|
||||
[{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([]) }],
|
||||
bindings,
|
||||
);
|
||||
const pending: PendingActs = recordMembershipAct(new Map(), EVENT, 'join', 0, derived.totals.get(EVENT));
|
||||
const adjusted = countsWithOverlay(derived, pending, ALICE);
|
||||
expect(adjusted.events[0]?.participantCount).toBe(1);
|
||||
const overlaid = applyMembershipOverlay(derived.participations, overlayOfPendingActs(pending), ALICE);
|
||||
expect(overlaid).toEqual([{ id: `${EVENT}|${ALICE}`, eventId: EVENT, userId: ALICE, isConfirmed: true }]);
|
||||
});
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
+11
-11
@@ -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<void> {
|
||||
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<void> {
|
||||
return postActivity('Leave', input);
|
||||
}
|
||||
|
||||
@@ -196,6 +196,11 @@ export const seedParticipations: FpParticipationData[] = [
|
||||
{ id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true },
|
||||
// Thomas participates in event 1
|
||||
{ id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true },
|
||||
// Marie is a member of both meeting points of event 5, and not signed up to it directly
|
||||
{ id: 'part-6', eventId: 'mp-3', userId: 'user-1', isConfirmed: true },
|
||||
{ id: 'part-7', eventId: 'mp-4', userId: 'user-1', isConfirmed: true },
|
||||
// Someone Marie cannot read signed up directly to event 4: counted, never shown
|
||||
{ id: 'part-8', eventId: 'event-4', userId: 'user-hors-reseau', isConfirmed: true },
|
||||
];
|
||||
|
||||
export const seedMeetingPoints: FpMeetingPointData[] = [
|
||||
@@ -211,6 +216,20 @@ export const seedMeetingPoints: FpMeetingPointData[] = [
|
||||
location: 'Station de métro Bellecour',
|
||||
time: '15h30',
|
||||
},
|
||||
{
|
||||
id: 'mp-3',
|
||||
eventId: 'event-5',
|
||||
title: 'Covoiturage depuis la gare',
|
||||
location: 'Gare Part-Dieu',
|
||||
time: '18h00',
|
||||
},
|
||||
{
|
||||
id: 'mp-4',
|
||||
eventId: 'event-5',
|
||||
title: 'Départ à pied de la place',
|
||||
location: 'Place Carnot',
|
||||
time: '18h30',
|
||||
},
|
||||
];
|
||||
|
||||
export const seedFriendships: FpFriendshipData[] = [
|
||||
|
||||
@@ -16,9 +16,13 @@ export interface ApodsScenarioState {
|
||||
session?: ApodsSession;
|
||||
/** The app server URL the `@browser` scenario loads (set by the hook). */
|
||||
appUrl?: string;
|
||||
/** Name of the throwaway account a scenario created (deleted by its After hook). */
|
||||
username?: string;
|
||||
anchorUri?: string;
|
||||
meetingPointUri?: string;
|
||||
collectionUri?: string;
|
||||
/** The `apods:attendees` collection of the anchor event itself (direct sign-ups). */
|
||||
eventCollectionUri?: string;
|
||||
}
|
||||
|
||||
export interface FestipodWorld extends World {
|
||||
|
||||
Reference in New Issue
Block a user