pgkiln 0.83.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 (62) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +2 -1
  3. package/docs/guide/03-builder.md +2 -0
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +7 -0
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +2 -0
  15. package/src/builder/code-editor.ts +1 -1
  16. package/src/builder/components.ts +10 -6
  17. package/src/builder/i18n/de.ts +8 -0
  18. package/src/builder/i18n/en.ts +8 -0
  19. package/src/builder/i18n/es.ts +8 -0
  20. package/src/builder/i18n/fr.ts +8 -0
  21. package/src/builder/i18n/it.ts +8 -0
  22. package/src/builder/i18n/ja.ts +8 -0
  23. package/src/builder/i18n/ko.ts +8 -0
  24. package/src/builder/i18n/nl.ts +8 -0
  25. package/src/builder/i18n/pt.ts +8 -0
  26. package/src/builder/i18n/zh-cn.ts +8 -0
  27. package/src/builder/i18n/zh-tw.ts +8 -0
  28. package/src/builder/notification-settings.ts +27 -0
  29. package/src/builder/routes.ts +3 -0
  30. package/src/cli/replace.ts +2 -0
  31. package/src/i18n/ar.ts +9 -0
  32. package/src/i18n/cs.ts +9 -0
  33. package/src/i18n/da.ts +9 -0
  34. package/src/i18n/de.ts +9 -0
  35. package/src/i18n/el.ts +9 -0
  36. package/src/i18n/es.ts +9 -0
  37. package/src/i18n/fi.ts +9 -0
  38. package/src/i18n/fr.ts +9 -0
  39. package/src/i18n/he.ts +9 -0
  40. package/src/i18n/it.ts +9 -0
  41. package/src/i18n/ja.ts +9 -0
  42. package/src/i18n/ko.ts +9 -0
  43. package/src/i18n/nb.ts +9 -0
  44. package/src/i18n/pl.ts +9 -0
  45. package/src/i18n/pt.ts +9 -0
  46. package/src/i18n/ru.ts +9 -0
  47. package/src/i18n/sv.ts +9 -0
  48. package/src/i18n/tr.ts +9 -0
  49. package/src/i18n/uk.ts +9 -0
  50. package/src/i18n/zh.ts +9 -0
  51. package/src/i18n.ts +18 -0
  52. package/src/metadata.ts +9 -3
  53. package/src/runtime/engine.ts +2 -1
  54. package/src/runtime/feedback.ts +1 -1
  55. package/src/runtime/lists.ts +43 -11
  56. package/src/runtime/notifications.ts +237 -0
  57. package/src/runtime/processes.ts +45 -1
  58. package/src/runtime/regions.ts +11 -15
  59. package/src/runtime/render.ts +12 -3
  60. package/src/runtime/routes.ts +12 -4
  61. package/src/runtime/signin.ts +2 -1
  62. package/src/session.ts +13 -4
@@ -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';
@@ -108,7 +109,8 @@ async function navTree(ctx: PageContext, topNav = false) {
108
109
  // a list as the navigation menu (falls back to the navigation entries when the list is missing)
109
110
  if (ctx.app.nav_list) {
110
111
  const nodes = await listTree(ctx, ctx.app.nav_list);
111
- 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);
112
114
  }
113
115
  const current = new Set<number>();
114
116
  for (let p: number | null | undefined = ctx.page.page_no, guard = 0; p && guard < 10; guard++) {
@@ -267,7 +269,9 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
267
269
  // (084) the Feedback entry, after the application's own navigation bar list
268
270
  const feedback = feedbackNode(ctx);
269
271
  const navbarNodes = listNodes || feedback ? [...(listNodes ?? []), ...(feedback ? [feedback] : [])] : null;
270
- 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);
271
275
  // (sprint 72) the account's display name in the user menu
272
276
  const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
273
277
  // (sprint 88, GEN-4; APEX: the page footer) under the content: the application's version and "Back to top" (to the header, #top)
@@ -280,6 +284,7 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
280
284
  <span class="t-spacer"></span>
281
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>` : ''}
282
286
  ${navbar}
287
+ ${bell}
283
288
  ${ctx.app.authentication !== 'none'
284
289
  ? signedIn
285
290
  ? html`<details class="menu t-user">
@@ -314,7 +319,11 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
314
319
  </div>
315
320
  ${devbarHtml(ctx)}`,
316
321
  `t-app${topNav ? ' nav-top' : ''}${appearance ? ` nav-pillar nav-${appearance === 'pillar_dark' ? 'pillar-dark' : 'pillar-light'}` : ''}`,
317
- { '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
+ },
318
327
  html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
319
328
  root,
320
329
  );
@@ -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, {
@@ -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) {
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) {