source: Klonkt/src/services/guardianship/queues.js@ 7e53594

main
Last change on this file since 7e53594 was 7e53594, checked in by Bart <bart@…>, 4 weeks ago

shaer:log: het logboek staat naast de wachtrijen, niet erin

De apps konden er niet bij. Het logboek zat achter /guardian/api/events, een
PWA-route, dus een ward kon niet lezen waarom haar eigen aanbod geweigerd was --
en dat is precies de helft die §4.2 eist.

Eigen sleutel op de actor, GET /ap/users/:slug/log, dezelfde eigenaar-only
bearer als de wachtrijen. Bewust niet onder shaer:queues: alles daar wacht op
een antwoord en dit is wat er al besloten is. Geschiedenis onderbrengen bij een
woord dat "wachtend" betekent maakt van twee dingen één, en dat is de fout die
deze module vandaag drie keer heeft opgeruimd.

type is 'shaer:Event' en geen AS2-werkwoord. De meeste soorten zijn er geen: een
lapse-stem of een opgepakte hulpvraag is geen Accept, en het zo noemen zou
netter lezen dan het is.

Clients vinden hem via het actor-document, niet via een pad dat ze zelf
verzinnen (shaer-qa0). De AS2-test viel netjes om tot shaer:log was aangemeld --
tweede keer vandaag dat die het vangt.

