source: Klonkt/src/services/guardianship/gated.js@ ffb371c

main
Last change on this file since ffb371c was ffb371c, checked in by roboburr <roboburr@…>, 5 weeks ago

Antwoorden is ook iets: een eigen poort (shaer-r4c)

Bart, 8-8: "Ook meedoen aan een gesprek valt onder een gate, maak die en maak die
zichtbaar."

HIER STOND EEN AANNAME, EN DIE WAS VAN MIJ. compose liet een antwoord bewust
door, met als reden "een antwoord is meedoen aan een gesprek, geen eigen podium".
Dat is een ontwerpkeuze die ik als vanzelfsprekend had opgeschreven, en Bart
draait hem terug: meedoen aan een gesprek is ook iets waar guardians over gaan.

EEN EIGEN POORT, NIET ONDER COMPOSE. Los in beide richtingen, en daar staan
toetsen op: je kunt willen dat een kind meepraat zonder eigen podium, en ook
precies andersom. Zou compose antwoorden meesluiten, dan is de nieuwe rij in het
paneel een knop die niets doet.

DE DEUR NAAST DE POORT. De innamepoort in ingestOutboxActivity dekt alleen C2S --
Shaer. routes/posts.js roept deliverReply op drie plekken RECHTSTREEKS aan, dus
de eigen webinterface van Klonkt loopt er nooit langs. Een poort die alleen in de
app staat is geen poort. De echte controle staat nu in deliverReply zelf, het
knooppunt dat beide paden delen; de outbox houdt zijn eigen check zodat de app
een nette 403 gated_replies krijgt in plaats van een stille null.

De boei komt daar niet langs en dat is geen toeval: een hulpvraag is altijd
direct en loopt via deliverDirectNote. Er is dus geen uitzondering nodig om hem
open te houden -- maar er staat wel een toets op, want dit is het gevaarlijkste
dat deze poort kan doen: een kind dat om hulp vraagt in het draadje waar het
misgaat.

Een direct antwoord wordt door allebei de poorten gedekt (messages en replies).
Een prive-antwoord is allebei, en dan mag allebei hem tegenhouden.

Paneel en shaer:capabilities krijgen hem gratis uit de catalogus. Labels in
nl/en/de. Zes toetsen, drie mutaties gecontroleerd. Suite 646/646.

  • Property mode set to 100644
