source: Klonkt/src/services/guardianship/queues.js@ 3bbf73d

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

De ouderdom van een oppak haalde de apps niet (shaer-lgo)

Barts besluit was dat "opgepikt" niet vervalt maar zichtbaar VEROUDERT -- het
verschil tussen "er is iemand mee bezig" en "er was ooit iemand mee bezig". Het
guardian-paneel toont dat ook, met een regel zodra het oudste oppakken langer
dan een uur geleden is.

Maar helpCollection gooide het veld weg. helpStatus() rekent oldestPickupAt
netjes uit; de C2S-wachtrij serialiseerde alleen open, handledBy, handledAt,
pickedUpBy en formerWard. In Shaer zag een oppak van vijf minuten er dus uit als
een van vijf dagen -- en de faalstand is hier nou juist "iedereen denkt dat het
geregeld is".

Als TIJDSTIP en niet als leeftijd, om dezelfde reden die in guardian.js staat:
een leeftijd maakt elk antwoord anders en dan kan de ETag nooit gelijk zijn. De
client rekent zelf terug.

En weg zodra hij is afgehandeld: hoe lang er iemand naar keek is dan geen
informatie meer, en zou als "er wacht nog iets" lezen.

Gecontroleerd dat de test bijt: veld eruit -> 7 groen 1 rood, terug -> 8 groen.

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

  • Property mode set to 100644
File size: 15.2 KB
RevLine 
[6b5d7da]1/**
2 * Guardianship (FEP-633c) — the owner-only dashboard queues.
3 *
4 * Three OrderedCollections on the actor (shaer:queues), same contract as the
[780a7c6]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
[1e172f3]8 * - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
[780a7c6]9 * - wards: my committed wards
[6b5d7da]10 */
[780a7c6]11import * as offers from './offers.js';
[6b5d7da]12import * as relations from './relations.js';
[6eab7e9]13import * as availability from './availability.js';
[fa33214]14import * as outgoing from './outgoing.js';
[1e172f3]15import * as follows from './follows.js';
[f3a2556]16import * as gated from './gated.js';
[2bfe26c]17import * as gatereq from './gatereq.js';
[86e6a45]18import * as help from './help.js';
19import db from '../../config/database.js';
[30d0e2c]20import * as handshake from './handshake.js';
[6b5d7da]21
22const collection = (id, items) => ({
23 id, type: 'OrderedCollection', totalItems: items.length, orderedItems: items,
24});
25
[6eab7e9]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. */
[6b5d7da]30export function offersCollection(id, slug, me) {
[30d0e2c]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 */ });
[780a7c6]37 const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
[6eab7e9]38 items.push(...availability.lapseQueueItems(slug, me, Date.now()));
[6b5d7da]39 return collection(id, items);
40}
41
[1e172f3]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;
[6b5d7da]76}
77
[1e172f3]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 */
[fa33214]89export function outgoingFollowsCollection(id, slug, me) {
[1e172f3]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);
[fa33214]95}
96
[780a7c6]97/** The guardian's committed wards, with cached handle for display. */
[6b5d7da]98export function wardsCollection(id, slug) {
99 const items = relations.listWards(slug)
[f3a2556]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 }));
[6b5d7da]110 return collection(id, items);
111}
112
[6eab7e9]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
[7e53594]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 };
[f3a2556]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);
[709dc6f]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')
[f3a2556]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 })),
[709dc6f]206 waiting: {
207 'shaer:follows': wachtendIn || undefined,
208 'shaer:following': wachtendUit || undefined,
209 },
[0b0cd54]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),
[f3a2556]216 });
217}
[86e6a45]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
[a9ede28]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) {
[86e6a45]249 let rijen = [];
250 try {
[6259305]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.
[86e6a45]258 rijen = db.prepare(
[6259305]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
[a9ede28]261 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC`,
262 ).all(slug);
[86e6a45]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));
[a9ede28]266 const alles = rijen.map((r) => ({
[86e6a45]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(
[e667d26]271 staat.get(r.object_uri) || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null },
[86e6a45]272 mijn.has(r.actor_uri),
273 ),
274 }));
[a9ede28]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];
[86e6a45]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),
[c34ef26]296 // HOE OUD het oudste oppakken is (shaer-lgo). Barts besluit was dat
297 // "opgepikt" niet vervalt maar zichtbaar VEROUDERT -- het verschil tussen
298 // "er is iemand mee bezig" en "er was ooit iemand mee bezig". Het paneel
299 // toonde dat al; de apps konden het niet, want dit veld bleef hier liggen.
300 // Een oppak van vijf minuten zag er daar uit als een van vijf dagen, en de
301 // faalstand is hier nou juist "iedereen denkt dat het geregeld is".
302 //
303 // Als TIJDSTIP en niet als leeftijd: een leeftijd maakt elk antwoord anders
304 // en dan kan de ETag nooit gelijk zijn. De client rekent zelf terug, precies
305 // zoals guardian.js het doet.
306 'shaer:oldestPickupAt': h.state.oldestPickupAt || undefined,
[86e6a45]307 'shaer:formerWard': h.state.formerWard || undefined,
308 }));
[a9ede28]309 const coll = collection(id, items);
310 // Het teken dat de app mag concluderen uit afwezigheid: elke OPEN vraag zit
311 // hierin. Ontbreekt deze vlag (een oudere server), dan valt de app terug op
312 // bij-twijfel-open, en dat is de veilige kant.
313 coll['shaer:openComplete'] = true;
314 return coll;
[86e6a45]315}
Note: See TracBrowser for help on using the repository browser.