@aiaiai-pt/design-system 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/EChart.svelte +266 -0
- package/components/EChart.svelte.d.ts +63 -0
- package/components/index.js +3 -0
- package/components/renderer/EChartWidget.svelte +78 -0
- package/components/renderer/EChartWidget.svelte.d.ts +4 -0
- package/components/renderer/ResultsChartWidget.svelte +77 -0
- package/components/renderer/ResultsChartWidget.svelte.d.ts +4 -0
- package/components/renderer/StatGridWidget.svelte +33 -0
- package/components/renderer/StatGridWidget.svelte.d.ts +4 -0
- package/components/renderer/aggregate.d.ts +34 -0
- package/components/renderer/aggregate.ts +72 -0
- package/components/renderer/data-provider.d.ts +82 -0
- package/components/renderer/data-provider.ts +162 -0
- package/components/renderer/dispatch.d.ts +63 -0
- package/components/renderer/dispatch.ts +99 -0
- package/components/renderer/registry.d.ts +48 -0
- package/components/renderer/registry.ts +101 -0
- package/components/renderer/resolve-data.d.ts +109 -0
- package/components/renderer/resolve-data.ts +619 -0
- package/components/renderer/types.d.ts +164 -0
- package/components/renderer/types.ts +250 -0
- package/components/renderer/vote.d.ts +121 -0
- package/components/renderer/vote.ts +253 -0
- package/package.json +17 -3
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveData(block)` — the single data path between a block's pointer and a
|
|
3
|
+
* widget (#73, spec §5). Reads the `binding`, fetches through the host-injected
|
|
4
|
+
* `DataProvider` (#492 P0), and returns normalised `{ schema, data, actionDef }`.
|
|
5
|
+
* The block stores ONLY the pointer — rows are never baked in.
|
|
6
|
+
*
|
|
7
|
+
* Pure + provider-injectable so the security-critical decisions (which paths are
|
|
8
|
+
* reachable, fail-closed on misconfig/upstream error) are unit/mutation-
|
|
9
|
+
* testable without SvelteKit. The route loader is a thin wrapper that passes a
|
|
10
|
+
* `createPublicDataProvider` wrapping its server `fetch` (which carries the
|
|
11
|
+
* tenant + graduated principal).
|
|
12
|
+
*
|
|
13
|
+
* Fail-closed (§14.8): an unmappable binding never fetches; a non-OK upstream
|
|
14
|
+
* yields `{ ok: false }` and the caller renders per blast radius (see
|
|
15
|
+
* `decideRender` in `./dispatch.ts`).
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
Binding,
|
|
19
|
+
Block,
|
|
20
|
+
FeedView,
|
|
21
|
+
OntologySchema,
|
|
22
|
+
WidgetKind,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import type { DataProvider } from "./data-provider";
|
|
25
|
+
import { type Ballot, isClosed, toOptions, voteReadConfig } from "./vote";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Presentational kinds (#75 M5 hero/lookup + #105 Phase 7 landing/content) that
|
|
29
|
+
* render PURELY from their block props — no data path, no fetch. resolveData
|
|
30
|
+
* short-circuits them with `{ data: null }`, which still satisfies decideRender's
|
|
31
|
+
* "matched + dataOk" contract (the widget owns its own props). Keeping them in
|
|
32
|
+
* one set means a new content section never accidentally tries to fetch.
|
|
33
|
+
*/
|
|
34
|
+
export const PRESENTATIONAL_KINDS: ReadonlySet<WidgetKind> =
|
|
35
|
+
new Set<WidgetKind>([
|
|
36
|
+
"hero",
|
|
37
|
+
"lookup",
|
|
38
|
+
"feature-grid",
|
|
39
|
+
"cta",
|
|
40
|
+
"media-text",
|
|
41
|
+
"steps",
|
|
42
|
+
"testimonial",
|
|
43
|
+
"faq",
|
|
44
|
+
"logo-strip",
|
|
45
|
+
"media-gallery",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export interface ResolvedData {
|
|
49
|
+
data: unknown;
|
|
50
|
+
/** Ontology schema for the bound entity — wired in 73d. */
|
|
51
|
+
schema: OntologySchema | null;
|
|
52
|
+
/** Action definition for form bindings — wired in 73f. */
|
|
53
|
+
actionDef: unknown | null;
|
|
54
|
+
/** The BFF path the data came from (#75 M5 slice 4) — widgets that page
|
|
55
|
+
* (list "load more") re-fetch it client-side with `?after=`. */
|
|
56
|
+
dataPath?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ResolveDataResult =
|
|
60
|
+
| { ok: true; value: ResolvedData }
|
|
61
|
+
| { ok: false; status: number; reason: string };
|
|
62
|
+
|
|
63
|
+
/** The minimal fetch shape we depend on (injectable; SvelteKit `fetch` fits). */
|
|
64
|
+
export type FetchLike = (path: string) => Promise<{
|
|
65
|
+
ok: boolean;
|
|
66
|
+
status: number;
|
|
67
|
+
json: () => Promise<unknown>;
|
|
68
|
+
}>;
|
|
69
|
+
|
|
70
|
+
export interface ResolveDataDeps {
|
|
71
|
+
/** Host-injected data transport seam (#492 P0). */
|
|
72
|
+
provider: DataProvider;
|
|
73
|
+
/**
|
|
74
|
+
* "Now", injected by the route loader (`new Date()`), used to compute the
|
|
75
|
+
* calendar's required `start`/`end` window (73e). Optional — only the
|
|
76
|
+
* `calendar` kind reads it. Injected (not read here) so the path stays
|
|
77
|
+
* deterministic in tests.
|
|
78
|
+
*/
|
|
79
|
+
now?: Date;
|
|
80
|
+
/**
|
|
81
|
+
* The active feed view (`?view=` — #308), used only by the `feed` kind to
|
|
82
|
+
* pick which underlying feed (list/map) to resolve. Absent → the block's
|
|
83
|
+
* first configured view.
|
|
84
|
+
*/
|
|
85
|
+
view?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The URL's facet/search params (#308), forwarded to a feed read so the SSR
|
|
88
|
+
* feed is server-FILTERED (and paging rides `dataPath`). The BFF drops any
|
|
89
|
+
* param it doesn't declare (its security contract), so the whole set is safe
|
|
90
|
+
* to pass. Reserved keys (`view`, `reference`) are stripped by the loader.
|
|
91
|
+
*/
|
|
92
|
+
filterParams?: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function enc(value: string): string {
|
|
96
|
+
return encodeURIComponent(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The feed sub-views a `feed` block may toggle between (#308). Bounds the
|
|
100
|
+
* operator-authored `props.views` to a known allowlist; an empty/invalid list
|
|
101
|
+
* falls back to `["list"]` (every browse surface has a list). */
|
|
102
|
+
const FEED_VIEW_ALLOWLIST: readonly FeedView[] = ["list", "map"];
|
|
103
|
+
|
|
104
|
+
export function feedViews(
|
|
105
|
+
props: Record<string, unknown> | undefined,
|
|
106
|
+
): FeedView[] {
|
|
107
|
+
const raw = props?.views;
|
|
108
|
+
const parsed = Array.isArray(raw)
|
|
109
|
+
? raw.filter((v): v is FeedView =>
|
|
110
|
+
(FEED_VIEW_ALLOWLIST as readonly string[]).includes(v as string),
|
|
111
|
+
)
|
|
112
|
+
: [];
|
|
113
|
+
return parsed.length > 0 ? parsed : ["list"];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The active view = the requested one when it's an available view, else the
|
|
117
|
+
* default (first). Keeps `?view=` shareable + an unknown value harmless. */
|
|
118
|
+
export function activeFeedView(
|
|
119
|
+
views: FeedView[],
|
|
120
|
+
requested: string | undefined,
|
|
121
|
+
): FeedView {
|
|
122
|
+
return requested && (views as string[]).includes(requested)
|
|
123
|
+
? (requested as FeedView)
|
|
124
|
+
: views[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Merge facet/search params into a BFF path (handles an existing `?`). Empty
|
|
128
|
+
* values drop; an empty param set returns the path unchanged. */
|
|
129
|
+
export function appendQuery(
|
|
130
|
+
path: string,
|
|
131
|
+
params: Record<string, string> | undefined,
|
|
132
|
+
): string {
|
|
133
|
+
if (!params) return path;
|
|
134
|
+
const entries = Object.entries(params).filter(
|
|
135
|
+
([, v]) => v !== undefined && v !== null && v !== "",
|
|
136
|
+
);
|
|
137
|
+
if (entries.length === 0) return path;
|
|
138
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
139
|
+
return `${path}${sep}${new URLSearchParams(entries).toString()}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Pure mapping: pointer → BFF public path (#70 surface, app-first). Returns
|
|
144
|
+
* `null` for any binding that doesn't map to a wired read endpoint — the
|
|
145
|
+
* caller fails closed (no fetch, no DOM). `binding.filter` carries the
|
|
146
|
+
* per-route selector (ref/key/id) for the single-resource kinds.
|
|
147
|
+
*/
|
|
148
|
+
export function bffPathForBinding(
|
|
149
|
+
binding: Binding,
|
|
150
|
+
app: string,
|
|
151
|
+
): string | null {
|
|
152
|
+
switch (binding.kind) {
|
|
153
|
+
case "list": {
|
|
154
|
+
if (!binding.entity) return null;
|
|
155
|
+
// Optional list scope (#75 M5): cap + ordering ride the query string.
|
|
156
|
+
// The BFF shape-sanitises `order_by` (drops malformed values whole)
|
|
157
|
+
// and clamps `limit`; both are plain passthrough params here.
|
|
158
|
+
const params = new URLSearchParams();
|
|
159
|
+
if (typeof binding.limit === "number" && binding.limit > 0) {
|
|
160
|
+
params.set("limit", String(Math.floor(binding.limit)));
|
|
161
|
+
}
|
|
162
|
+
if (typeof binding.order === "string" && binding.order) {
|
|
163
|
+
params.set("order_by", binding.order);
|
|
164
|
+
}
|
|
165
|
+
const qs = params.toString();
|
|
166
|
+
return `/${app}/public/${enc(binding.entity)}${qs ? `?${qs}` : ""}`;
|
|
167
|
+
}
|
|
168
|
+
case "aggregate": {
|
|
169
|
+
// #105 Phase 4 — a public aggregate VIEW's rows as primary data. Same
|
|
170
|
+
// public-list URL as a `list` (the BFF routes to the view lane off the
|
|
171
|
+
// surface row's `source_kind: view`, #250 M3), but a distinct kind so the
|
|
172
|
+
// registry dispatches RankingBoard/ResultsChart (which read `{label,
|
|
173
|
+
// value}` rows) instead of EntityList (which needs a per-type schema a
|
|
174
|
+
// view doesn't have). `limit`/`order` ride the query string; the view's
|
|
175
|
+
// sortable columns ARE its output columns, so the BFF drops undeclared
|
|
176
|
+
// order terms.
|
|
177
|
+
if (!binding.entity) return null;
|
|
178
|
+
const params = new URLSearchParams();
|
|
179
|
+
if (typeof binding.limit === "number" && binding.limit > 0) {
|
|
180
|
+
params.set("limit", String(Math.floor(binding.limit)));
|
|
181
|
+
}
|
|
182
|
+
if (typeof binding.order === "string" && binding.order) {
|
|
183
|
+
params.set("order_by", binding.order);
|
|
184
|
+
}
|
|
185
|
+
const qs = params.toString();
|
|
186
|
+
return `/${app}/public/${enc(binding.entity)}${qs ? `?${qs}` : ""}`;
|
|
187
|
+
}
|
|
188
|
+
case "forms":
|
|
189
|
+
// The service directory (#75 M5) — citizen-startable write placements.
|
|
190
|
+
return `/${app}/public/forms`;
|
|
191
|
+
case "detail": {
|
|
192
|
+
if (!binding.entity || !binding.filter) return null;
|
|
193
|
+
const base = `/${app}/public/${enc(binding.entity)}/${enc(binding.filter)}`;
|
|
194
|
+
const names = (binding.expand ?? []).filter(
|
|
195
|
+
(n): n is string =>
|
|
196
|
+
typeof n === "string" && /^[a-z][a-z0-9_]*$/.test(n),
|
|
197
|
+
);
|
|
198
|
+
return names.length > 0 ? `${base}?expand=${enc(names.join(","))}` : base;
|
|
199
|
+
}
|
|
200
|
+
case "actions": {
|
|
201
|
+
// #313 — citizen action availability for a detail row. Needs the entity
|
|
202
|
+
// type + row id (carried like `detail`); the BFF projects which
|
|
203
|
+
// citizen-write actions are OPEN on this row. No expand.
|
|
204
|
+
if (!binding.entity || !binding.filter) return null;
|
|
205
|
+
return `/${app}/public/${enc(binding.entity)}/${enc(binding.filter)}/actions`;
|
|
206
|
+
}
|
|
207
|
+
case "status":
|
|
208
|
+
return binding.filter
|
|
209
|
+
? `/${app}/public/submission/${enc(binding.filter)}`
|
|
210
|
+
: null;
|
|
211
|
+
case "content":
|
|
212
|
+
return binding.filter
|
|
213
|
+
? `/${app}/public/content/${enc(binding.filter)}`
|
|
214
|
+
: null;
|
|
215
|
+
case "map":
|
|
216
|
+
return `/${app}/public/map`;
|
|
217
|
+
case "calendar":
|
|
218
|
+
return `/${app}/public/calendar`;
|
|
219
|
+
case "form":
|
|
220
|
+
// The action FORM contract, keyed by the citizen-write PLACEMENT key
|
|
221
|
+
// (carried in `filter`). 73f — the converged renderer mounts this.
|
|
222
|
+
return binding.filter
|
|
223
|
+
? `/${app}/public/form/${enc(binding.filter)}`
|
|
224
|
+
: null;
|
|
225
|
+
// kpi: no wired read endpoint yet. `vote` is handled in a dedicated
|
|
226
|
+
// resolveData branch (two reads composed into a ballot), never here. Fail
|
|
227
|
+
// closed for anything unmapped.
|
|
228
|
+
default:
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Pure mapping: pointer → BFF public *schema* path (#73 / 73d). Only the
|
|
235
|
+
* schema-driven kinds (`list`, `detail`) have one; the entity's field shape
|
|
236
|
+
* (key/type/label) lets EntityList/DetailView render columns/fields without a
|
|
237
|
+
* hardcoded entity-type union (§14.6). Returns `null` for every other kind, and
|
|
238
|
+
* for list/detail without an entity. `binding.filter` (the detail row id) is
|
|
239
|
+
* NOT part of the schema path — the schema is per *type*, not per row.
|
|
240
|
+
*/
|
|
241
|
+
export function schemaPathForBinding(
|
|
242
|
+
binding: Binding,
|
|
243
|
+
app: string,
|
|
244
|
+
): string | null {
|
|
245
|
+
switch (binding.kind) {
|
|
246
|
+
case "list":
|
|
247
|
+
case "detail":
|
|
248
|
+
return binding.entity
|
|
249
|
+
? `/${app}/public/schema/${enc(binding.entity)}`
|
|
250
|
+
: null;
|
|
251
|
+
default:
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The date window the `/public/calendar` fetch requires (73e). The endpoint
|
|
258
|
+
* 422s without `start`/`end`, so the calendar block fetches a window computed
|
|
259
|
+
* from the injected "now": the current month through two months out (covering
|
|
260
|
+
* the default month view plus a buffer for next-month navigation). Pure given
|
|
261
|
+
* `now`, computed in UTC so it doesn't drift with the server timezone.
|
|
262
|
+
*/
|
|
263
|
+
export function calendarWindow(now: Date): { start: string; end: string } {
|
|
264
|
+
const year = now.getUTCFullYear();
|
|
265
|
+
const month = now.getUTCMonth();
|
|
266
|
+
const start = new Date(Date.UTC(year, month, 1));
|
|
267
|
+
const end = new Date(Date.UTC(year, month + 2, 1));
|
|
268
|
+
return {
|
|
269
|
+
start: start.toISOString().slice(0, 10),
|
|
270
|
+
end: end.toISOString().slice(0, 10),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function resolveData(
|
|
275
|
+
block: Block,
|
|
276
|
+
deps: ResolveDataDeps,
|
|
277
|
+
): Promise<ResolveDataResult> {
|
|
278
|
+
// Presentational kinds (#75 M5 hero/lookup + #105 Phase 7 landing/content):
|
|
279
|
+
// they render purely from block props — no data path, no fetch. Resolving OK
|
|
280
|
+
// with null data keeps decideRender's "matched + dataOk" contract without a
|
|
281
|
+
// network round-trip.
|
|
282
|
+
if (PRESENTATIONAL_KINDS.has(block.binding.kind)) {
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
value: { data: null, schema: null, actionDef: null },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// #105 Phase 5 — the `subscriptions` block reads the signed-in citizen's OWN
|
|
290
|
+
// notification subscriptions through the AUTHED account proxy
|
|
291
|
+
// (`/me/notifications/subscriptions`), NOT the public `/bff` surface: the lane
|
|
292
|
+
// is `require_auth` + OWN-scoped, so anonymous/unresolved-tenant calls 401/400
|
|
293
|
+
// at the proxy. The page is auth-gated upstream (the loader redirects an
|
|
294
|
+
// anonymous caller to login), so a non-OK here is a real upstream failure →
|
|
295
|
+
// fail the block CLOSED (the widget owns its own busy/empty UI once data
|
|
296
|
+
// loads). `dataPath` carries the lane so the widget mutates the same surface.
|
|
297
|
+
if (block.binding.kind === "subscriptions") {
|
|
298
|
+
const accountPath = deps.provider.accountUrl("notifications/subscriptions");
|
|
299
|
+
const resp = await deps.provider.fetch(accountPath);
|
|
300
|
+
if (!resp.ok) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
status: resp.status,
|
|
304
|
+
reason: `upstream ${resp.status}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const envelope = (await resp.json()) as Record<string, unknown>;
|
|
308
|
+
|
|
309
|
+
// #252 Anexo 110 — per edition/category/freguesia toggles. Each declared
|
|
310
|
+
// scope names a PUBLIC option-view + its value/label fields (DATA, no
|
|
311
|
+
// civic literal here); we read each view and shape `{value,label}` options.
|
|
312
|
+
// Soft per-dimension: a missing/failed view drops that section, never the
|
|
313
|
+
// whole panel. The backend already matches a subscription's single-key
|
|
314
|
+
// `filters` against the dispatch payload (#241), so enabling a toggle =
|
|
315
|
+
// creating `{filters:{<key>:<value>}}`.
|
|
316
|
+
const scopeDefs = Array.isArray(
|
|
317
|
+
(block.props as { scopes?: unknown })?.scopes,
|
|
318
|
+
)
|
|
319
|
+
? ((block.props as { scopes: unknown[] }).scopes as Record<
|
|
320
|
+
string,
|
|
321
|
+
unknown
|
|
322
|
+
>[])
|
|
323
|
+
: [];
|
|
324
|
+
const sp = (o: Record<string, unknown>, k: string): string =>
|
|
325
|
+
typeof o[k] === "string" ? (o[k] as string) : "";
|
|
326
|
+
const scopes: {
|
|
327
|
+
key: string;
|
|
328
|
+
group: string;
|
|
329
|
+
options: { value: string; label: string }[];
|
|
330
|
+
}[] = [];
|
|
331
|
+
for (const s of scopeDefs) {
|
|
332
|
+
const key = sp(s, "key");
|
|
333
|
+
const group = sp(s, "group");
|
|
334
|
+
const view = sp(s, "view");
|
|
335
|
+
const vf = sp(s, "value_field");
|
|
336
|
+
const lf = sp(s, "label_field");
|
|
337
|
+
if (!key || !group || !view || !vf || !lf) continue;
|
|
338
|
+
try {
|
|
339
|
+
const r = await deps.provider.fetch(
|
|
340
|
+
deps.provider.optionViewUrl(view, 200),
|
|
341
|
+
);
|
|
342
|
+
if (!r.ok) continue;
|
|
343
|
+
const rows = ((await r.json()) as { items?: Record<string, unknown>[] })
|
|
344
|
+
.items;
|
|
345
|
+
if (!Array.isArray(rows)) continue;
|
|
346
|
+
const options = rows
|
|
347
|
+
.map((row) => ({
|
|
348
|
+
value: row[vf] == null ? "" : String(row[vf]),
|
|
349
|
+
label: row[lf] == null ? String(row[vf] ?? "") : String(row[lf]),
|
|
350
|
+
}))
|
|
351
|
+
.filter((o) => o.value);
|
|
352
|
+
if (options.length) scopes.push({ key, group, options });
|
|
353
|
+
} catch {
|
|
354
|
+
/* soft-empty this dimension */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
360
|
+
value: {
|
|
361
|
+
data: { ...envelope, scopes },
|
|
362
|
+
schema: null,
|
|
363
|
+
actionDef: null,
|
|
364
|
+
dataPath: accountPath,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// #105 Phase 5 slice 2 — a `consent` block reads the signed-in citizen's OWN
|
|
370
|
+
// RGPD state (`/me/consent`: the tenant's active consent purposes joined with
|
|
371
|
+
// the caller's consent records) through the AUTHED account proxy, NOT the
|
|
372
|
+
// public `/bff` surface. Same auth-gated, fail-closed posture as
|
|
373
|
+
// `subscriptions` (the page loader redirects an anonymous caller to login).
|
|
374
|
+
// `dataPath` carries the lane so the widget revokes/erases/exports against it.
|
|
375
|
+
if (block.binding.kind === "consent") {
|
|
376
|
+
const accountPath = deps.provider.accountUrl("consent");
|
|
377
|
+
const resp = await deps.provider.fetch(accountPath);
|
|
378
|
+
if (!resp.ok) {
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
status: resp.status,
|
|
382
|
+
reason: `upstream ${resp.status}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
value: {
|
|
388
|
+
data: await resp.json(),
|
|
389
|
+
schema: null,
|
|
390
|
+
actionDef: null,
|
|
391
|
+
dataPath: accountPath,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// #252 Anexo 110 — a `deliveries` block reads the signed-in citizen's OWN
|
|
397
|
+
// notification HISTORY (`/me/notifications/deliveries`: require_auth +
|
|
398
|
+
// OWN-scoped on recipient_user_id) through the AUTHED account proxy. The
|
|
399
|
+
// `notification_delivery` ledger rows, written AFTER each dispatch. Read-only
|
|
400
|
+
// (no mutation). Same auth-gated, fail-CLOSED posture as `subscriptions` /
|
|
401
|
+
// `consent` — the page loader redirects an anonymous caller to login, so a
|
|
402
|
+
// non-OK here is a real upstream failure (the block soft-empties only when
|
|
403
|
+
// it is optional, per decideRender).
|
|
404
|
+
if (block.binding.kind === "deliveries") {
|
|
405
|
+
const accountPath = deps.provider.accountUrl("notifications/deliveries");
|
|
406
|
+
const resp = await deps.provider.fetch(accountPath);
|
|
407
|
+
if (!resp.ok) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
status: resp.status,
|
|
411
|
+
reason: `upstream ${resp.status}`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
ok: true,
|
|
416
|
+
value: {
|
|
417
|
+
data: await resp.json(),
|
|
418
|
+
schema: null,
|
|
419
|
+
actionDef: null,
|
|
420
|
+
dataPath: accountPath,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// #105 — a `vote` block COMPOSES two existing PUBLIC reads into a ballot (no
|
|
426
|
+
// backend): the edition row (its `current_phase` decides open/closed) and the
|
|
427
|
+
// votable-entity list (the finalists, as radio options). Both go through the
|
|
428
|
+
// auth-optional `/bff` proxy (a signed-in citizen's Bearer rides along for the
|
|
429
|
+
// cast, but the reads are public). The votable entity is on the binding; the
|
|
430
|
+
// edition entity / status / phase / label config is DATA in the block props,
|
|
431
|
+
// so this stays vertical-agnostic. Fail CLOSED on a missing edition id, a
|
|
432
|
+
// half-configured block, or a non-OK upstream — the page is auth-gated and a
|
|
433
|
+
// ballot that can't enumerate its options must not render an empty radio group.
|
|
434
|
+
if (block.binding.kind === "vote") {
|
|
435
|
+
const editionId = block.binding.filter;
|
|
436
|
+
const cfg = voteReadConfig(block.binding.entity, block.props);
|
|
437
|
+
if (!editionId || cfg === null) {
|
|
438
|
+
return { ok: false, status: 0, reason: "vote binding misconfigured" };
|
|
439
|
+
}
|
|
440
|
+
const [edResp, opResp] = await Promise.all([
|
|
441
|
+
deps.provider.fetch(deps.provider.voteEditionUrl(cfg, editionId)),
|
|
442
|
+
deps.provider.fetch(deps.provider.voteOptionsUrl(cfg, editionId)),
|
|
443
|
+
]);
|
|
444
|
+
if (!edResp.ok) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
status: edResp.status,
|
|
448
|
+
reason: `edition ${edResp.status}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if (!opResp.ok) {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
status: opResp.status,
|
|
455
|
+
reason: `options ${opResp.status}`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const editionRow = await edResp.json();
|
|
459
|
+
const proposalsBody = await opResp.json();
|
|
460
|
+
const ballot: Ballot = {
|
|
461
|
+
options: toOptions(proposalsBody, cfg),
|
|
462
|
+
closed: isClosed(editionRow, cfg),
|
|
463
|
+
edition: editionId,
|
|
464
|
+
};
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
value: { data: ballot, schema: null, actionDef: null },
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// #105 — an `owned-list` block reads the signed-in citizen's OWN rows of an
|
|
472
|
+
// entity through the AUTHED account proxy (`/me/owned/{entity}`: require_auth +
|
|
473
|
+
// OWN-scoped, resolves a user-entity-rel owner the public list can't). Fetches
|
|
474
|
+
// the public SCHEMA alongside (best-effort, like list/detail) so EntityList
|
|
475
|
+
// renders schema-driven columns; a schema miss falls back to row introspection.
|
|
476
|
+
// Same auth-gated, fail-closed posture as `subscriptions`/`consent`.
|
|
477
|
+
if (block.binding.kind === "owned-list") {
|
|
478
|
+
if (!block.binding.entity) {
|
|
479
|
+
return { ok: false, status: 0, reason: "owned-list missing entity" };
|
|
480
|
+
}
|
|
481
|
+
const ownedPath = deps.provider.accountUrl(
|
|
482
|
+
`owned/${enc(block.binding.entity)}`,
|
|
483
|
+
);
|
|
484
|
+
// The schema path uses the `list` kind convention (same schema endpoint
|
|
485
|
+
// as a public list — owned-list reads the OWN rows but schema is per-type).
|
|
486
|
+
const schemaUrl = deps.provider.schemaUrl({
|
|
487
|
+
kind: "list",
|
|
488
|
+
entity: block.binding.entity,
|
|
489
|
+
});
|
|
490
|
+
const schemaPromise =
|
|
491
|
+
schemaUrl !== null
|
|
492
|
+
? deps.provider.fetch(schemaUrl).then(
|
|
493
|
+
(r) => r,
|
|
494
|
+
() => null,
|
|
495
|
+
)
|
|
496
|
+
: null;
|
|
497
|
+
const resp = await deps.provider.fetch(ownedPath);
|
|
498
|
+
if (!resp.ok) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
status: resp.status,
|
|
502
|
+
reason: `upstream ${resp.status}`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const data = await resp.json();
|
|
506
|
+
let schema: OntologySchema | null = null;
|
|
507
|
+
const sresp = schemaPromise !== null ? await schemaPromise : null;
|
|
508
|
+
if (sresp !== null && sresp.ok) {
|
|
509
|
+
schema = (await sresp.json().catch(() => null)) as OntologySchema | null;
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
ok: true,
|
|
513
|
+
value: { data, schema, actionDef: null, dataPath: ownedPath },
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// #308 — a `filters` block renders the declared facets of ITS feed; its DATA
|
|
518
|
+
// is the entity schema (filterable_fields / searchable / fields). No entity →
|
|
519
|
+
// nothing to filter, soft-empty (the FilterBar renders nothing). The facet
|
|
520
|
+
// OPTIONS are resolved client-side by the widget (row-derived or via a
|
|
521
|
+
// relation `optionsSource`), the same machinery the inline bars used.
|
|
522
|
+
if (block.binding.kind === "filters") {
|
|
523
|
+
// The schema path uses the `list` kind convention (same schema endpoint
|
|
524
|
+
// as a feed's underlying list surface).
|
|
525
|
+
const schemaUrl = block.binding.entity
|
|
526
|
+
? deps.provider.schemaUrl({ kind: "list", entity: block.binding.entity })
|
|
527
|
+
: null;
|
|
528
|
+
if (schemaUrl === null) {
|
|
529
|
+
return { ok: true, value: { data: null, schema: null, actionDef: null } };
|
|
530
|
+
}
|
|
531
|
+
const r = await deps.provider.fetch(schemaUrl);
|
|
532
|
+
if (!r.ok) {
|
|
533
|
+
// Optional slot → soft-empty (the FilterBar renders nothing). The schema
|
|
534
|
+
// is the canonical declared-facet source; a 404 means the surface has no
|
|
535
|
+
// browse placement, which is a provisioning gap to fix at the surface,
|
|
536
|
+
// not to paper over here.
|
|
537
|
+
return { ok: true, value: { data: null, schema: null, actionDef: null } };
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
ok: true,
|
|
541
|
+
value: {
|
|
542
|
+
data: await r.json(),
|
|
543
|
+
schema: null,
|
|
544
|
+
actionDef: null,
|
|
545
|
+
dataPath: schemaUrl,
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// #308 — a `feed` block resolves the ACTIVE view's underlying feed. The view
|
|
551
|
+
// comes from the URL (`?view=`, default = first configured view); the
|
|
552
|
+
// synthetic binding lets the existing list/map path + schema logic run
|
|
553
|
+
// unchanged. `dataPath` then carries that view's feed — so the FeedView
|
|
554
|
+
// widget pages / re-derives the right surface.
|
|
555
|
+
const effective: Binding =
|
|
556
|
+
block.binding.kind === "feed"
|
|
557
|
+
? {
|
|
558
|
+
...block.binding,
|
|
559
|
+
kind: activeFeedView(feedViews(block.props), deps.view),
|
|
560
|
+
}
|
|
561
|
+
: block.binding;
|
|
562
|
+
|
|
563
|
+
// Compute the calendar window from `now` (if present) and pass it to the
|
|
564
|
+
// provider so resolveData stays URL-logic-free (§14.8 / #492 P0).
|
|
565
|
+
const calWin = deps.now !== undefined ? calendarWindow(deps.now) : undefined;
|
|
566
|
+
|
|
567
|
+
const dataUrl = deps.provider.dataUrl(effective, {
|
|
568
|
+
calendarWindow: calWin,
|
|
569
|
+
filterParams: deps.filterParams,
|
|
570
|
+
});
|
|
571
|
+
if (dataUrl === null) {
|
|
572
|
+
return { ok: false, status: 0, reason: "unresolvable binding" };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Fire the data fetch and the (best-effort) schema fetch in parallel. Data is
|
|
576
|
+
// the security boundary — a non-OK data response fails the block CLOSED.
|
|
577
|
+
// Schema is an enhancement: the widgets fall back to `props.columns` /
|
|
578
|
+
// row-key introspection, so a schema miss or network error never blocks
|
|
579
|
+
// rendering — it just yields `schema: null`. The `.then(ok, err)` neutralises
|
|
580
|
+
// a schema network rejection so it can't surface as an unhandled rejection
|
|
581
|
+
// when the data path returns early.
|
|
582
|
+
const schemaUrl = deps.provider.schemaUrl(effective);
|
|
583
|
+
const schemaPromise =
|
|
584
|
+
schemaUrl !== null
|
|
585
|
+
? deps.provider.fetch(schemaUrl).then(
|
|
586
|
+
(r) => r,
|
|
587
|
+
() => null,
|
|
588
|
+
)
|
|
589
|
+
: null;
|
|
590
|
+
|
|
591
|
+
const resp = await deps.provider.fetch(dataUrl);
|
|
592
|
+
if (!resp.ok) {
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
status: resp.status,
|
|
596
|
+
reason: `upstream ${resp.status}`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const data = await resp.json();
|
|
600
|
+
|
|
601
|
+
let schema: OntologySchema | null = null;
|
|
602
|
+
// Stryker disable next-line ConditionalExpression: awaiting `null` is harmless
|
|
603
|
+
// — when schemaPromise is null the inner `sresp !== null` guard skips anyway,
|
|
604
|
+
// so flipping this guard to `true` yields the identical result (equivalent).
|
|
605
|
+
if (schemaPromise !== null) {
|
|
606
|
+
const sresp = await schemaPromise;
|
|
607
|
+
if (sresp !== null && sresp.ok) {
|
|
608
|
+
// A malformed schema body must not fail the block — best-effort. `.catch`
|
|
609
|
+
// keeps the parse failure local (schema stays null) instead of rejecting
|
|
610
|
+
// the whole resolve.
|
|
611
|
+
schema = (await sresp.json().catch(() => null)) as OntologySchema | null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
ok: true,
|
|
617
|
+
value: { data, schema, actionDef: null, dataPath: dataUrl },
|
|
618
|
+
};
|
|
619
|
+
}
|