File size: 21.8 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
3 *
4 * The point of this file is that it works when the guardians are NOT on the
5 * ward's server, which is the ordinary case: a child on the family instance, a
6 * grandparent on theirs. A guardian proposes with an `Offer` of a
7 * `shaer:GatedSetting` addressed to the ward's server; the other guardians
8 * answer; the ward's server tallies and enforces, because it is the one that
9 * serves the feed.
10 *
11 * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
12 * majority), a window. A setting is reversible (a permission granted can be
13 * withdrawn), so it settles as a race to the threshold and fails closed.
14 */
15import db from '../../config/database.js';
16import { listGuardians } from './relations.js';
17import * as availability from './availability.js';
18
19/** The window a gated-setting decision stays open. Reversible, so a day. */
20export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
21
22/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
23export function thresholdFor(setSize) {
24 return Math.floor(setSize / 2) + 1;
25}
26
27/**
28 * Tally one decision. Pure, so the rule can be tested without a database.
29 *
30 * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
31 * @param {string[]} guardianSet the guardians at the moment the decision opened
32 * @param {number} ageMs how long the decision has been open
33 * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
34 */
35export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
36 const set = new Set((guardianSet || []).filter(Boolean));
37 if (!set.size) return { state: 'expired' }; // nobody may decide
38 const need = thresholdFor(set.size);
39 // Only answers from the snapshotted set count, one per guardian.
40 const seen = new Map();
41 for (const v of (votes || [])) {
42 if (!set.has(v.guardian_uri)) continue;
43 seen.set(v.guardian_uri, v.value === true || v.value === 1);
44 }
45 const yes = [...seen.values()].filter(Boolean).length;
46 const no = seen.size - yes;
47 // Race to the threshold, in both directions: settle the moment it is reached,
48 // and give up the moment it can no longer be reached.
49 if (yes >= need) return { state: 'settled', value: true };
50 if (no >= need) return { state: 'settled', value: false };
51 const undecided = set.size - seen.size;
52 if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
53 if (ageMs >= windowMs) return { state: 'expired' }; // fails closed
54 return { state: 'open' };
55}
56
57/** The column a feature maps onto. Unknown features are refused, not guessed. */
58const FEATURES = {
59 'shaer:externalEmbeds': 'external_embeds',
60 'shaer:externalPlayback': 'external_playback',
61 'shaer:externalThreads': 'external_threads',
62 'shaer:images': 'gate_images',
63 'shaer:messages': 'gate_messages',
64 'shaer:compose': 'gate_compose',
65 'shaer:replies': 'gate_replies',
66 'shaer:music': 'gate_music',
67 'shaer:quoteCards': 'gate_quote_cards',
68 'shaer:customEmoji': 'gate_custom_emoji',
69 'shaer:accountMove': 'gate_account_move',
70};
71/**
72 * De gates die deze Klonkt kent, met hun SOORT.
73 *
74 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
75 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
76 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
77 * te zijn en geen nieuw stuk scherm.
78 *
79 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
80 * een guardian niet hoeven raden:
81 *
82 * setting een stand, aan of uit, terug te draaien
83 * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
84 * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
85 *
86 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
87 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
88 */
89export const GATE_CATALOGUE = [
90 // Werkend: er is een kolom, de tally kan erover beslissen en de server dwingt
91 // hem af bij het serveren (of, voor compose/messages/move, bij het INNEMEN:
92 // wat de ward niet mag versturen wordt aan de outbox geweigerd).
93 { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
94 { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
95 // Sinds 8-8 ("maak ze allemaal functioneel", Bart): de hele setting-familie
96 // schakelt echt. De bead-nummers blijven staan, want elk van deze heeft nog
97 // een app-kant (wat de UI toont als de poort dicht is) en die woont daar.
98 { feature: 'shaer:externalThreads', kind: 'setting', reversible: true, bead: 'shaer-9y2' },
99 { feature: 'shaer:images', kind: 'setting', reversible: true, bead: 'shaer-6p5' },
100 { feature: 'shaer:messages', kind: 'setting', reversible: true, bead: 'shaer-3ow' },
101 { feature: 'shaer:compose', kind: 'setting', reversible: true, bead: 'shaer-qgev' },
102 // MEEDOEN AAN EEN GESPREK IS OOK IETS (Bart, 8-8). Dit stond hier bewust niet:
103 // een antwoord gold als meedoen en niet als eigen podium, dus compose liet het
104 // door. Bart heeft dat teruggedraaid -- wie mag antwoorden staat los van wie
105 // mag posten, en het hoort een eigen poort te zijn die je kunt zien.
106 //
107 // Los van compose en niet eronder: je kunt willen dat een kind wel meepraat
108 // maar geen eigen podium heeft, en ook precies andersom.
109 { feature: 'shaer:replies', kind: 'setting', reversible: true, bead: 'shaer-r4c' },
110 { feature: 'shaer:music', kind: 'setting', reversible: true, bead: 'shaer-rmz' },
111 { feature: 'shaer:quoteCards', kind: 'setting', reversible: true, bead: 'shaer-mls' },
112 { feature: 'shaer:customEmoji', kind: 'setting', reversible: true, bead: 'shaer-ytw' },
113 { feature: 'shaer:accountMove', kind: 'setting', reversible: true, bead: 'shaer-tge' },
114 // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
115 // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
116 { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
117
118 // GEPLAND, en dat is bij deze twee geen achterstand maar een besluit.
119 //
120 // publicProfile is niet een veld dat je wegfiltert: het is het hele publieke
121 // web-oppervlak van een site (de Krant, de AP-objecten, de scrape-vraag van
122 // shaer-hj0). Dat dichtzetten zonder dat ontwerp is een half slot, en een
123 // half slot leest als een heel slot -- gevaarlijker dan geen.
124 //
125 // available: false is geen detail. featureColumn() kent deze naam niet, dus
126 // een voorstel strandt op unknown_feature, en de rij leest als "hier is nog
127 // niets van", niet als een gesloten poort.
128 { feature: 'shaer:publicProfile', kind: 'setting', reversible: true, available: false, bead: 'shaer-hj0' },
129 // De enige die gezag OVERDRAAGT, en daarmee de enige die niet terug te draaien
130 // is zodra het kind hem gebruikt (shaer-90v). Telt met de lapse-vorm: volle
131 // set, volle venster. Die vorm hoort daar beslist te worden, niet hier
132 // geimproviseerd: een verkeerd gemaakte onafhankelijkheid is een kind zonder
133 // vangnet.
134 { feature: 'shaer:independence', kind: 'handover', reversible: false, available: false, bead: 'shaer-90v' },
135];
136
137/**
138 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
139 * getoetst kunnen worden zonder database of scherm.
140 *
141 * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit
142 * @param guardianCount aantal guardians, of null als we het niet weten
143 * @param proposals [{feature, value, status}] lopende voorstellen
144 * @param waiting {feature: aantal} wat er per gate op een besluit wacht
145 */
146export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
147 return GATE_CATALOGUE.map((g) => {
148 // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
149 // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
150 // suggereren dat niemand nam.
151 const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
152 const beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
153 const value = raw && typeof raw === 'object' ? raw.value : raw;
154 // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
155 // bij een ward elders kennen we de stand niet, en verbergen betekende daar
156 // ooit dat een voorstel nooit geopend kon worden.
157 const bovenliggend = settings[g.needs];
158 const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
159 const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
160 // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
161 const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
162 return {
163 feature: g.feature,
164 kind: g.kind,
165 reversible: !!g.reversible,
166 value,
167 decided: beslist,
168 // Vast staat vast: tonen mag, verzetten niet.
169 // Wat er niet is, valt niet te verzetten. Een knop die op unknown_feature
170 // strandt is erger dan geen knop.
171 available: g.available !== false,
172 adjustable: g.available !== false && !g.fixed && !blockedBy,
173 blockedBy: blockedBy || undefined,
174 // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
175 // leest als een feit, en dit is precies waar een guardian op afgaat.
176 threshold: (guardianCount && guardianCount > 0)
177 ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
178 proposal: proposals.find((p) => p.feature === g.feature) || undefined,
179 waiting: waiting[g.feature] || undefined,
180 };
181 });
182}
183
184export function featureColumn(feature) {
185 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
186}
187
188/**
189 * Record one guardian's answer and settle if the threshold is now reached.
190 * Returns the tally state so a caller can report it.
191 */
192export function recordGatedVote(slug, feature, guardianUri, value) {
193 const column = featureColumn(feature);
194 if (!column) return { state: 'expired', error: 'unknown_feature' };
195 const all = listGuardians(slug).map((g) => g.other_uri);
196 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
197 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
198 // restored first, so it always counts itself back into the set below.
199 availability.oneAnswer(guardianUri, Date.now());
200 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
201 // against the full list above: any guardian may answer, and answering is
202 // exactly what brings it back in.
203 const guardians = availability.availableSet(slug, all, Date.now());
204
205 // The window opens with the first answer, and a stale decision starts over:
206 // a proposal from last month should not silently count toward today's.
207 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
208 .get(slug, feature);
209 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
210 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
211 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
212 openedAt = Date.now();
213 }
214 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
215 VALUES (?,?,?,?,?)
216 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
217 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
218
219 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
220 .all(slug, feature);
221 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
222 if (result.state === 'settled') {
223 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
224 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
225 } else if (result.state === 'expired') {
226 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
227 }
228 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
229}
230
231/**
232 * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
233 *
234 * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
235 * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
236 * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
237 * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
238 * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
239 * het gezien.
240 *
241 * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
242 * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
243 * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
244 * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
245 *
246 * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
247 * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
248 * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
249 * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
250 */
251export function gateConsequence(feature) {
252 const g = GATE_CATALOGUE.find((x) => x.feature === feature);
253 if (!g) return 'unknown';
254 return g.reversible === false ? 'irreversible' : 'reversible';
255}
256
257/** The open decision for a feature, for showing progress ("1 of 2"). */
258export function gatedProgress(slug, feature) {
259 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
260 .all(slug, feature);
261 // Progress over the available set (§3.5), like the tally itself.
262 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
263 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
264}
265
266// ── The federated shape (§5.6) ────────────────────────────────────
267// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
268// here so both the inbox and the outbox read it the same way.
269
270/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
271export function parseGatedSetting(object) {
272 if (!object || typeof object !== 'object') return null;
273 const type = Array.isArray(object.type) ? object.type[0] : object.type;
274 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
275 const ward = object['shaer:ward'] || object.ward;
276 const feature = object['shaer:feature'] || object.feature;
277 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
278 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
279 return { ward, feature, value: value === true || value === 1 || value === 'true' };
280}
281
282/** Build the Offer a guardian sends to the ward's server. */
283export function buildGatedOffer(offerId, actor, ward, feature, value) {
284 return {
285 id: offerId,
286 type: 'Offer',
287 actor,
288 to: [ward],
289 object: {
290 type: 'shaer:GatedSetting',
291 'shaer:ward': ward,
292 'shaer:feature': feature,
293 'shaer:value': !!value,
294 },
295 };
296}
297
298// ── The guardian-side copy (the missing leg of §5.6) ──────────────
299// A proposal addressed to the ward's server reaches only the proposer and the
300// ward. The other guardians never learn it exists, so a threshold of two can
301// never be met and every proposal expires unanswered. The ward's server
302// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
303// guardian stores a copy it can answer, and the answer travels back to the
304// ward, which tallies.
305
306let _rs = null;
307function rstmts() {
308 if (!_rs) {
309 _rs = {
310 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
311 VALUES (?,?,?,?,?,?,?)
312 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
313 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
314 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
315 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
316 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
317 };
318 }
319 return _rs;
320}
321
322export function recordGatedReview(guardianSlug, r) {
323 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
324 return rstmts().get.get(guardianSlug, r.id);
325}
326export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
327export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
328export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
329/** Drop every guardian's copy once the decision has settled or lapsed. */
330export function clearGatedReviews(id) { rstmts().delAll.run(id); }
331
332export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
333 try {
334 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
335 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
336 } catch { /* non-fatal */ }
337}
338
339export function recallGatedOffer(offerId) {
340 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
341 catch { return null; }
342}
343
344// ── The proposer's own record (5.6) ───────────────────────────────
345// "Where did my proposal go?" had no answer: the status was a button caption
346// that did not survive a refresh. The ward's server tallies elsewhere, so the
347// proposer keeps its own row and the ward's server ANSWERS the Offer when the
348// decision settles: Accept when it settled on the proposed value, Reject when
349// it settled on the opposite. An open row past the window renders as expired,
350// because an expired decision settles on nothing and nobody writes home.
351
352export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
353 try {
354 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
355 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
356 } catch { /* non-fatal */ }
357}
358
359export function recallSent(offerId) {
360 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
361 catch { return null; }
362}
363
364/**
365 * De stand van een gate zoals DEZE guardian hem kent.
366 *
367 * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
368 * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
369 * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
370 * true betekent dat de poort openging.
371 *
372 * Geeft { value, decided }:
373 * decided true we hebben een aangenomen besluit gezien; value is die waarde
374 * decided false we hebben er geen; value is de standaard voor een ward (uit)
375 *
376 * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
377 * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
378 *
379 * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
380 * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
381 * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
382 * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
383 * onveilige kant en het hoort gerepareerd te worden.
384 */
385export function knownSetting(guardianSlug, wardUri, feature) {
386 try {
387 const r = db.prepare(`SELECT value FROM ap_gated_sent
388 WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
389 ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
390 if (r) return { value: !!r.value, decided: true };
391 } catch { /* val terug op de standaard */ }
392 return { value: false, decided: false };
393}
394
395export function settleSent(offerId, outcome) {
396 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
397}
398
399/** The latest proposal per feature this guardian sent to this ward. */
400export function listSent(guardianSlug, wardUri) {
401 try {
402 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
403 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
404 } catch { return []; }
405}
406
407/**
408 * What a sent row means on a screen. Pure, so the rule is testable: an answer
409 * wins, and silence past the window is not "still running", it is over.
410 */
411export function sentStatus(row, now) {
412 if (!row) return null;
413 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
414 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
415 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
416 return 'open';
417}
418
419export default {
420 GATE_CATALOGUE, gateRows, knownSetting,
421 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, GATED_WINDOW_MS,
422 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
423 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
424 recordSent, recallSent, settleSent, listSent, sentStatus,
425};
Note: See TracBrowser for help on using the repository browser.