pgkiln 0.82.0 → 0.84.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.
Files changed (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
@@ -0,0 +1,237 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { appTx, runtime } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { checkPageAccess, computeVisibility, Forbidden } from './authz.ts';
6
+ import { publicError, type PageContext } from './context.ts';
7
+ import { listTree, queryBadge, type ListNode } from './lists.ts';
8
+ import { buttonBadgeKey } from './regions.ts';
9
+ import { chrome, clientTexts } from './render.ts';
10
+ import { safeNext } from './signin.ts';
11
+ import { saveState, takeFlash } from '../session.ts';
12
+ import { loadContext, txContext, type Req } from './routes.ts';
13
+
14
+ // (sprint 165, migration 154) In-app notifications and live badges (APEX: a notification bell in the
15
+ // header, navigation menu badges, badges refreshed with dynamic actions and JavaScript).
16
+ //
17
+ // Application code adds notifications with meta.notify() or the send_notification process. With
18
+ // meta.app.notifications on, signed-in users get a bell in the header: the number of unread
19
+ // notifications and the newest ones. Opening one (a POST with the session's CSRF token) marks it read
20
+ // and goes to its page (a link signed for the recipient when it was made). /a/<alias>/notifications
21
+ // lists them all. Only the user's own notifications of this application are ever read or changed:
22
+ // every query has the application's id and the session's user name.
23
+ //
24
+ // With meta.app.live_badges_s set, the page asks GET /a/<alias>/<page>/live every so many seconds for
25
+ // the badges of its navigation menu and bar (list entries), of its buttons with a badge query, and for
26
+ // the bell. These requests don't keep an idle session alive (loadContext touch: false).
27
+
28
+ /** Newest notifications in the bell's menu. */
29
+ const BELL_ROWS = 8;
30
+ /** Notifications on the notifications page. */
31
+ const PAGE_ROWS = 100;
32
+
33
+ interface Row {
34
+ id: string;
35
+ title: string;
36
+ body: string | null;
37
+ url: string | null;
38
+ icon: string | null;
39
+ created_at: Date | string;
40
+ read_at: Date | string | null;
41
+ }
42
+
43
+ const signedIn = (ctx: PageContext) => ctx.app.authentication !== 'none' && ctx.user !== 'nobody';
44
+ /** The bell is shown: notifications on, a signed-in user, a page with the header. */
45
+ export const hasBell = (ctx: PageContext) => !!ctx.app.notifications && signedIn(ctx);
46
+
47
+ async function rows(ctx: PageContext, limit: number): Promise<Row[]> {
48
+ return (
49
+ await runtime.query<Row>(
50
+ `select id::text, title, body, url, icon, created_at, read_at from meta.notification
51
+ where app_id = $1 and lower(username) = lower($2) order by id desc limit $3`,
52
+ [ctx.app.id, ctx.user, limit],
53
+ )
54
+ ).rows;
55
+ }
56
+
57
+ async function unreadCount(ctx: PageContext): Promise<number> {
58
+ const r = await runtime.one<{ n: number }>(
59
+ 'select count(*)::int as n from meta.notification where app_id = $1 and lower(username) = lower($2) and read_at is null',
60
+ [ctx.app.id, ctx.user],
61
+ );
62
+ return r?.n ?? 0;
63
+ }
64
+
65
+ const shown = (n: number) => (n > 99 ? '99+' : String(n));
66
+
67
+ /** A notification's time in the user's language and time zone (the pool gives timestamps as text). */
68
+ function when(ctx: PageContext, value: Date | string) {
69
+ const d = value instanceof Date ? value : new Date(value);
70
+ if (Number.isNaN(d.getTime())) return String(value);
71
+ try {
72
+ return new Intl.DateTimeFormat(ctx.locale.lang, { dateStyle: 'medium', timeStyle: 'short', timeZone: ctx.locale.timeZone ?? undefined }).format(d);
73
+ } catch {
74
+ return d.toISOString().slice(0, 16).replace('T', ' ');
75
+ }
76
+ }
77
+
78
+ /** A notification as a button that opens it (a form: it marks the notification read). */
79
+ function entry(ctx: PageContext, r: Row, cls: string): Raw {
80
+ const t = ctx.locale.t;
81
+ return html`<li class="${cls}${r.read_at ? '' : ' is-unread'}"><form method="post" action="${ctx.base}/notifications/${r.id}/open">
82
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
83
+ <button class="t-notification">${icon(r.icon ?? 'bell')}<span class="t-notification-text">
84
+ <span class="t-notification-title">${r.read_at ? '' : html`<span class="sr-only">${t('notify.unread_one')}: </span>`}${r.title}</span>
85
+ ${r.body ? html`<span class="t-notification-body">${r.body}</span>` : ''}
86
+ <span class="t-notification-time">${when(ctx, r.created_at)}</span></span></button>
87
+ </form></li>`;
88
+ }
89
+
90
+ /** "Mark all read", back to next. */
91
+ function markAllForm(ctx: PageContext, next: string): Raw {
92
+ return html`<form method="post" action="${ctx.base}/notifications/read" class="t-bell-read">
93
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}"><input type="hidden" name="next" value="${next}">
94
+ <button class="link-button">${icon('check')} ${ctx.locale.t('notify.mark_all_read')}</button></form>`;
95
+ }
96
+
97
+ /** The bell's menu: the newest notifications, Mark all read, All notifications. */
98
+ async function bellPanel(ctx: PageContext, unread: number): Promise<Raw> {
99
+ const t = ctx.locale.t;
100
+ const list = await rows(ctx, BELL_ROWS);
101
+ return html`<div class="menu-section t-bell-head"><strong>${t('notify.title')}</strong>${unread ? markAllForm(ctx, `${ctx.base}/${ctx.page.page_no}`) : ''}</div>
102
+ ${list.length ? html`<ul class="t-bell-list">${list.map((r) => entry(ctx, r, 't-bell-entry'))}</ul>` : html`<p class="muted t-bell-none">${t('notify.none')}</p>`}
103
+ <div class="menu-section"><a href="${ctx.base}/notifications">${icon('arrow-right')} ${t('notify.all')}</a></div>`;
104
+ }
105
+
106
+ const bellLabel = (ctx: PageContext, unread: number) =>
107
+ unread ? ctx.locale.t(unread === 1 ? 'notify.bell_one' : 'notify.bell_n', { n: unread }) : ctx.locale.t('notify.title');
108
+
109
+ /** The bell in the header (empty when the application has no notifications or nobody is signed in). */
110
+ export async function bellMarkup(ctx: PageContext): Promise<Raw | ''> {
111
+ if (!hasBell(ctx)) return '';
112
+ const unread = await unreadCount(ctx);
113
+ return html`<details class="menu t-bell" data-bell>
114
+ <summary class="icon-button" title="${bellLabel(ctx, unread)}" aria-label="${bellLabel(ctx, unread)}">${icon('bell')}<span class="t-bell-count" data-badge="bell"${unread ? '' : raw(' hidden')}>${unread ? shown(unread) : ''}</span></summary>
115
+ <div class="menu-panel align-right t-bell-panel" data-bell-panel>${await bellPanel(ctx, unread)}</div>
116
+ </details>`;
117
+ }
118
+
119
+ /** Every node of a list tree, depth first. */
120
+ const allNodes = (nodes: ListNode[]): ListNode[] => nodes.flatMap((n) => [n, ...allNodes(n.children)]);
121
+
122
+ export interface LiveState {
123
+ /** data-badge key → its text (null: no badge) */
124
+ badges: Record<string, string | null>;
125
+ bell: { unread: number; label: string; panel: string } | null;
126
+ }
127
+
128
+ /** The page's badges now (navigation menu and bar, buttons with a badge query) and the bell. */
129
+ export async function liveState(ctx: PageContext): Promise<LiveState> {
130
+ const badges: Record<string, string | null> = {};
131
+ for (const [list, prefix] of [[ctx.app.nav_list, 'n:'], [ctx.app.navbar_list, 'b:']] as const) {
132
+ if (!list) continue;
133
+ for (const n of allNodes((await listTree(ctx, list)) ?? [])) badges[`${prefix}${n.key}`] = n.badge;
134
+ }
135
+ for (const b of ctx.vis!.buttons.values())
136
+ if (b.badge_query?.trim() && b.id > 0) badges[buttonBadgeKey(b)] = await queryBadge(ctx, b.badge_query, `badge of button ${b.name}`);
137
+ return { badges, bell: null };
138
+ }
139
+
140
+ export async function notificationRoutes(app: FastifyInstance) {
141
+ /** A signed-in user of an application with notifications on (else a 404). */
142
+ const context = async (req: Req, reply: FastifyReply) => {
143
+ const ctx = await loadContext(req, reply, { pageNo: 'home' });
144
+ if (!ctx) return null;
145
+ if (!hasBell(ctx)) {
146
+ reply.code(404).type('text/plain').send('Not found');
147
+ return null;
148
+ }
149
+ return ctx;
150
+ };
151
+
152
+ app.get('/a/:alias/notifications', async (req: Req, reply) => {
153
+ const ctx = await context(req, reply);
154
+ if (!ctx) return;
155
+ const t = ctx.locale.t;
156
+ const list = await rows(ctx, PAGE_ROWS);
157
+ const unread = list.some((r) => !r.read_at);
158
+ const flash = takeFlash(ctx.session);
159
+ const main = html`<div class="t-titlebar"><h1>${t('notify.title')}</h1></div>
160
+ <div class="t-content notifications-page">
161
+ <div class="messages" aria-live="polite">${flash ? html`<div class="alert alert-success" role="status">${flash}</div>` : ''}</div>
162
+ <section class="region region-standard"><div class="region-body">
163
+ ${unread ? html`<div class="buttons u-mt0">${markAllForm(ctx, `${ctx.base}/notifications`)}</div>` : ''}
164
+ ${list.length ? html`<ul class="t-notification-list">${list.map((r) => entry(ctx, r, 't-notification-entry'))}</ul>` : html`<p class="muted">${t('notify.none')}</p>`}
165
+ </div></section>
166
+ </div>
167
+ <script type="application/json" id="pgkiln-meta">${raw(JSON.stringify({ csrf: ctx.session.csrf_token, das: [], texts: clientTexts(ctx) }).replace(/</g, '\\u003c'))}</script>`;
168
+ ctx.vis = { regions: new Set(), items: new Set(), editable: new Set(), buttons: new Map(), dynamicActions: new Set() };
169
+ const body = await appTx(txContext(ctx), async (c) => {
170
+ ctx.client = c;
171
+ return chrome(ctx, main, t('notify.title'));
172
+ });
173
+ await saveState(ctx.session);
174
+ return reply.header('cache-control', 'private, no-store').type('text/html').send(body);
175
+ });
176
+
177
+ // open a notification: mark it read, go to its page (or the list when it has none)
178
+ app.post('/a/:alias/notifications/:id/open', async (req: Req, reply) => {
179
+ const ctx = await context(req, reply);
180
+ if (!ctx) return;
181
+ if (req.body?.__csrf !== ctx.session.csrf_token) return reply.redirect(`${ctx.base}/notifications`, 303);
182
+ const id = /^[1-9][0-9]{0,17}$/.test(String(req.params.id)) ? String(req.params.id) : null;
183
+ const row = id
184
+ ? await runtime.one<{ url: string | null }>(
185
+ `update meta.notification set read_at = coalesce(read_at, now())
186
+ where id = $1 and app_id = $2 and lower(username) = lower($3) returning url`,
187
+ [id, ctx.app.id, ctx.user],
188
+ )
189
+ : null;
190
+ if (!row) {
191
+ ctx.session.state.__FLASH = ctx.locale.t('notify.gone');
192
+ await saveState(ctx.session);
193
+ return reply.redirect(`${ctx.base}/notifications`, 303);
194
+ }
195
+ // the link was made under the application's alias then; it goes to the same page now
196
+ const rest = row.url?.replace(/^\/a\/[^/]+\//, '');
197
+ return reply.redirect(rest ? `${ctx.base}/${rest}` : `${ctx.base}/notifications`, 303);
198
+ });
199
+
200
+ app.post('/a/:alias/notifications/read', async (req: Req, reply) => {
201
+ const ctx = await context(req, reply);
202
+ if (!ctx) return;
203
+ const next = safeNext(ctx.app, typeof req.body?.next === 'string' ? req.body.next : `${ctx.base}/notifications`);
204
+ if (req.body?.__csrf !== ctx.session.csrf_token) return reply.redirect(next, 303);
205
+ await runtime.query(
206
+ 'update meta.notification set read_at = now() where app_id = $1 and lower(username) = lower($2) and read_at is null',
207
+ [ctx.app.id, ctx.user],
208
+ );
209
+ ctx.session.state.__FLASH = ctx.locale.t('notify.marked');
210
+ await saveState(ctx.session);
211
+ return reply.redirect(next, 303);
212
+ });
213
+
214
+ // live badges: what the page shows now; never extends the session
215
+ app.get('/a/:alias/:page/live', async (req: Req, reply) => {
216
+ const ctx = await loadContext(req, reply, { json: true, touch: false });
217
+ if (!ctx) return;
218
+ reply.header('cache-control', 'private, no-store');
219
+ if (!ctx.app.live_badges_s) return reply.code(404).send({ error: 'not found' });
220
+ try {
221
+ const state = await appTx(txContext(ctx), async (c) => {
222
+ ctx.client = c;
223
+ await checkPageAccess(ctx);
224
+ ctx.vis = await computeVisibility(ctx);
225
+ return liveState(ctx);
226
+ });
227
+ if (hasBell(ctx)) {
228
+ const unread = await unreadCount(ctx);
229
+ state.bell = { unread, label: bellLabel(ctx, unread), panel: (await bellPanel(ctx, unread)).toString() };
230
+ }
231
+ return reply.send(state);
232
+ } catch (e) {
233
+ if (e instanceof Forbidden) return reply.code(403).send({ error: e.message });
234
+ return reply.code(400).send({ error: await publicError(ctx, e, 'live badges') });
235
+ }
236
+ });
237
+ }
@@ -176,7 +176,7 @@ export function chainProblems(conf: unknown): string[] {
176
176
  }
177
177
 
178
178
  /** Process types a background chain can run (nothing that needs the request: forms, grids, uploads, files). */
179
- export const BACKGROUND_TYPES = new Set<Process['type']>(['sql', 'invoke_api', 'workflow', 'chain', 'send_push', 'send_email']);
179
+ export const BACKGROUND_TYPES = new Set<Process['type']>(['sql', 'invoke_api', 'workflow', 'chain', 'send_push', 'send_email', 'send_notification']);
180
180
 
181
181
  // ---------------------------------------------------------------- workflows
182
182
 
@@ -248,6 +248,7 @@ export function processProblems(type: string, conf: unknown): string[] {
248
248
  if (type === 'chain') return chainProblems(conf);
249
249
  if (type === 'workflow') return workflowProblems(conf);
250
250
  if (type === 'send_push') return sendPushProblems(conf);
251
+ if (type === 'send_notification') return sendNotificationProblems(conf);
251
252
  if (type === 'send_email') return sendEmailProblems(conf);
252
253
  if (DECLARATIVE_PROCESS_TYPES.has(type)) return declarativeProblems(type, conf);
253
254
  return [];
@@ -298,6 +299,49 @@ export async function sendPushProcess(ctx: PageContext, p: Process): Promise<str
298
299
  return p.success_message ?? null;
299
300
  }
300
301
 
302
+ // ---------------------------------------------------------------- send_notification (154)
303
+
304
+ interface SendNotificationConfig {
305
+ to: string;
306
+ title: string;
307
+ body?: string;
308
+ page?: number;
309
+ items?: Record<string, string>;
310
+ icon?: string;
311
+ push?: boolean;
312
+ }
313
+
314
+ export function sendNotificationProblems(conf: unknown): string[] {
315
+ const c = (conf ?? {}) as Record<string, any>;
316
+ const out: string[] = [];
317
+ if (typeof c.to !== 'string' || !c.to.trim()) out.push('"to" is the user name, e.g. "&P5_OWNER." or "&APP_USER.".');
318
+ if (typeof c.title !== 'string' || !c.title.trim()) out.push('"title" is the notification\'s title.');
319
+ if (c.body !== undefined && typeof c.body !== 'string') out.push('"body" is text.');
320
+ if (c.page !== undefined && !(Number.isInteger(c.page) && c.page > 0)) out.push('"page" is a page number of this application.');
321
+ if (c.items !== undefined && (typeof c.items !== 'object' || Array.isArray(c.items) || c.items === null || Object.entries(c.items).some(([k, v]) => !ITEM.test(k.toUpperCase()) || typeof v !== 'string')))
322
+ out.push('"items" maps item names of the page to values, e.g. {"P5_ID": "&P5_ID."}.');
323
+ if (c.items !== undefined && c.page === undefined) out.push('"items" needs a "page".');
324
+ if (c.icon !== undefined && (typeof c.icon !== 'string' || !/^[a-z0-9-]{1,40}$/.test(c.icon))) out.push('"icon" is an icon name, e.g. "bell" or "check".');
325
+ if (c.push !== undefined && typeof c.push !== 'boolean') out.push('"push" is true or false (also to the user\'s devices when push notifications are on).');
326
+ return out;
327
+ }
328
+
329
+ /**
330
+ * send_notification: adds an in-app notification (the bell) for a user with meta.notify, in the page's
331
+ * transaction, so it exists only when the submit commits. Values take &ITEM. substitutions.
332
+ */
333
+ export async function sendNotificationProcess(ctx: PageContext, p: Process): Promise<string | null> {
334
+ const conf = (p.config ?? {}) as unknown as SendNotificationConfig;
335
+ const problems = sendNotificationProblems(conf);
336
+ if (problems.length) throw new Error(`Process "${p.name}": ${problems.join(' ')}`);
337
+ const value = (s: string | undefined) => (s === undefined ? null : substitute(s, ctx, (x) => x));
338
+ const items = Object.fromEntries(Object.entries(conf.items ?? {}).map(([k, v]) => [k.toUpperCase(), value(v) ?? '']));
339
+ await ctx.client!.query('select meta.notify($1, $2, $3, $4, $5::jsonb, $6, $7)', [
340
+ (value(conf.to) ?? '').trim(), value(conf.title)?.slice(0, 200), value(conf.body)?.slice(0, 1000) || null, conf.page ?? null, JSON.stringify(items), conf.icon ?? null, conf.push === true,
341
+ ]);
342
+ return p.success_message ?? null;
343
+ }
344
+
301
345
  // ---------------------------------------------------------------- send_email (081)
302
346
 
303
347
  interface SendEmailConfig {
@@ -16,7 +16,7 @@ import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './c
16
16
  import { databaseTimeZone } from './locale.ts';
17
17
  import { renderFacets } from './facets.ts';
18
18
  import { renderSmartFilters } from './smart-filters.ts';
19
- import { renderListRegion } from './lists.ts';
19
+ import { queryBadge, renderListRegion } from './lists.ts';
20
20
  import { renderDisplaySelector } from './display-selector.ts';
21
21
  import { renderGrid } from './grid.ts';
22
22
  import { masterItemOf, mastersOf } from './master-detail.ts';
@@ -44,22 +44,18 @@ import { renderBreadcrumbRegion, renderHelpTextRegion } from './page-regions.ts'
44
44
 
45
45
  /** A button's badge: the badge query's first value, or the badge text with &ITEM. substitutions. */
46
46
  async function badgeOf(ctx: PageContext, b: Button): Promise<Raw | ''> {
47
- let value: string | null = null;
48
- if (b.badge_query?.trim()) {
49
- const c = ctx.client!;
50
- const sql = stripSemicolon(applyBinds(b.badge_query, bindValues(ctx)));
51
- try {
52
- const res = await savepoint(c, () => c.query({ text: sql, rowMode: 'array' }));
53
- const v = res.rows[0]?.[0];
54
- value = v === null || v === undefined ? null : String(v);
55
- } catch (e) {
56
- ctx.errors.page.push(await publicError(ctx, e, `badge of button ${b.name}`));
57
- }
58
- } else if (b.badge) value = substitute(b.badge, ctx, (x) => x);
59
- value = value?.trim().slice(0, 100) || null;
60
- return value ? html` <span class="btn-badge">${value}</span>` : '';
47
+ const value = b.badge_query?.trim()
48
+ ? await queryBadge(ctx, b.badge_query, `badge of button ${b.name}`)
49
+ : b.badge ? substitute(b.badge, ctx, (x) => x).trim().slice(0, 100) || null : null;
50
+ // (154) with live badges, a badge from a query keeps a place the refresh can fill
51
+ const live = ctx.app.live_badges_s && b.badge_query?.trim() ? html` data-badge="${buttonBadgeKey(b)}"` : '';
52
+ if (!value) return live ? html` <span class="btn-badge"${live} hidden></span>` : '';
53
+ return html` <span class="btn-badge"${live}>${value}</span>`;
61
54
  }
62
55
 
56
+ /** (154) The data-badge key of a button's badge (live badges). */
57
+ export const buttonBadgeKey = (b: { id: number }) => `btn:${b.id}`;
58
+
63
59
  /**
64
60
  * A menu button: a <details> dropdown (works without JavaScript) with links
65
61
  * to pages (signed item values, only pages the user may open), submit
@@ -19,6 +19,7 @@ import { runtime, savepoint } from '../db.ts';
19
19
  import { renderItems } from './items.ts';
20
20
  import { buttonsFor, renderRegion } from './regions.ts';
21
21
  import { listTree, navbarMarkup, navMarkup } from './lists.ts';
22
+ import { bellMarkup } from './notifications.ts';
22
23
  import { aiInputs, aiOutputs, aiProcessOf } from './ai.ts';
23
24
  import { frameOrigins } from './url-region.ts';
24
25
  import { breadcrumbLinks, hasBreadcrumbRegion } from './page-regions.ts';
@@ -30,6 +31,9 @@ import { sessionLimits } from '../session.ts';
30
31
 
31
32
  const list = (s: string | null) => (s ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean);
32
33
 
34
+ /** (161) events of grid, calendar, map and tree regions (their value: see public/app.js, pgkiln:component) */
35
+ export const COMPONENT_EVENTS = new Set(['grid_row_init', 'grid_save', 'calendar_date_select', 'calendar_view_change', 'map_object_click', 'map_change', 'tree_selection_change']);
36
+
33
37
  /** Client-side definition of the page's dynamic actions (never includes SQL). */
34
38
  function dynamicActionsJson(ctx: PageContext) {
35
39
  return ctx.page.dynamic_actions
@@ -53,6 +57,8 @@ function dynamicActionsJson(ctx: PageContext) {
53
57
  ...(d.action === 'plugin' ? pluginAction(ctx, d.code, d.config) : {}),
54
58
  // (156) set_style: checked properties and values; open_report_dialog: the dialog's key (checked by the database)
55
59
  ...(d.action === 'set_style' ? { styles: safeStyles(d.config) } : {}),
60
+ // (161) set_value without SQL on a component event: the event's value (a date, node id, …) into the affected items
61
+ ...(d.action === 'set_value' && !d.code?.trim() && COMPONENT_EVENTS.has(d.event) ? { eventValue: true } : {}),
56
62
  ...(d.action === 'open_report_dialog' ? { dialog: /^[a-z]{3,10}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null } : {}),
57
63
  }));
58
64
 
@@ -103,7 +109,8 @@ async function navTree(ctx: PageContext, topNav = false) {
103
109
  // a list as the navigation menu (falls back to the navigation entries when the list is missing)
104
110
  if (ctx.app.nav_list) {
105
111
  const nodes = await listTree(ctx, ctx.app.nav_list);
106
- if (nodes) return navMarkup(nodes, topNav);
112
+ // (154) with live badges, every entry's badge has a key the refresh finds it by
113
+ if (nodes) return navMarkup(nodes, topNav, ctx.app.live_badges_s ? 'n:' : undefined);
107
114
  }
108
115
  const current = new Set<number>();
109
116
  for (let p: number | null | undefined = ctx.page.page_no, guard = 0; p && guard < 10; guard++) {
@@ -262,7 +269,9 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
262
269
  // (084) the Feedback entry, after the application's own navigation bar list
263
270
  const feedback = feedbackNode(ctx);
264
271
  const navbarNodes = listNodes || feedback ? [...(listNodes ?? []), ...(feedback ? [feedback] : [])] : null;
265
- const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar')) : '';
272
+ const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar'), ctx.app.live_badges_s ? 'b:' : undefined) : '';
273
+ // (154) the notification bell
274
+ const bell = await bellMarkup(ctx);
266
275
  // (sprint 72) the account's display name in the user menu
267
276
  const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
268
277
  // (sprint 88, GEN-4; APEX: the page footer) under the content: the application's version and "Back to top" (to the header, #top)
@@ -275,6 +284,7 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
275
284
  <span class="t-spacer"></span>
276
285
  ${ctx.app.pwa ? html`<button type="button" class="icon-button t-install" data-pwa-install hidden title="${t('pwa.install')}" aria-label="${t('pwa.install')}">${icon('download')}</button>` : ''}
277
286
  ${navbar}
287
+ ${bell}
278
288
  ${ctx.app.authentication !== 'none'
279
289
  ? signedIn
280
290
  ? html`<details class="menu t-user">
@@ -309,7 +319,11 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
309
319
  </div>
310
320
  ${devbarHtml(ctx)}`,
311
321
  `t-app${topNav ? ' nav-top' : ''}${appearance ? ` nav-pillar nav-${appearance === 'pillar_dark' ? 'pillar-dark' : 'pillar-light'}` : ''}`,
312
- { 'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user), ...(await pushBody(ctx.app, ctx.session.username)) },
322
+ {
323
+ 'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user), ...(await pushBody(ctx.app, ctx.session.username)),
324
+ // (154) live badges: the page asks for its badges again every so many seconds
325
+ ...(ctx.app.live_badges_s ? { 'data-live': String(ctx.app.live_badges_s) } : {}),
326
+ },
313
327
  html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
314
328
  root,
315
329
  );
@@ -422,7 +436,7 @@ export async function renderPage(ctx: PageContext) {
422
436
  ${ctx.detached}
423
437
  </div>
424
438
  <script type="application/json" id="pgkiln-meta">${raw(
425
- JSON.stringify({ csrf: ctx.session.csrf_token, das, texts: clientTexts(ctx), dialogs: dialogShapes(ctx), ...timeZoneMeta(ctx), session: sessionMeta(ctx) }).replace(/</g, '\\u003c'),
439
+ JSON.stringify({ csrf: ctx.session.csrf_token, das, ...(ctx.savedGrids?.length ? { savedGrids: ctx.savedGrids } : {}), texts: clientTexts(ctx), dialogs: dialogShapes(ctx), ...timeZoneMeta(ctx), session: sessionMeta(ctx) }).replace(/</g, '\\u003c'),
426
440
  )}</script>`;
427
441
  return chrome(ctx, main, title);
428
442
  }
@@ -229,13 +229,21 @@ export const txContext = (ctx: PageContext) => ({
229
229
  });
230
230
 
231
231
  /** Load app, page, session and user; handles 404 and the login redirect. */
232
- export async function loadContext(req: Req, reply: FastifyReply, { json = false, pageNo: fixedPage }: { json?: boolean; pageNo?: 'home' } = {}): Promise<PageContext | null> {
232
+ export async function loadContext(
233
+ req: Req,
234
+ reply: FastifyReply,
235
+ { json = false, pageNo: fixedPage, touch = true }: { json?: boolean; pageNo?: 'home'; touch?: boolean } = {},
236
+ ): Promise<PageContext | null> {
233
237
  const app = await loadApp(req.params.alias);
234
238
  if (!app) return simplePage(reply, 404, english('error.not_found'), english('error.app_not_found', { app: req.params.alias })), null;
235
239
  const base = `/a/${app.alias}`;
236
- let session = await getSession(req, reply, app.id, base);
240
+ // (154) touch false: a request the page makes by itself (live badges) neither keeps an idle session
241
+ // alive nor starts one (no sign-in from a remembered browser either)
242
+ const existing = touch ? await getSession(req, reply, app.id, base) : await getSession(req, reply, app.id, base, { touch: false });
243
+ if (!existing) return reply.code(401).send({ error: english('error.session_ended') }), null;
244
+ let session = existing;
237
245
  // the session ended, but the browser was remembered: sign in again silently (a new token each time)
238
- if (app.authentication === 'app_users' && !session.username) {
246
+ if (touch && app.authentication === 'app_users' && !session.username) {
239
247
  const remembered = await useRemember(req, reply, app);
240
248
  // (sprint 130) one made before the application asked this user for a second step doesn't stand in for it
241
249
  if (remembered && (await carriesSecondStep(app, remembered.username, remembered.roles, remembered.method)))
@@ -245,7 +253,7 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
245
253
  });
246
254
  }
247
255
  // (067) another application of the session sharing group was signed in to: sign in here too
248
- if (!session.username && sharingGroup(app)) {
256
+ if (touch && !session.username && sharingGroup(app)) {
249
257
  const shared = await useShared(req, reply, app);
250
258
  if (shared && (await carriesSecondStep(app, shared.username, shared.roles, shared.method)))
251
259
  session = await signIn(req, reply, app, session, shared.username, {
@@ -443,6 +451,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
443
451
  if (flash) ctx.messages.push(flash);
444
452
  const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
445
453
  if (flashError) ctx.errors.page.push(flashError);
454
+ const saved = takeFlash(ctx.session, '__GRID_SAVED');
455
+ if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
446
456
  // Actions → Download CSV / Excel / PDF: r<region id>_csv=1, _xlsx=1 or _pdf=1
447
457
  const downloadKey = [...ctx.params.keys()].find((k) => /^r\d+_(csv|xlsx|pdf)$/.test(k));
448
458
  // a document template: ?doc=NAME (see documents.ts)
@@ -629,6 +639,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
629
639
  return reply.headers(downloadHeaders(ctx.download)).send(ctx.download.content);
630
640
  }
631
641
  if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
642
+ // (161) the page that comes back runs the saved grids' grid_save dynamic actions
643
+ if (ctx.savedGrids?.length && !ctx.dialog) ctx.session.state.__GRID_SAVED = ctx.savedGrids.join(',');
632
644
  await saveState(ctx.session);
633
645
  if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx, ctx.closeDialog));
634
646
  // (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
@@ -19,7 +19,8 @@ import { languagePicker } from './render.ts';
19
19
 
20
20
  /** Where to go after signing in: a path of this application, else its home page. */
21
21
  export const safeNext = (app: App, next: string | undefined) =>
22
- next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') ? next : `/a/${app.alias}/${app.home_page}`;
22
+ // (sprint 165) no dot segments either: /a/<alias>/../../builder stays inside the application
23
+ next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') && !/\/(\.|%2e){1,2}(\/|$|\?|#)/i.test(next) ? next : `/a/${app.alias}/${app.home_page}`;
23
24
 
24
25
  /** "Forgot password?" is offered: turned on, app users with passwords, an enabled mail service. */
25
26
  export async function passwordResetAvailable(app: App) {
@@ -64,14 +64,15 @@ export async function renderTree(ctx: PageContext, r: Region): Promise<Raw> {
64
64
  });
65
65
  label = html`<a ${linkAttrs(ctx, link.page, items)}>${label}</a>`;
66
66
  }
67
- if (!n.children.length) return html`<li class="tree-leaf">${label}</li>`;
67
+ // (161) data-node: what a tree_selection_change dynamic action's condition sees
68
+ if (!n.children.length) return html`<li class="tree-leaf" data-node="${n.id}">${label}</li>`;
68
69
  const open = depth < expanded ? raw(' open') : '';
69
70
  const children = html`<ul>${n.children.map((ch) => draw(ch, depth + 1))}</ul>`;
70
71
  // a linked branch: the link is a row of its own and the disclosure only toggles (no link inside <summary>)
71
72
  if (linkOk && link)
72
- return html`<li class="tree-branch"><span class="tree-row">${label} <span class="tree-count" aria-hidden="true">${n.children.length}</span></span>
73
+ return html`<li class="tree-branch" data-node="${n.id}"><span class="tree-row">${label} <span class="tree-count" aria-hidden="true">${n.children.length}</span></span>
73
74
  <details${open}><summary><span class="sr-only">${n.label} (${n.children.length})</span></summary>${children}</details></li>`;
74
- return html`<li><details${open}><summary>${label} <span class="tree-count">${n.children.length}</span></summary>
75
+ return html`<li data-node="${n.id}"><details${open}><summary>${label} <span class="tree-count">${n.children.length}</span></summary>
75
76
  ${children}</details></li>`;
76
77
  };
77
78
  return html`<ul class="tree-view">${roots.map((n) => draw(n, 0))}</ul>`;
package/src/session.ts CHANGED
@@ -53,10 +53,11 @@ export const cookieName = (appId: number | null) => (appId === null ? 'pgkiln_de
53
53
  /** The idle and maximum session length (minutes, hours), for lookups outside this file. */
54
54
  export const sessionLimits = () => ({ idleMinutes: IDLE_MINUTES(), maxHours: MAX_HOURS() });
55
55
 
56
- async function find(token: string | undefined, appId: number | null) {
56
+ /** A live session of the token; touch (the default): the request counts as activity (last_seen). */
57
+ async function find(token: string | undefined, appId: number | null, touch = true) {
57
58
  if (!token || token.length > 100) return undefined;
58
59
  return runtime.one<Session>(
59
- `update meta.session set last_seen = now()
60
+ `update meta.session set last_seen = ${touch ? 'now()' : 'last_seen'}
60
61
  where token_hash = $1 and app_id is not distinct from $2
61
62
  and last_seen > now() - make_interval(mins => $3)
62
63
  and created_at > now() - make_interval(hours => $4)
@@ -84,8 +85,16 @@ export async function createSession(reply: FastifyReply, appId: number | null, p
84
85
  return { ...s, isNew: true };
85
86
  }
86
87
 
87
- export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string) {
88
- return (await find(req.cookies[cookieName(appId)], appId)) ?? createSession(reply, appId, path);
88
+ /**
89
+ * The request's session, or a new one. (154) touch false: a request the browser makes by itself (live
90
+ * badges) doesn't keep an idle session alive, and makes no new session when there is none (null).
91
+ */
92
+ export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string): Promise<Session>;
93
+ export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string, opts: { touch: false }): Promise<Session | null>;
94
+ export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string, opts?: { touch: false }) {
95
+ const found = await find(req.cookies[cookieName(appId)], appId, !opts);
96
+ if (found || opts) return found ?? null;
97
+ return createSession(reply, appId, path);
89
98
  }
90
99
 
91
100
  export async function saveState(s: Session) {