diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 9f1e1f2..158019d 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -246,7 +246,7 @@ function useNgData(): FestipodDataContextValue { const { username } = useAccount(); // The selected backend's binding for events — read here, so a switch re-renders // with the new one instead of keeping whichever was current at import time. - const eventBinding = useBackend().event; + const eventBinding = useBackend().bindings.event; // Mono-store fallback scope: the shared wallet's private store NURI. Opens the // repo in the verifier (enables reads + writes), per the data-layer rule. const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined; diff --git a/src/shared/refine/RefineProvider.tsx b/src/shared/refine/RefineProvider.tsx index 1715cde..0dc8270 100644 --- a/src/shared/refine/RefineProvider.tsx +++ b/src/shared/refine/RefineProvider.tsx @@ -24,7 +24,7 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; import { Refine } from '@refinedev/core'; -import { getBackend, initialBackendName, type RefineBackend } from './providers'; +import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers'; interface BackendSelection { backend: RefineBackend; @@ -81,7 +81,7 @@ export function RefineProvider({ children }: { children: ReactNode }) { dataProvider={providers.dataProvider} liveProvider={providers.liveProvider} authProvider={providers.authProvider} - resources={backend.resources.map((resource) => ({ name: resource }))} + resources={resourceNames(backend).map((resource) => ({ name: resource }))} options={{ // `auto` is what makes the live provider actually subscribe behind every // query — without it the ORM pushes changes and no hook is listening. diff --git a/src/shared/refine/providers/index.ts b/src/shared/refine/providers/index.ts index 65b9d61..09449c5 100644 --- a/src/shared/refine/providers/index.ts +++ b/src/shared/refine/providers/index.ts @@ -32,7 +32,8 @@ import { nextgraphBackend } from './nextgraph'; import type { RefineBackend } from './types'; -export type { RecordBinding, RefineBackend } from './types'; +export type { RecordBinding, RecordBindings, RefineBackend } from './types'; +export { resourceNames } from './types'; const BACKENDS: Record = { [nextgraphBackend.name]: nextgraphBackend, diff --git a/src/shared/refine/providers/nextgraph.ts b/src/shared/refine/providers/nextgraph.ts index de5f32a..bbf721b 100644 --- a/src/shared/refine/providers/nextgraph.ts +++ b/src/shared/refine/providers/nextgraph.ts @@ -9,24 +9,51 @@ import { dataProvider, liveProvider } from '@ng-org/refine-providers'; import type { BaseKey } from '@refinedev/core'; -import type { FpEventData } from '../../data/types'; -import type { FpEvent } from '../../shapes/orm/festipodShapes.typings'; -import shapeTypes, { RESOURCE_EVENTS } from '../shapeTypes'; +import type { FpEventData, FpParticipationData, FpUserData } from '../../data/types'; +import type { FpEvent, FpParticipation, FpUserProfile } from '../../shapes/orm/festipodShapes.typings'; +import shapeTypes, { + RESOURCE_EVENTS, + RESOURCE_PARTICIPATIONS, + RESOURCE_PROFILES, +} from '../shapeTypes'; import { createNgAuthProvider } from '../ngAuthProvider'; import type { RecordBinding, RefineBackend } from './types'; -/** The RDF class Festipod events are persisted as. */ -const EVENT_TYPE = 'http://festipod.org/Event'; +/** + * The RDF classes Festipod persists. These IRIs ARE the stored data — renaming + * one renames what is on disk, so they are never adjusted for convenience. + */ +const TYPE = { + event: 'http://festipod.org/Event', + profile: 'http://festipod.org/UserProfile', + participation: 'http://festipod.org/Participation', +} as const; + +/** + * Shared by every binding here: this provider addresses a record by the DOCUMENT + * NURI, while the app's entities reference each other by the SUBJECT IRI. One + * document per record keeps them 1-1, but they are different strings and `update` + * only accepts the first. + */ +const recordKey = (record: unknown): BaseKey => + (record as { '@graph': string })['@graph'] as BaseKey; +const domainId = (record: unknown): string => (record as { '@id': string })['@id']; + +/** This provider's `update` deletes the old object and adds one built from values. */ +const replaces = { + updateReplaces: true as const, + toPatch: (): never => { + throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch'); + }, +}; const eventBinding: RecordBinding = { - // Refine addresses a record by its DOCUMENT NURI. `create` makes one document - // per record, so this is 1-1 with the subject IRI below — but it is a different - // string, and `update` only accepts this one. - recordKey: (record) => (record as FpEvent)['@graph'] as BaseKey, - - // What the rest of the app references: a participation's `fp:event` holds the - // SUBJECT IRI, so that is the id `FpEventData` must carry. - domainId: (record) => (record as FpEvent)['@id'], + resource: RESOURCE_EVENTS, + // Events are what other users must find — see concept `functional-domain`. + visibility: 'public', + recordKey, + domainId, + ...replaces, toDomain: (record) => { const e = record as FpEvent; @@ -49,7 +76,7 @@ const eventBinding: RecordBinding = { // the caller's values AFTER it, so passing `@type` is what makes the record a // Festipod Event. Drop it and the shape stops matching on read. toValues: (data) => ({ - '@type': EVENT_TYPE, + '@type': TYPE.event, title: data.title, description: data.description, date: data.date, @@ -60,22 +87,73 @@ const eventBinding: RecordBinding = { hostName: data.hostName, hostInitials: data.hostInitials, }), +}; - // Measured: `update` deletes the old object and adds one built from `values`. - updateReplaces: true, +const profileBinding: RecordBinding = { + resource: RESOURCE_PROFILES, + // A profile is readable by one's network, not by everyone. + visibility: 'protected', + recordKey, + domainId, + ...replaces, - // Never reached while `updateReplaces` is true. It exists so a patching backend - // has somewhere to put its answer, and it fails loudly rather than sending a - // partial record to a provider that would drop everything else. - toPatch: () => { - throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch'); + toDomain: (record) => { + const u = record as FpUserProfile; + return { + id: u['@id'], + name: u.name, + initials: u.initials, + username: u.username, + role: u.role, + isPublic: u.isPublic, + }; }, + + toValues: (data) => ({ + '@type': TYPE.profile, + name: data.name, + initials: data.initials, + username: data.username, + role: data.role, + isPublic: data.isPublic, + }), +}; + +const participationBinding: RecordBinding = { + resource: RESOURCE_PARTICIPATIONS, + visibility: 'protected', + recordKey, + domainId, + ...replaces, + + // `fp:event` and `fp:user` hold SUBJECT IRIs, which is why `domainId` above is + // the subject and not the document: these references resolve against it. + toDomain: (record) => { + const p = record as FpParticipation; + return { + id: p['@id'], + eventId: p.event, + userId: p.user, + isConfirmed: p.isConfirmed, + }; + }, + + toValues: (data) => ({ + '@type': TYPE.participation, + event: data.eventId, + user: data.userId, + isConfirmed: data.isConfirmed, + }), }; export const nextgraphBackend: RefineBackend = { name: 'nextgraph', - resources: [RESOURCE_EVENTS], - event: eventBinding, + bindings: { + event: eventBinding, + profile: profileBinding, + participation: participationBinding, + // meetingPoint: pending its SHEX shape — see types.ts. + }, build: () => { // Festipod's own AuthProvider, not the package's — see ngAuthProvider for why // a second `init()` on one WASM session is a collision, not a convenience. diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts index 4e4611f..4f8ec66 100644 --- a/src/shared/refine/providers/types.ts +++ b/src/shared/refine/providers/types.ts @@ -26,7 +26,12 @@ */ import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core'; -import type { FpEventData } from '../../data/types'; +import type { + FpEventData, + FpMeetingPointData, + FpParticipationData, + FpUserData, +} from '../../data/types'; /** How one entity's records translate to and from the app's domain type. */ export interface RecordBinding { @@ -52,6 +57,41 @@ export interface RecordBinding { toPatch(updates: Partial): Record; /** True when `update` replaces the record rather than patching it. */ readonly updateReplaces: boolean; + /** + * Which Refine resource these records live under. The NAME is the backend's, + * not the app's: `@ng-org` keys `shapeTypes` by it, `@activitypods` keys + * `resources` by it, and nothing says the two must agree on a spelling. + */ + readonly resource: string; + /** + * WHO MUST BE ABLE TO READ these records. Festipod's confidentiality model is + * per-entity (concept `functional-domain`), and each backend enforces it its own + * way — NextGraph by placing a document in a store, ActivityPods by shape trees + * and ACLs. Refine itself has NO notion of it, so the intent is declared here + * and the backend is what honours it. + * + * Naming it does not make it enforced: the NextGraph data provider currently + * reads across every graph it can see, so this records the INTENT and marks + * where a backend has to close the gap. + */ + readonly visibility: 'public' | 'protected' | 'private'; +} + +/** + * The entities the app binds. One key per domain type it persists. + * + * `meetingPoint` is OPTIONAL only because its SHEX shape does not exist on this + * branch yet — adding it means regenerating the ORM bindings, which is its own + * step with its own failure mode (the generator drops the `Fp` prefix, since the + * emitted name follows the shape IRI). Once that lands it becomes required like + * the others. `Friendship` is deliberately absent: it has no shape and no + * persistence anywhere in this project. + */ +export interface RecordBindings { + event: RecordBinding; + profile: RecordBinding; + participation: RecordBinding; + meetingPoint?: RecordBinding; } /** Everything Festipod needs from one Refine backend. */ @@ -67,8 +107,11 @@ export interface RefineBackend { authProvider?: AuthProvider; liveProvider?: LiveProvider; }; - /** Resource names this backend serves, for ``. */ - readonly resources: readonly string[]; - /** The Events vertical is the only one wired so far. */ - readonly event: RecordBinding; + /** One binding per entity the app persists. */ + readonly bindings: RecordBindings; +} + +/** Every resource name a backend serves, derived from its bindings. */ +export function resourceNames(backend: RefineBackend): string[] { + return [...new Set(Object.values(backend.bindings).map((b) => b.resource))]; } diff --git a/src/shared/refine/shapeTypes.ts b/src/shared/refine/shapeTypes.ts index 1263928..5456e8f 100644 --- a/src/shared/refine/shapeTypes.ts +++ b/src/shared/refine/shapeTypes.ts @@ -5,21 +5,30 @@ * `@ng-org/refine-providers` identifies data by shape type, not by endpoint: its * data provider looks `shapeTypes[resource]` up on every call, so a resource name * that is absent here silently resolves to `undefined` and the read comes back - * empty. Every `useList` / `useOne` / `useCreate` / `useUpdate` call site must - * therefore name a resource declared below. + * empty — no error, just nothing. Every `useList` / `useOne` / `useCreate` / + * `useUpdate` call site must therefore name a resource declared below. * - * Only `events` is wired: the Events vertical is what goes through Refine — - * profiles and participations still read through `useShapeWithDefaults`. + * These names are the NextGraph backend's, which is why this file lives beside it + * rather than in the app: another backend keys its own configuration by its own + * names, and nothing says the two must agree on a spelling. */ import type { ShapeTypes } from '@ng-org/refine-providers'; -import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes'; +import { + FpEventShapeType, + FpParticipationShapeType, + FpUserProfileShapeType, +} from '../shapes/orm/festipodShapes.shapeTypes'; -/** Refine resource name for Festipod events. Import it — never retype the string. */ +/** Resource names. Import them — never retype the strings. */ export const RESOURCE_EVENTS = 'events'; +export const RESOURCE_PROFILES = 'profiles'; +export const RESOURCE_PARTICIPATIONS = 'participations'; const shapeTypes: ShapeTypes = { [RESOURCE_EVENTS]: FpEventShapeType, + [RESOURCE_PROFILES]: FpUserProfileShapeType, + [RESOURCE_PARTICIPATIONS]: FpParticipationShapeType, }; export default shapeTypes;