De daemon volgt (shaer-6d9): zolang die geen shaer:log serveert, liegt een
client die tegen beide praat over een van de twee.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 14.4 KB
Line 
1/**
2 * Guardianship (FEP-633c) — the owner-only dashboard queues.
3 *
4 * Three OrderedCollections on the actor (shaer:queues), same contract as the
5 * Shaer test daemon so the iOS/Android dashboards read them as-is:
6 * - offers: pending handshake offers where I am a party (§3), with the full
7 * accept tally so the client shows the right action
8 * - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
9 * - wards: my committed wards
10 */
11import * as offers from './offers.js';
12import * as relations from './relations.js';
13import * as availability from './availability.js';
14import * as outgoing from './outgoing.js';
15import * as follows from './follows.js';
16import * as gated from './gated.js';
17import * as gatereq from './gatereq.js';
18import * as help from './help.js';
19import db from '../../config/database.js';
20import * as handshake from './handshake.js';
21
22const collection = (id, items) => ({
23 id, type: 'OrderedCollection', totalItems: items.length, orderedItems: items,
24});
25
26/** Pending offers where the local site is a party, each with its accept
27 * tally. The same collection carries the running lapses (§3.6.3) this
28 * account is a party to, exactly as the daemon serves them, so the Shaer
29 * clients render both without a second fetch. */
30export function offersCollection(id, slug, me) {
31 // §4.2: a handshake whose candidate could not be dereferenced is deferred,
32 // not decided, and the last Accept may already have landed — so nothing else
33 // would ever retry it. This poll is the schedule. Not awaited: the read
34 // answers with what is true now, and a retry that succeeds surfaces in the
35 // next one. `listForParty` settles closed windows on the way past.
36 handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ });
37 const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
38 items.push(...availability.lapseQueueItems(slug, me, Date.now()));
39 return collection(id, items);
40}
41
42/**
43 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
44 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
45 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
46 *
47 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
48 * op deze:
49 * - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
50 * - ap_pending_follows: een ward op deze instance
51 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
52 */
53export function followsCollection(id, slug, me) {
54 const items = follows.listReviewsByDirection(slug, 'incoming')
55 .map((r) => follows.reviewQueueItem(r, me));
56 for (const w of relations.listWards(slug)) {
57 const wardSlug = slugOf(w.other_uri);
58 if (!wardSlug) continue;
59 for (const p of follows.listForWard(wardSlug)) {
60 items.push({
61 id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
62 'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
63 'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
64 'shaer:quorum': p.quorum || 'any', published: p.created_at,
65 });
66 }
67 }
68 return collection(id, items);
69}
70
71/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
72function slugOf(uri) {
73 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
74 if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
75 return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
76}
77
78/**
79 * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
80 * guardian wederzijds uitsluit: je bent het een of het ander.
81 *
82 * ALS WARD wat IK wil volgen en waar mijn guardians nog over moeten
83 * ALS GUARDIAN wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
84 *
85 * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
86 * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
87 * follow" kon nooit iets tonen.
88 */
89export function outgoingFollowsCollection(id, slug, me) {
90 const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
91 for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
92 items.push(follows.reviewQueueItem(r, me));
93 }
94 return collection(id, items);
95}
96
97/** The guardian's committed wards, with cached handle for display. */
98export function wardsCollection(id, slug) {
99 const items = relations.listWards(slug)
100 .map((r) => ({
101 id: r.other_uri,
102 'shaer:handle': r.other_handle || undefined,
103 since: r.created_at,
104 // Alles wat voor dit kind gated is, met soort, drempel en lopend voorstel
105 // (shaer-ahy.1). Zonder dit kon een app wel een ward TONEN maar niets over
106 // hem zeggen -- en dat is precies de helft van het antwoord op "wat mag
107 // dit kind". Dezelfde rijen als het PWA-paneel, uit dezelfde functie.
108 'shaer:gates': wardGates(slug, r.other_uri),
109 }));
110 return collection(id, items);
111}
112
113/** The ward's guardians with their availability (§3.6.1: never public,
114 * owner-only): the real size of the safety net. Same shape as the daemon. */
115export function guardiansCollection(id, slug) {
116 const uris = relations.listGuardians(slug).map((r) => r.other_uri);
117 return collection(id, availability.statusesFor(slug, uris, Date.now()));
118}
119
120/**
121 * Het logboek (§4.2): wat er is besloten, en waarom.
122 *
123 * Geen wachtrij, en daarom een eigen sleutel op de actor. Elk item draagt zijn
124 * soort en, als die er was, de REDEN -- want zonder die reden merkte een ward
125 * een weigering alleen doordat er iets uit een lijst verdween.
126 *
127 * `type` is geen AS2-werkwoord: de meeste soorten zijn er geen. Een lapse-stem
128 * of een opgepakte hulpvraag is geen Accept, en het zo noemen zou netter lezen
129 * dan het is.
130 *
131 * De lezer levert `listEvents` aan; deze module kent ActivityPubService niet en
132 * houdt dat zo (zie de kop van delivery.js).
133 */
134export function logCollection(id, slug, listEvents) {
135 const items = (typeof listEvents === 'function' ? listEvents(slug) : []).map((e) => {
136 const { id: n, kind, created, ...rest } = e;
137 const item = { id: `${id}/${n}`, type: 'shaer:Event', 'shaer:kind': kind, published: created };
138 for (const [k, v] of Object.entries(rest)) {
139 if (v === undefined || v === null) continue;
140 item[k.startsWith('shaer:') ? k : `shaer:${k}`] = v;
141 }
142 return item;
143 });
144 return collection(id, items);
145}
146
147export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection, helpCollection, helpItemsFor, wardGates, wardGuardianStatuses, logCollection };
148
149// ── Wat er voor een ward gated is (shaer-ahy.1) ─────────────────────────
150//
151// STOND IN routes/guardian.js, en daar kon alleen de PWA erbij. De Shaer-apps
152// lezen dezelfde toestand via de wards-queue, en een tweede berekening naast
153// deze zou vroeg of laat een ander antwoord geven op dezelfde vraag -- dat is
154// hier geen schoonheidsfoutje maar twee guardians die een verschillend beeld
155// van hetzelfde kind krijgen. Een plek dus, en beide schermen lezen eruit.
156/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
157 * spirit; the co-guardians are among the owners of the relationship). Null
158 * for a remote ward: its server tracks availability, not us. */
159export function wardGuardianStatuses(wardUri) {
160 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
161 if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
162 const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
163 try {
164 const uris = relations.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
165 const st = Object.fromEntries(
166 availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
167 );
168 return uris.map((u) => ({
169 uri: u.uri,
170 handle: u.handle,
171 availability: (st[u.uri] || {})['shaer:availability'] || 'active',
172 awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
173 lapse: (st[u.uri] || {})['shaer:lapse'] || null,
174 }));
175 } catch { return null; }
176}
177/**
178 * De gate-rijen van een ward voor het paneel.
179 *
180 * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
181 * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
182 * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
183 * getal wordt er geen drempel verzonnen.
184 */
185export function wardGates(mySlug, wardUri) {
186 const statuses = wardGuardianStatuses(wardUri);
187 // Per richting geteld, want het zijn twee zorgen. "follows: 3 wachtend" liet
188 // een guardian niet zien of er drie vreemden bij zijn kind willen of dat zijn
189 // kind drie keer heeft gevraagd of het iemand mag volgen (shaer-p729).
190 const wachtendIn = follows.listReviewsByDirection(mySlug, 'incoming')
191 .filter((r) => r.ward_uri === wardUri).length;
192 const wachtendUit = follows.listReviewsByDirection(mySlug, 'outgoing')
193 .filter((r) => r.ward_uri === wardUri).length;
194 return gated.gateRows({
195 // Uit de BESLUITEN, niet uit onze eigen kolom. Er zijn geen lokale accounts:
196 // elke ward woont elders, dus wardEmbedSetting() gaf voor iedere ward null en
197 // stond er in het paneel overal "onbekend". Wat een guardian wel heeft is de
198 // uitslag van wat hij voorstelde.
199 settings: Object.fromEntries(gated.GATE_CATALOGUE
200 .filter((g) => g.available !== false && gated.featureColumn(g.feature))
201 .map((g) => [g.feature, gated.knownSetting(mySlug, wardUri, g.feature)])),
202 guardianCount: statuses ? statuses.length : null,
203 proposals: gated.listSent(mySlug, wardUri).map((p) => ({
204 feature: p.feature, value: !!p.value, status: gated.sentStatus(p, Date.now()),
205 })),
206 waiting: {
207 'shaer:follows': wachtendIn || undefined,
208 'shaer:following': wachtendUit || undefined,
209 },
210 // De vraag van het kind zelf staat APART van wat er in een wachtrij staat
211 // (shaer-8ru). Allebei "n waiting" noemen maakt van twee verschillende
212 // dingen een getal: drie onbekenden die je kind willen volgen is iets heel
213 // anders dan je kind dat een keer vraagt of muziek aan mag. Wel bij de poort
214 // waar het over gaat, want een aparte lijst vergeet je.
215 requested: gatereq.waitingFor(mySlug, wardUri),
216 });
217}
218
219
220// ── Hulpvragen met hun staat (shaer-lgo, shaer-ahy.1) ───────────────────
221//
222// De PWA had dit al; de apps kregen alleen de losse notes uit de feed en wisten
223// dus NIET of er al iemand op af was. Daarom bleef een afgehandeld verzoek daar
224// gewoon staan -- Barts melding. De staat wordt hier een keer berekend, zoals bij
225// wardGates: twee berekeningen zouden twee guardians een ander beeld geven van
226// hetzelfde kind.
227
228/**
229 * De hulpvragen van deze guardian, met wie erop af is en of het dicht is.
230 *
231 * OPEN VRAGEN WORDEN NOOIT AFGEKAPT, en dat is geen ruimhartigheid maar de reden
232 * dat de app iets mag CONCLUDEREN uit afwezigheid (Barts punt, 8-8).
233 *
234 * Dit stond op 50, en ik noemde 'tientallen hulpvragen bij een guardian' een
235 * randgeval. Bart wees op de jeugdzorgmedewerker: die heeft geen handvol wards
236 * maar een caseload, en voor hem is dat een gewone dinsdag. De gebruiker die dit
237 * het hardst nodig heeft was precies degene voor wie het brak.
238 *
239 * Met een afkap op alles zag een app een oudere vraag niet in de queue, vond geen
240 * staat, en toonde hem -- terecht, want bij twijfel OPEN -- als openstaand. Een
241 * allang afgehandelde hulpvraag die weer om aandacht vraagt. Nu geldt: staat hij
242 * niet in de queue, dan is hij NIET open. Die gevolgtrekking klopt alleen zolang
243 * we open vragen volledig leveren.
244 *
245 * De geschiedenis mag wel afgekapt: die vraagt niets, en wat eraf valt is nog
246 * steeds op de server te vinden.
247 */
248export function helpItemsFor(slug, historyLimit = 50) {
249 let rijen = [];
250 try {
251 // Alle velden die een kaart kan tonen, niet alleen die van de queue: de
252 // PWA had hierom een EIGEN kopie van deze query -- mét een afkap op 50,
253 // waardoor de fix hierboven aan het paneel voorbijging (Barts 429-jacht,
254 // 9-8). Een tweede weg naar dezelfde staat is precies wat er bij de
255 // reply-gate al misging; nu is dit de enige weg, en dan hoort hij ook te
256 // dragen wat een kaart nodig heeft. De extra kolommen kosten de queue
257 // niets: die leest ze gewoon niet.
258 rijen = db.prepare(
259 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
260 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
261 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC`,
262 ).all(slug);
263 } catch { return []; }
264 const staat = help.statusFor(rijen.map((r) => r.object_uri));
265 const mijn = new Set(relations.listWards(slug).map((w) => w.other_uri));
266 const alles = rijen.map((r) => ({
267 ...r,
268 // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
269 // niet is, is de gevaarlijke fout -- niet andersom.
270 state: help.withWardship(
271 staat.get(r.object_uri) || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null },
272 mijn.has(r.actor_uri),
273 ),
274 }));
275 const open = alles.filter((h) => h.state.open);
276 const rest = alles.filter((h) => !h.state.open).slice(0, historyLimit);
277 return [...open, ...rest];
278}
279
280/** Dezelfde vragen als collectie voor de apps (5.2.1). */
281export function helpCollection(id, slug) {
282 const items = helpItemsFor(slug).map((h) => ({
283 id: h.object_uri,
284 type: 'Note',
285 attributedTo: h.actor_uri,
286 'shaer:handle': h.actor_handle || undefined,
287 content: h.content || '',
288 published: h.published || h.created_at,
289 'shaer:helpRequest': true,
290 // De staat als platte velden: een app hoeft hem niet af te leiden, en kan
291 // hem dus ook niet anders afleiden dan het paneel.
292 'shaer:open': h.state.open,
293 'shaer:handledBy': h.state.handled ? (h.state.handled.handle || h.state.handled.uri) : undefined,
294 'shaer:handledAt': h.state.handled ? h.state.handled.at : undefined,
295 'shaer:pickedUpBy': h.state.pickedUpBy.map((p) => p.handle || p.uri),
296 'shaer:formerWard': h.state.formerWard || undefined,
297 }));
298 const coll = collection(id, items);
299 // Het teken dat de app mag concluderen uit afwezigheid: elke OPEN vraag zit
300 // hierin. Ontbreekt deze vlag (een oudere server), dan valt de app terug op
301 // bij-twijfel-open, en dat is de veilige kant.
302 coll['shaer:openComplete'] = true;
303 return coll;
304}
Note: See TracBrowser for help on using the repository browser.