source: Klonkt/src/services/guardianship/queues.js@ 783b9ff

main
Last change on this file since 783b9ff was 783b9ff, checked in by Robin <roboburr@…>, 4 weeks ago

De wachtrijen hadden hun eigen paginering-leugen (shaer-sk4)

guardianship/queues.js had een EIGEN collection()-helper met precies de vorm die
gisteren uit ap-core verdween: first en last allebei op ?page=1, alles inline,
nooit gesneden. Een tweede spelling van dezelfde zaak loopt vanzelf uit elkaar,
en dat was hier al gebeurd -- de ene kant kreeg echte paginering, de andere niet.

Nu dezelfde bouwer, maar ZONDER @context: de route zet die erop en twee keer
maakt het document ongeldig. Dat staat vastgelegd in ap-outbox-paging.test.js,
en die test ving het ook meteen toen ik het hier vergat -- precies waarvoor hij
er staat.

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

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