source: Klonkt/src/services/guardianship/gated.js@ 0b0cd54

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

De vraag van het kind staat apart van de wachtrij (shaer-8ru, vervolg)

Ik gooide het verzoek van de ward in dezelfde waiting-teller als de wachtende
volgverzoeken. Dat is fout: drie onbekenden die je kind willen volgen is iets
heel anders dan je kind dat een keer vraagt of muziek aan mag, en een gedeeld
getal maakt daar hetzelfde van -- dan leest de ene urgentie als de andere.

Nu een eigen veld requested op de poortrij, en in het paneel een eigen regel
onder de schakelaar in plaats van een getal in het statusveld: dit is het enige
op die rij dat van het KIND komt, en het hoort niet weg te vallen tussen de
drempels en de soorten. Drie talen.

Suite 714/714.

  • Property mode set to 100644
File size: 22.1 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 = {}, requested = {} } = {}) {
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 // Het kind vroeg hier zelf om (shaer-8ru). Apart van `waiting`: drie
181 // onbekenden die je kind willen volgen is iets anders dan je kind dat
182 // een keer vraagt of muziek aan mag, en een gedeeld getal maakt daar
183 // hetzelfde van.
184 requested: requested[g.feature] || undefined,
185 };
186 });
187}
188
189export function featureColumn(feature) {
190 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
191}
192
193/**
194 * Record one guardian's answer and settle if the threshold is now reached.
195 * Returns the tally state so a caller can report it.
196 */
197export function recordGatedVote(slug, feature, guardianUri, value) {
198 const column = featureColumn(feature);
199 if (!column) return { state: 'expired', error: 'unknown_feature' };
200 const all = listGuardians(slug).map((g) => g.other_uri);
201 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
202 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
203 // restored first, so it always counts itself back into the set below.
204 availability.oneAnswer(guardianUri, Date.now());
205 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
206 // against the full list above: any guardian may answer, and answering is
207 // exactly what brings it back in.
208 const guardians = availability.availableSet(slug, all, Date.now());
209
210 // The window opens with the first answer, and a stale decision starts over:
211 // a proposal from last month should not silently count toward today's.
212 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
213 .get(slug, feature);
214 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
215 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
216 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
217 openedAt = Date.now();
218 }
219 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
220 VALUES (?,?,?,?,?)
221 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
222 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
223
224 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
225 .all(slug, feature);
226 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
227 if (result.state === 'settled') {
228 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
229 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
230 } else if (result.state === 'expired') {
231 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
232 }
233 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
234}
235
236/**
237 * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
238 *
239 * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
240 * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
241 * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
242 * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
243 * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
244 * het gezien.
245 *
246 * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
247 * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
248 * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
249 * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
250 *
251 * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
252 * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
253 * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
254 * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
255 */
256export function gateConsequence(feature) {
257 const g = GATE_CATALOGUE.find((x) => x.feature === feature);
258 if (!g) return 'unknown';
259 return g.reversible === false ? 'irreversible' : 'reversible';
260}
261
262/** The open decision for a feature, for showing progress ("1 of 2"). */
263export function gatedProgress(slug, feature) {
264 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
265 .all(slug, feature);
266 // Progress over the available set (§3.5), like the tally itself.
267 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
268 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
269}
270
271// ── The federated shape (§5.6) ────────────────────────────────────
272// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
273// here so both the inbox and the outbox read it the same way.
274
275/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
276export function parseGatedSetting(object) {
277 if (!object || typeof object !== 'object') return null;
278 const type = Array.isArray(object.type) ? object.type[0] : object.type;
279 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
280 const ward = object['shaer:ward'] || object.ward;
281 const feature = object['shaer:feature'] || object.feature;
282 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
283 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
284 return { ward, feature, value: value === true || value === 1 || value === 'true' };
285}
286
287/** Build the Offer a guardian sends to the ward's server. */
288export function buildGatedOffer(offerId, actor, ward, feature, value) {
289 return {
290 id: offerId,
291 type: 'Offer',
292 actor,
293 to: [ward],
294 object: {
295 type: 'shaer:GatedSetting',
296 'shaer:ward': ward,
297 'shaer:feature': feature,
298 'shaer:value': !!value,
299 },
300 };
301}
302
303// ── The guardian-side copy (the missing leg of §5.6) ──────────────
304// A proposal addressed to the ward's server reaches only the proposer and the
305// ward. The other guardians never learn it exists, so a threshold of two can
306// never be met and every proposal expires unanswered. The ward's server
307// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
308// guardian stores a copy it can answer, and the answer travels back to the
309// ward, which tallies.
310
311let _rs = null;
312function rstmts() {
313 if (!_rs) {
314 _rs = {
315 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
316 VALUES (?,?,?,?,?,?,?)
317 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
318 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
319 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
320 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
321 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
322 };
323 }
324 return _rs;
325}
326
327export function recordGatedReview(guardianSlug, r) {
328 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
329 return rstmts().get.get(guardianSlug, r.id);
330}
331export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
332export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
333export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
334/** Drop every guardian's copy once the decision has settled or lapsed. */
335export function clearGatedReviews(id) { rstmts().delAll.run(id); }
336
337export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
338 try {
339 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
340 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
341 } catch { /* non-fatal */ }
342}
343
344export function recallGatedOffer(offerId) {
345 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
346 catch { return null; }
347}
348
349// ── The proposer's own record (5.6) ───────────────────────────────
350// "Where did my proposal go?" had no answer: the status was a button caption
351// that did not survive a refresh. The ward's server tallies elsewhere, so the
352// proposer keeps its own row and the ward's server ANSWERS the Offer when the
353// decision settles: Accept when it settled on the proposed value, Reject when
354// it settled on the opposite. An open row past the window renders as expired,
355// because an expired decision settles on nothing and nobody writes home.
356
357export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
358 try {
359 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
360 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
361 } catch { /* non-fatal */ }
362}
363
364export function recallSent(offerId) {
365 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
366 catch { return null; }
367}
368
369/**
370 * De stand van een gate zoals DEZE guardian hem kent.
371 *
372 * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
373 * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
374 * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
375 * true betekent dat de poort openging.
376 *
377 * Geeft { value, decided }:
378 * decided true we hebben een aangenomen besluit gezien; value is die waarde
379 * decided false we hebben er geen; value is de standaard voor een ward (uit)
380 *
381 * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
382 * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
383 *
384 * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
385 * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
386 * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
387 * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
388 * onveilige kant en het hoort gerepareerd te worden.
389 */
390export function knownSetting(guardianSlug, wardUri, feature) {
391 try {
392 const r = db.prepare(`SELECT value FROM ap_gated_sent
393 WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
394 ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
395 if (r) return { value: !!r.value, decided: true };
396 } catch { /* val terug op de standaard */ }
397 return { value: false, decided: false };
398}
399
400export function settleSent(offerId, outcome) {
401 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
402}
403
404/** The latest proposal per feature this guardian sent to this ward. */
405export function listSent(guardianSlug, wardUri) {
406 try {
407 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
408 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
409 } catch { return []; }
410}
411
412/**
413 * What a sent row means on a screen. Pure, so the rule is testable: an answer
414 * wins, and silence past the window is not "still running", it is over.
415 */
416export function sentStatus(row, now) {
417 if (!row) return null;
418 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
419 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
420 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
421 return 'open';
422}
423
424export default {
425 GATE_CATALOGUE, gateRows, knownSetting,
426 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, GATED_WINDOW_MS,
427 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
428 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
429 recordSent, recallSent, settleSent, listSent, sentStatus,
430};
Note: See TracBrowser for help on using the repository browser.