|
|
|
@@ -10,8 +10,6 @@ import type {
|
|
|
|
|
import {
|
|
|
|
|
depositRegistration,
|
|
|
|
|
depositLeave,
|
|
|
|
|
buildNotification,
|
|
|
|
|
insertNotification,
|
|
|
|
|
readRegistrationNotifications,
|
|
|
|
|
materializeAttendance,
|
|
|
|
|
deleteParticipation,
|
|
|
|
@@ -116,24 +114,44 @@ function nextId(prefix: string): string {
|
|
|
|
|
// old form, or those participations render as "participant inconnu".
|
|
|
|
|
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
|
|
|
|
|
|
|
|
|
|
/** Waits between attempts at resolving the owned-event set (see its effect). */
|
|
|
|
|
/** Waits between attempts at resolving an owned-document set (see the effects). */
|
|
|
|
|
const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
|
|
|
|
|
* that meet at this join (the root cause of the "unknown participant" bug):
|
|
|
|
|
* • a Participation stores `urn:festipod:user:<normalized-identifier>` (the stable
|
|
|
|
|
* principal = `currentUserId`), while
|
|
|
|
|
* • a UserProfile's `id` is its `did:ng:` document NURI — never that principal.
|
|
|
|
|
* The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)`
|
|
|
|
|
* for the matching profile (the exact equality `currentUser` resolution already uses).
|
|
|
|
|
* So: strip the principal prefix off the participation's userId, and compare the
|
|
|
|
|
* remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides
|
|
|
|
|
* are the bare seed id (`user-1`), matched directly by `u.id === userId` — which is
|
|
|
|
|
* why the direct match is tried FIRST (the seed username `@mariedupont` would not
|
|
|
|
|
* normalize to `user-1`). A per-deposit materializer uid (`mint...`, e.g.
|
|
|
|
|
* `mrktnoke-rzd699dk`) is a THIRD, unrelated space: it identifies an inbox deposit
|
|
|
|
|
* for the count, never a user — it does not participate in this join.
|
|
|
|
|
* What a BRAND-NEW profile is created with.
|
|
|
|
|
*
|
|
|
|
|
* A profile is entirely Festipod's own object — the data layer knows nothing of
|
|
|
|
|
* pseudos or display names — but the UserProfile SHEX shape makes `fp:name`,
|
|
|
|
|
* `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written
|
|
|
|
|
* empty. And nothing about the person is known at sign-in: the identity the
|
|
|
|
|
* session signed in as is OPAQUE (no display name; it is never parsed, never
|
|
|
|
|
* rendered, never written into an entity). So the three required fields carry a
|
|
|
|
|
* PLACEHOLDER that reads on screen as "not filled in yet": none of them is a
|
|
|
|
|
* person's name or handle, and none is derived from the identity. The user
|
|
|
|
|
* replaces them through `updateProfile` (UpdateProfileScreen).
|
|
|
|
|
*/
|
|
|
|
|
const UNSET_PROFILE = {
|
|
|
|
|
name: 'Profil à compléter',
|
|
|
|
|
initials: '?',
|
|
|
|
|
username: '(pseudo non défini)',
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve a Participation's `fp:user` to its UserProfile.
|
|
|
|
|
*
|
|
|
|
|
* TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document
|
|
|
|
|
* NURI, so the DIRECT match `u.id === userId` answers — and it is tried first.
|
|
|
|
|
* The demo/local fixtures coincide there too (both sides are the bare `user-1`).
|
|
|
|
|
*
|
|
|
|
|
* LEGACY ONLY: participations written under the earlier scheme carry
|
|
|
|
|
* `urn:festipod:user:<normalized-handle>`, which matches no profile id. For those
|
|
|
|
|
* — and ONLY those — the handle is stripped off and compared to
|
|
|
|
|
* `normalizeIdentifier(profile.username)`. This username bridge is a READ-side
|
|
|
|
|
* survival for old data; it plays NO part in deciding who the current user is
|
|
|
|
|
* (my profile is the profile document I own — see the "WHO AM I" block below).
|
|
|
|
|
*
|
|
|
|
|
* A per-deposit materializer uid (`mint...`, e.g. `mrktnoke-rzd699dk`) is a third,
|
|
|
|
|
* unrelated space: it identifies an inbox deposit for the count, never a user.
|
|
|
|
|
*/
|
|
|
|
|
function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
|
|
|
|
|
// 1) Direct id match — demo/local seed space (`user-1`), or any coincident space.
|
|
|
|
@@ -304,10 +322,10 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
const eventQuery = useShapeQuery(FpEventShapeType, 'public');
|
|
|
|
|
const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
|
|
|
|
|
const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
|
|
|
|
|
const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
|
|
|
|
|
// The RAW reactive sets straight from `watchShape` (before the optimistic
|
|
|
|
|
// overlay). The exposed `events`/`participations` merge these with the pending
|
|
|
|
|
// overlay below — see the "OPTIMISTIC OVERLAY" block.
|
|
|
|
|
// overlay). The exposed `events`/`users`/`participations` merge these with the
|
|
|
|
|
// pending overlay below — see the "OPTIMISTIC OVERLAY" block.
|
|
|
|
|
const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
|
|
|
|
|
const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
|
|
|
|
|
const reactiveParticipations = React.useMemo(
|
|
|
|
|
() => adaptParticipations(partQuery.data),
|
|
|
|
@@ -331,9 +349,17 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
|
|
|
|
|
// to `watchShape`'s own pushes.
|
|
|
|
|
const [pendingAddEvents, setPendingAddEvents] = useState<FpEventData[]>([]);
|
|
|
|
|
const [pendingAddUsers, setPendingAddUsers] = useState<FpUserData[]>([]);
|
|
|
|
|
const [pendingAddParticipations, setPendingAddParticipations] = useState<FpParticipationData[]>([]);
|
|
|
|
|
const [pendingRemoveIds, setPendingRemoveIds] = useState<Set<string>>(() => new Set());
|
|
|
|
|
|
|
|
|
|
const users = React.useMemo(() => {
|
|
|
|
|
if (pendingAddUsers.length === 0) return reactiveUsers;
|
|
|
|
|
const seen = new Set(reactiveUsers.map(u => u.id));
|
|
|
|
|
const extra = pendingAddUsers.filter(u => !seen.has(u.id));
|
|
|
|
|
return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers;
|
|
|
|
|
}, [reactiveUsers, pendingAddUsers]);
|
|
|
|
|
|
|
|
|
|
const events = React.useMemo(() => {
|
|
|
|
|
if (pendingAddEvents.length === 0) return reactiveEvents;
|
|
|
|
|
const seen = new Set(reactiveEvents.map(e => e.id));
|
|
|
|
@@ -365,6 +391,15 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
});
|
|
|
|
|
}, [reactiveEvents, pendingAddEvents]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (pendingAddUsers.length === 0) return;
|
|
|
|
|
const live = new Set(reactiveUsers.map(u => u.id));
|
|
|
|
|
setPendingAddUsers(prev => {
|
|
|
|
|
const next = prev.filter(u => !live.has(u.id));
|
|
|
|
|
return next.length === prev.length ? prev : next;
|
|
|
|
|
});
|
|
|
|
|
}, [reactiveUsers, pendingAddUsers]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (pendingAddParticipations.length === 0) return;
|
|
|
|
|
const live = new Set(reactiveParticipations.map(p => p.id));
|
|
|
|
@@ -406,6 +441,17 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
|
|
|
|
|
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
|
|
|
|
|
|
|
|
|
|
// WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests.
|
|
|
|
|
// `null` means NOT ANSWERED YET (the listing has not landed, or it failed):
|
|
|
|
|
// "unknown" and "I own nothing" are indistinguishable as an empty array, and
|
|
|
|
|
// only one of them may lead to creating a profile. Nothing downstream may read
|
|
|
|
|
// `null` as an empty set.
|
|
|
|
|
const [myProtectedDocs, setMyProtectedDocs] = useState<Nuri[] | null>(null);
|
|
|
|
|
// The profile document THIS session created for the signed-in person. Set once,
|
|
|
|
|
// by the creation effect; it settles "which of my profile documents is mine"
|
|
|
|
|
// without looking at any field of any profile.
|
|
|
|
|
const [myProfileDocId, setMyProfileDocId] = useState<string>('');
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fold documents THIS SESSION just created into the owned set.
|
|
|
|
|
*
|
|
|
|
@@ -501,7 +547,12 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
if (hasTriedAutoSeed.current) return;
|
|
|
|
|
if (!ready) return;
|
|
|
|
|
if (!readReady) return; // still syncing — do NOT mistake pending for empty
|
|
|
|
|
const walletHasData = events.length > 0 || users.length > 0;
|
|
|
|
|
// MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a
|
|
|
|
|
// brand-new wallet, so counting it here would permanently disable the seed on
|
|
|
|
|
// exactly the wallets it exists for. The question the gate asks is "does this
|
|
|
|
|
// wallet already hold something worth preserving", and my own empty profile
|
|
|
|
|
// does not.
|
|
|
|
|
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
|
|
|
|
|
if (!shouldAutoSeed(walletHasData)) {
|
|
|
|
|
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
|
|
|
|
|
return;
|
|
|
|
@@ -516,40 +567,51 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
|
|
|
|
|
// own (each createEntityDoc appends to the scope index → the container-index
|
|
|
|
|
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
|
|
|
|
|
}, [ready, readReady, events.length, users.length, claimOwnedEventDocs]);
|
|
|
|
|
}, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]);
|
|
|
|
|
|
|
|
|
|
// --- Derived ---
|
|
|
|
|
// WHO AM I — answered in TWO id spaces that must not be confused.
|
|
|
|
|
//
|
|
|
|
|
// (1) `currentPrincipal` — what signing in returned. Known as soon as the one
|
|
|
|
|
// identity await settles, i.e. before any document has been read. It names
|
|
|
|
|
// a PERSON. It is for display and log attribution; no data call takes it,
|
|
|
|
|
// and it is never written into an entity.
|
|
|
|
|
// (2) `currentUserId` — the app's own entity space: the `@id` of the profile
|
|
|
|
|
// DOCUMENT read back in the protected scope (a doc NURI). This is what a
|
|
|
|
|
// Participation's `fp:user` carries and what `resolveParticipantUser`
|
|
|
|
|
// matches directly, so it is the only value a mutation may write. It stays
|
|
|
|
|
// empty until the protected read lands — mutations that need it refuse
|
|
|
|
|
// rather than write an entity the read would drop.
|
|
|
|
|
// a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never
|
|
|
|
|
// written into an entity and never handed to a data call. It exists here
|
|
|
|
|
// for display of "am I signed in" and for log attribution.
|
|
|
|
|
// (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE
|
|
|
|
|
// DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries
|
|
|
|
|
// and what `resolveParticipantUser` matches directly, so it is the only
|
|
|
|
|
// value a mutation may write.
|
|
|
|
|
//
|
|
|
|
|
// THE JOIN between the two is explicit and lives HERE, in one place: a profile
|
|
|
|
|
// belongs to the signed-in person when its username normalizes to the
|
|
|
|
|
// principal — the same bridge `resolveParticipantUser` uses for the legacy
|
|
|
|
|
// `urn:festipod:user:` space. Nothing merges the spaces: the principal selects
|
|
|
|
|
// a profile, it never stands in for one.
|
|
|
|
|
// THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is
|
|
|
|
|
// Festipod's own object; the identity says nothing about it. **My profile is
|
|
|
|
|
// the profile document I OWN** — `listMyEntityDocs('protected')` answers "which
|
|
|
|
|
// documents are mine", and the UserProfile among them is mine. No field of any
|
|
|
|
|
// profile takes part in the answer: no username comparison, no normalization,
|
|
|
|
|
// no positional pick.
|
|
|
|
|
//
|
|
|
|
|
// THREE OUTCOMES, and "somebody else's profile" is not one of them:
|
|
|
|
|
// • exactly one owned profile → that is me;
|
|
|
|
|
// • none → I have no profile yet, and the creation effect below makes one
|
|
|
|
|
// (until it lands, `currentUserId` is '' and the mutations that need it
|
|
|
|
|
// REJECT — they never write an entity keyed on nobody);
|
|
|
|
|
// • several, none of them created by this session → genuinely AMBIGUOUS (the
|
|
|
|
|
// opt-in fixture seed writes its profiles into my own protected scope), so
|
|
|
|
|
// the answer is NO PROFILE, said loudly. Picking one would be picking a
|
|
|
|
|
// person at random and calling them "you".
|
|
|
|
|
const currentPrincipal = useCurrentPrincipal();
|
|
|
|
|
const currentUser =
|
|
|
|
|
(currentPrincipal
|
|
|
|
|
? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal)
|
|
|
|
|
: undefined)
|
|
|
|
|
// No profile answers to the signed-in person (the wallet holds fixtures, or
|
|
|
|
|
// the profile read has not landed): fall back to the demo-seed pick. KNOWN
|
|
|
|
|
// HAZARD — this GUESSES a profile, so the app can show the wrong person as
|
|
|
|
|
// "you" while the real answer has simply not been read yet. Note what is and
|
|
|
|
|
// is not guessed: the identity itself never is (it is exactly what
|
|
|
|
|
// `ensureIdentity()` returned); only the profile it selects can be wrong.
|
|
|
|
|
|| users.find(u => u.username === '@mariedupont')
|
|
|
|
|
|| users[0];
|
|
|
|
|
/** The profiles I own: the reactive profiles whose document is one of mine. */
|
|
|
|
|
const myOwnedProfiles = React.useMemo<FpUserData[] | null>(() => {
|
|
|
|
|
if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
|
|
|
|
|
const mine = new Set<string>(myProtectedDocs);
|
|
|
|
|
return users.filter(u => mine.has(u.id));
|
|
|
|
|
}, [users, myProtectedDocs]);
|
|
|
|
|
const currentUser = React.useMemo<FpUserData | undefined>(() => {
|
|
|
|
|
// This session created it → no ambiguity possible, whatever else is owned.
|
|
|
|
|
if (myProfileDocId) return users.find(u => u.id === myProfileDocId);
|
|
|
|
|
if (myOwnedProfiles === null) return undefined; // not answered yet
|
|
|
|
|
if (myOwnedProfiles.length === 1) return myOwnedProfiles[0];
|
|
|
|
|
return undefined; // none, or ambiguous
|
|
|
|
|
}, [users, myProfileDocId, myOwnedProfiles]);
|
|
|
|
|
const currentUserId = currentUser?.id || '';
|
|
|
|
|
// Identity-first log prefix, reused by every DATA log below (including the
|
|
|
|
|
// closures defined earlier in this function body — they only execute after
|
|
|
|
@@ -573,6 +635,106 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
}
|
|
|
|
|
const selectedUser = users.find(u => u.id === selectedUserId);
|
|
|
|
|
|
|
|
|
|
// --- MY PROFILE: which documents are mine ----------------------------------
|
|
|
|
|
// Resolve the PROTECTED documents this identity owns. That set is the whole
|
|
|
|
|
// basis of "which profile is mine", and it is also what decides whether a
|
|
|
|
|
// profile has to be CREATED — so a failure here must never look like an answer:
|
|
|
|
|
// a rejection means UNKNOWN, and reading it as "I own nothing" would create a
|
|
|
|
|
// second profile for someone who already has one. Retried; if it still will not
|
|
|
|
|
// answer, the set stays `null` (no profile resolved, no profile created) and it
|
|
|
|
|
// is said loudly.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!ready) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
(async () => {
|
|
|
|
|
for (let attempt = 0; !cancelled; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
const mine = await listMyEntityDocs('protected');
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]);
|
|
|
|
|
return;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
|
|
|
|
|
if (wait === undefined) {
|
|
|
|
|
console.error(
|
|
|
|
|
`${logPrefix} my-protected-documents resolution FAILED after ` +
|
|
|
|
|
`${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` +
|
|
|
|
|
`not absent: no profile will be resolved and none will be created until it is known:`,
|
|
|
|
|
err,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
console.warn(
|
|
|
|
|
`${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` +
|
|
|
|
|
`retrying in ${wait}ms:`,
|
|
|
|
|
err,
|
|
|
|
|
);
|
|
|
|
|
await new Promise(r => setTimeout(r, wait));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
}, [ready]);
|
|
|
|
|
|
|
|
|
|
// --- MY PROFILE: create one when I have none -------------------------------
|
|
|
|
|
// A profile is Festipod's own object, and signing in produces none — so the
|
|
|
|
|
// first time a person signs in, the app makes theirs. Gated on BOTH the
|
|
|
|
|
// protected read having settled (`userQuery.isSuccess`: synced-and-empty, not
|
|
|
|
|
// still-syncing) and the owned-document set being KNOWN, because "I have no
|
|
|
|
|
// profile" is only true when both have answered. Single-shot per session; on
|
|
|
|
|
// failure the guard is released, so a later change to the owned set retries.
|
|
|
|
|
const hasTriedProfileCreate = useRef(false);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!ready) return;
|
|
|
|
|
if (hasTriedProfileCreate.current) return;
|
|
|
|
|
if (!userQuery.isSuccess) return;
|
|
|
|
|
if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none"
|
|
|
|
|
if (myOwnedProfiles.length > 0) return; // I already have one (or more)
|
|
|
|
|
hasTriedProfileCreate.current = true;
|
|
|
|
|
(async () => {
|
|
|
|
|
console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`);
|
|
|
|
|
const graph = await createEntityDoc('protected');
|
|
|
|
|
// The three fields the UserProfile shape makes mandatory, written with the
|
|
|
|
|
// "not filled in yet" placeholders — nothing here comes from the identity.
|
|
|
|
|
await writeEntity(graph, ENTITY_TYPE.user, {
|
|
|
|
|
name: str(UNSET_PROFILE.name),
|
|
|
|
|
initials: str(UNSET_PROFILE.initials),
|
|
|
|
|
username: str(UNSET_PROFILE.username),
|
|
|
|
|
});
|
|
|
|
|
// This document is MINE — claimed explicitly, so it is recognized as my
|
|
|
|
|
// profile whatever else the protected scope holds (fixtures included).
|
|
|
|
|
setMyProfileDocId(graph);
|
|
|
|
|
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]);
|
|
|
|
|
// OPTIMISTIC OVERLAY, same pattern as events/participations: surface the
|
|
|
|
|
// profile immediately so `currentUserId` resolves without waiting for the
|
|
|
|
|
// broker push; the reconciliation effect drops it once the read carries it.
|
|
|
|
|
const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE };
|
|
|
|
|
setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile]));
|
|
|
|
|
})().catch(err => {
|
|
|
|
|
hasTriedProfileCreate.current = false;
|
|
|
|
|
console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err);
|
|
|
|
|
});
|
|
|
|
|
}, [ready, userQuery.isSuccess, myOwnedProfiles]);
|
|
|
|
|
|
|
|
|
|
// --- MY PROFILE: say it when the answer is ambiguous ------------------------
|
|
|
|
|
// Several profile documents are mine and none was created by this session (the
|
|
|
|
|
// opt-in fixture seed writes its profiles into my own protected scope). There
|
|
|
|
|
// is no honest way to tell which one is the person at the keyboard, so nothing
|
|
|
|
|
// is picked — `currentUser` stays undefined and the mutations that need it
|
|
|
|
|
// refuse. Silence here would look exactly like "the read has not landed".
|
|
|
|
|
const warnedAmbiguousProfile = useRef(false);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (myProfileDocId) return;
|
|
|
|
|
if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
|
|
|
|
|
if (warnedAmbiguousProfile.current) return;
|
|
|
|
|
warnedAmbiguousProfile.current = true;
|
|
|
|
|
console.error(
|
|
|
|
|
`${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
|
|
|
|
|
`this session — which one is me cannot be told apart, so NO profile is resolved (a fixture ` +
|
|
|
|
|
`seed run on this wallet is the usual cause). Sign-up and profile edition will refuse.`,
|
|
|
|
|
);
|
|
|
|
|
}, [myOwnedProfiles, myProfileDocId]);
|
|
|
|
|
|
|
|
|
|
// --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
|
|
|
|
|
// The event OWNER's session materializes its OWN events' inbox deposits into
|
|
|
|
|
// (1) the correct `participantCount` on its OWN event doc, and
|
|
|
|
@@ -866,14 +1028,19 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
|
|
|
|
|
const uid = userId || currentUserId;
|
|
|
|
|
console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
|
|
|
|
|
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
|
|
|
|
|
// writing one without it produces an entity the ORM drops on read (the
|
|
|
|
|
// participation silently never round-trips). Refuse an empty principal rather
|
|
|
|
|
// than persist a broken participation. The caller resolves a real user id (the
|
|
|
|
|
// current user's IRI) before joining.
|
|
|
|
|
// A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) —
|
|
|
|
|
// writing one without it produces an entity the read drops (the participation
|
|
|
|
|
// silently never round-trips). REJECT rather than return: returning here made
|
|
|
|
|
// the sign-up a no-op that wrote nothing, threw nothing and let the screen
|
|
|
|
|
// congratulate the user. The cause is named, because it is actionable: no
|
|
|
|
|
// profile of mine is resolved yet.
|
|
|
|
|
if (!uid) {
|
|
|
|
|
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
|
|
|
|
|
return;
|
|
|
|
|
const msg =
|
|
|
|
|
`joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
|
|
|
|
|
`so a Participation would carry no fp:user and would never round-trip. ` +
|
|
|
|
|
`Wait for the profile to be created/read, or resolve the ambiguity reported above.`;
|
|
|
|
|
console.error(`${logPrefix} ${msg}`);
|
|
|
|
|
throw new Error(msg);
|
|
|
|
|
}
|
|
|
|
|
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
|
|
|
|
|
// The reactive participation set can lag a just-written participation, so a
|
|
|
|
@@ -921,16 +1088,24 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// The joiner's sole writes are: their OWN participation doc (above) + the
|
|
|
|
|
// inbox DEPOSIT (below). While the owner is offline the count doesn't advance
|
|
|
|
|
// for others — accepted eventual behaviour (brief §E.2); nothing is lost.
|
|
|
|
|
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
|
|
|
|
|
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
|
|
|
|
|
// when connected, anonymous (null) otherwise. Best-effort: a failed deposit
|
|
|
|
|
// must not roll back a successful registration.
|
|
|
|
|
// 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)`
|
|
|
|
|
// is what the surface publishes for reaching a document's owner — anyone
|
|
|
|
|
// may deposit, only the owner reads. The host's notification is then built
|
|
|
|
|
// on the OWNER's side, out of the deposits it reads from its own event's
|
|
|
|
|
// inbox (`readRegistrationNotifications`, in the materializer above).
|
|
|
|
|
//
|
|
|
|
|
// Nothing else is written here. A host FpNotification used to be minted at
|
|
|
|
|
// this point into the JOINER's own protected scope with `recipient` set to
|
|
|
|
|
// the event — a document the host cannot read, and never will: the joiner
|
|
|
|
|
// owns it and the surface has no way to hand it over. It also pushed that
|
|
|
|
|
// notification into THIS session's own list, so the joiner saw a
|
|
|
|
|
// "new participant" notice addressed to someone else. Both are gone; the
|
|
|
|
|
// deposit alone carries the news, and it reaches its reader.
|
|
|
|
|
//
|
|
|
|
|
// Best-effort: a failed deposit must not roll back a successful
|
|
|
|
|
// registration (the participation document is already written).
|
|
|
|
|
try {
|
|
|
|
|
const registrantId = uid || null; // no current user → anonymous deposit
|
|
|
|
|
// Recipient = the event host. The Event shape carries no host IRI yet, so
|
|
|
|
|
// we key the host inbox/notification on the eventId (the host of THAT
|
|
|
|
|
// event). This is the domain injection the generic lib deliberately omits.
|
|
|
|
|
const recipientId = eventId;
|
|
|
|
|
// The event's `@id` IS its document NURI, and a deposit NAMES that document
|
|
|
|
|
// — the joiner resolves no inbox and holds no address.
|
|
|
|
|
// Carry the joiner's participation-doc NURI so the owner (if a connection)
|
|
|
|
@@ -940,24 +1115,11 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
|
|
|
|
|
`materializing this deposit on its own doc, at its next connection)`,
|
|
|
|
|
);
|
|
|
|
|
const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
|
|
|
|
|
const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
|
|
|
|
|
// Remember the join uid so a same-session leave can cancel it precisely.
|
|
|
|
|
joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
|
|
|
|
|
const notif = buildNotification(recipientId, eventId, registrantId, ts);
|
|
|
|
|
// The host FpNotification is its OWN document in the PROTECTED scope (one
|
|
|
|
|
// doc per entity). The inbox materialization remains the source of truth;
|
|
|
|
|
// this direct write only pre-warms the reactive read — but a FAILED write is
|
|
|
|
|
// not swallowed: it used to be dropped silently, and the line below then
|
|
|
|
|
// surfaced a notification nothing had recorded. The rejection reaches the
|
|
|
|
|
// catch under this block, which names it.
|
|
|
|
|
const notifGraph = await createEntityDoc('protected');
|
|
|
|
|
await insertNotification(notifGraph, notif);
|
|
|
|
|
// Surface immediately in reactive state (materialization also refreshes it).
|
|
|
|
|
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
|
|
|
|
|
// notification id from the inbox and same-ms/anon deposits never collide.
|
|
|
|
|
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
|
|
|
|
|
console.error(`${logPrefix} joinEvent inbox deposit failed:`, err);
|
|
|
|
|
}
|
|
|
|
|
}, [events, currentUserId]);
|
|
|
|
|
|
|
|
|
@@ -968,7 +1130,22 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
|
|
|
|
|
// the subject IRI AND the graph NURI it lives in.
|
|
|
|
|
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
|
|
|
|
|
if (!part) return;
|
|
|
|
|
// REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see
|
|
|
|
|
// caveat_participation-deletion): the caller only reaches here because it
|
|
|
|
|
// believes a participation exists, so finding none is a real disagreement
|
|
|
|
|
// about the state — either no profile of mine is resolved (`uid` empty), or
|
|
|
|
|
// the participation the screen showed is not in the set. Returning silently
|
|
|
|
|
// deleted nothing while the screen announced a withdrawal, and the sign-up
|
|
|
|
|
// came back on the next read.
|
|
|
|
|
if (!part) {
|
|
|
|
|
const msg = uid
|
|
|
|
|
? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` +
|
|
|
|
|
`in the participation set — nothing was deleted, so the withdrawal must not be announced.`
|
|
|
|
|
: `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
|
|
|
|
|
`so the participation to withdraw cannot even be named.`;
|
|
|
|
|
console.error(`${logPrefix} ${msg}`);
|
|
|
|
|
throw new Error(msg);
|
|
|
|
|
}
|
|
|
|
|
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
|
|
|
|
|
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
|
|
|
|
|
// removes the Participation server-side so it does NOT resurrect after re-sync.
|
|
|
|
@@ -1054,9 +1231,19 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
|
|
|
|
|
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
|
|
|
|
|
console.log(`${logPrefix} updateProfile (NG):`, updates);
|
|
|
|
|
// The current user's profile is its own document (subject IRI = doc NURI).
|
|
|
|
|
const target = currentUser ?? users[0];
|
|
|
|
|
if (!target) return;
|
|
|
|
|
// MY profile is its own document (subject IRI = doc NURI), and it is the ONLY
|
|
|
|
|
// document these edits may land in. There used to be a `?? users[0]` fallback
|
|
|
|
|
// here: with no profile resolved, a person editing their own pseudo wrote it
|
|
|
|
|
// into a stranger's profile document. Refuse instead — and say which of the
|
|
|
|
|
// two reasons it is, since only one of them clears up on its own.
|
|
|
|
|
const target = currentUser;
|
|
|
|
|
if (!target) {
|
|
|
|
|
const msg =
|
|
|
|
|
`updateProfile refused: no profile of mine is resolved, and these edits must never land in ` +
|
|
|
|
|
`someone else's profile document.`;
|
|
|
|
|
console.error(`${logPrefix} ${msg}`);
|
|
|
|
|
throw new Error(msg);
|
|
|
|
|
}
|
|
|
|
|
const graph = target.id;
|
|
|
|
|
const persists: Promise<void>[] = [];
|
|
|
|
|
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
|
|
|
|
@@ -1073,7 +1260,10 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// seed runs (marking the guard at the START, before the awaited seed, closes
|
|
|
|
|
// the window where the auto-seed effect could also fire on a still-empty read).
|
|
|
|
|
hasTriedAutoSeed.current = true;
|
|
|
|
|
const walletHasData = events.length > 0 || users.length > 0;
|
|
|
|
|
// Same reading as the auto-seed gate: my own profile is not "data in the
|
|
|
|
|
// wallet" — it exists on every wallet from the first sign-in, so counting it
|
|
|
|
|
// would make an explicit load a permanent no-op.
|
|
|
|
|
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
|
|
|
|
|
const result = await bootstrapWallet(walletHasData, createEntityDoc);
|
|
|
|
|
// The seeded per-entity docs are appended to their scope indices, which
|
|
|
|
|
// `watchShape` subscribes → they enter the reactive reads on the push. No
|
|
|
|
@@ -1083,8 +1273,11 @@ function useNgData(): FestipodDataContextValue {
|
|
|
|
|
// them — otherwise the owner-materializer never opens their inboxes and their
|
|
|
|
|
// `participantCount` is never derived.
|
|
|
|
|
claimOwnedEventDocs(result.createdDocs.public);
|
|
|
|
|
// The seeded PROTECTED docs are deliberately NOT claimed as mine: they are
|
|
|
|
|
// FIXTURES (nobody signed in as them), and folding them into the owned-profile
|
|
|
|
|
// set is exactly what would make "which profile is mine" ambiguous.
|
|
|
|
|
return result;
|
|
|
|
|
}, [events.length, users.length, claimOwnedEventDocs]);
|
|
|
|
|
}, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentUserId, currentUser, currentPrincipal,
|
|
|
|
|