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
package/src/i18n/pl.ts CHANGED
@@ -310,6 +310,15 @@ export const pl: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Przeglądarki, w których wybrano „Nie wylogowuj mnie”: {count}.",
311
311
  'account.forget_devices': "Wyloguj na wszystkich urządzeniach",
312
312
  'account.devices_forgotten': "Wylogowano na wszystkich urządzeniach.",
313
+ 'notify.title': "Powiadomienia",
314
+ 'notify.none': "Brak powiadomień.",
315
+ 'notify.mark_all_read': "Oznacz wszystkie jako przeczytane",
316
+ 'notify.all': "Wszystkie powiadomienia",
317
+ 'notify.marked': "Powiadomienia oznaczono jako przeczytane.",
318
+ 'notify.gone': "To powiadomienie już nie istnieje.",
319
+ 'notify.bell_one': "Powiadomienia: 1 nieprzeczytane",
320
+ 'notify.bell_n': "Powiadomienia: nieprzeczytane: {n}",
321
+ 'notify.unread_one': "Nieprzeczytane",
313
322
  'push.title': "Powiadomienia",
314
323
  'push.help': "Otrzymuj powiadomienia z tej aplikacji na tym urządzeniu, także gdy aplikacja jest zamknięta.",
315
324
  'push.devices': "Urządzenia z powiadomieniami: {n}.",
package/src/i18n/pt.ts CHANGED
@@ -310,6 +310,15 @@ export const pt: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Navegadores onde escolheu “Manter a sessão iniciada”: {count}.",
311
311
  'account.forget_devices': "Terminar sessão em todos os dispositivos",
312
312
  'account.devices_forgotten': "Sessão terminada em todos os dispositivos.",
313
+ 'notify.title': "Notificações",
314
+ 'notify.none': "Sem notificações.",
315
+ 'notify.mark_all_read': "Marcar todas como lidas",
316
+ 'notify.all': "Todas as notificações",
317
+ 'notify.marked': "Notificações marcadas como lidas.",
318
+ 'notify.gone': "Esta notificação já não existe.",
319
+ 'notify.bell_one': "Notificações: 1 por ler",
320
+ 'notify.bell_n': "Notificações: {n} por ler",
321
+ 'notify.unread_one': "Por ler",
313
322
  'push.title': "Notificações",
314
323
  'push.help': "Receba notificações desta aplicação neste dispositivo, mesmo com a aplicação fechada.",
315
324
  'push.devices': "Dispositivos com notificações: {n}.",
package/src/i18n/ru.ts CHANGED
@@ -310,6 +310,15 @@ export const ru: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Браузеров, где выбрано «Не выходить»: {count}.",
311
311
  'account.forget_devices': "Выйти на всех устройствах",
312
312
  'account.devices_forgotten': "Выход выполнен на всех устройствах.",
313
+ 'notify.title': "Уведомления",
314
+ 'notify.none': "Уведомлений нет.",
315
+ 'notify.mark_all_read': "Отметить все как прочитанные",
316
+ 'notify.all': "Все уведомления",
317
+ 'notify.marked': "Уведомления отмечены как прочитанные.",
318
+ 'notify.gone': "Этого уведомления больше нет.",
319
+ 'notify.bell_one': "Уведомления: 1 непрочитанное",
320
+ 'notify.bell_n': "Уведомления: непрочитанных — {n}",
321
+ 'notify.unread_one': "Непрочитанное",
313
322
  'push.title': "Уведомления",
314
323
  'push.help': "Получайте уведомления этого приложения на этом устройстве, даже когда приложение закрыто.",
315
324
  'push.devices': "Устройства с уведомлениями: {n}.",
package/src/i18n/sv.ts CHANGED
@@ -310,6 +310,15 @@ export const sv: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Webbläsare där du valde ”Håll mig inloggad”: {count}.",
311
311
  'account.forget_devices': "Logga ut på alla enheter",
312
312
  'account.devices_forgotten': "Utloggad på alla enheter.",
313
+ 'notify.title': "Aviseringar",
314
+ 'notify.none': "Inga aviseringar.",
315
+ 'notify.mark_all_read': "Markera alla som lästa",
316
+ 'notify.all': "Alla aviseringar",
317
+ 'notify.marked': "Aviseringarna har markerats som lästa.",
318
+ 'notify.gone': "Den här aviseringen finns inte längre.",
319
+ 'notify.bell_one': "Aviseringar: 1 oläst",
320
+ 'notify.bell_n': "Aviseringar: {n} olästa",
321
+ 'notify.unread_one': "Oläst",
313
322
  'push.title': "Aviseringar",
314
323
  'push.help': "Få aviseringar från den här appen på den här enheten, även när appen är stängd.",
315
324
  'push.devices': "Enheter med aviseringar: {n}.",
package/src/i18n/tr.ts CHANGED
@@ -310,6 +310,15 @@ export const tr: Record<MessageKey, string> = {
310
310
  'account.devices_help': "“Oturumum açık kalsın” seçtiğiniz tarayıcılar: {count}.",
311
311
  'account.forget_devices': "Tüm cihazlarda oturumu kapat",
312
312
  'account.devices_forgotten': "Tüm cihazlarda oturum kapatıldı.",
313
+ 'notify.title': "Bildirimler",
314
+ 'notify.none': "Bildirim yok.",
315
+ 'notify.mark_all_read': "Tümünü okundu olarak işaretle",
316
+ 'notify.all': "Tüm bildirimler",
317
+ 'notify.marked': "Bildirimler okundu olarak işaretlendi.",
318
+ 'notify.gone': "Bu bildirim artık yok.",
319
+ 'notify.bell_one': "Bildirimler: 1 okunmamış",
320
+ 'notify.bell_n': "Bildirimler: {n} okunmamış",
321
+ 'notify.unread_one': "Okunmamış",
313
322
  'push.title': "Bildirimler",
314
323
  'push.help': "Uygulama kapalıyken de bu cihazda bu uygulamanın bildirimlerini alın.",
315
324
  'push.devices': "Bildirim alan cihazlar: {n}.",
package/src/i18n/uk.ts CHANGED
@@ -310,6 +310,15 @@ export const uk: Record<MessageKey, string> = {
310
310
  'account.devices_help': "Браузерів, де вибрано «Не виходити»: {count}.",
311
311
  'account.forget_devices': "Вийти на всіх пристроях",
312
312
  'account.devices_forgotten': "Вихід виконано на всіх пристроях.",
313
+ 'notify.title': "Сповіщення",
314
+ 'notify.none': "Сповіщень немає.",
315
+ 'notify.mark_all_read': "Позначити всі як прочитані",
316
+ 'notify.all': "Усі сповіщення",
317
+ 'notify.marked': "Сповіщення позначено як прочитані.",
318
+ 'notify.gone': "Цього сповіщення більше немає.",
319
+ 'notify.bell_one': "Сповіщення: 1 непрочитане",
320
+ 'notify.bell_n': "Сповіщення: непрочитаних — {n}",
321
+ 'notify.unread_one': "Непрочитане",
313
322
  'push.title': "Сповіщення",
314
323
  'push.help': "Отримуйте сповіщення цього застосунку на цьому пристрої, навіть коли застосунок закрито.",
315
324
  'push.devices': "Пристрої зі сповіщеннями: {n}.",
package/src/i18n/zh.ts CHANGED
@@ -310,6 +310,15 @@ export const zh: Record<MessageKey, string> = {
310
310
  'account.devices_help': "您选择了“保持登录”的浏览器:{count}。",
311
311
  'account.forget_devices': "在所有设备上退出登录",
312
312
  'account.devices_forgotten': "已在所有设备上退出登录。",
313
+ 'notify.title': "通知",
314
+ 'notify.none': "没有通知。",
315
+ 'notify.mark_all_read': "全部标为已读",
316
+ 'notify.all': "所有通知",
317
+ 'notify.marked': "通知已标为已读。",
318
+ 'notify.gone': "此通知已不存在。",
319
+ 'notify.bell_one': "通知:1 条未读",
320
+ 'notify.bell_n': "通知:{n} 条未读",
321
+ 'notify.unread_one': "未读",
313
322
  'push.title': "通知",
314
323
  'push.help': "在此设备上接收此应用的通知,即使应用已关闭。",
315
324
  'push.devices': "接收通知的设备:{n}。",
package/src/i18n.ts CHANGED
@@ -337,6 +337,15 @@ const en = {
337
337
  'account.devices_help': 'Browsers where you chose "Keep me signed in": {count}.',
338
338
  'account.forget_devices': 'Sign out on all devices',
339
339
  'account.devices_forgotten': 'Signed out on all devices.',
340
+ 'notify.title': "Notifications",
341
+ 'notify.none': "No notifications.",
342
+ 'notify.mark_all_read': "Mark all read",
343
+ 'notify.all': "All notifications",
344
+ 'notify.marked': "Notifications marked as read.",
345
+ 'notify.gone': "This notification no longer exists.",
346
+ 'notify.bell_one': "Notifications: 1 unread",
347
+ 'notify.bell_n': "Notifications: {n} unread",
348
+ 'notify.unread_one': "Unread",
340
349
  'push.title': "Notifications",
341
350
  'push.help': "Get notifications from this app on this device, also when the app is closed.",
342
351
  'push.devices': "Devices with notifications: {n}.",
@@ -1247,6 +1256,15 @@ const nl: Record<MessageKey, string> = {
1247
1256
  'account.devices_help': 'Browsers waarin je "Ingelogd blijven" koos: {count}.',
1248
1257
  'account.forget_devices': 'Overal afmelden',
1249
1258
  'account.devices_forgotten': 'Op alle apparaten afgemeld.',
1259
+ 'notify.title': "Meldingen",
1260
+ 'notify.none': "Geen meldingen.",
1261
+ 'notify.mark_all_read': "Alles gelezen",
1262
+ 'notify.all': "Alle meldingen",
1263
+ 'notify.marked': "Meldingen als gelezen gemarkeerd.",
1264
+ 'notify.gone': "Deze melding bestaat niet meer.",
1265
+ 'notify.bell_one': "Meldingen: 1 ongelezen",
1266
+ 'notify.bell_n': "Meldingen: {n} ongelezen",
1267
+ 'notify.unread_one': "Ongelezen",
1250
1268
  'push.title': "Meldingen",
1251
1269
  'push.help': "Ontvang meldingen van deze app op dit apparaat, ook als de app gesloten is.",
1252
1270
  'push.devices': "Apparaten met meldingen: {n}.",
package/src/metadata.ts CHANGED
@@ -112,6 +112,10 @@ export interface App {
112
112
  availability_users: string[];
113
113
  /** (084) users can send feedback from the navigation bar */
114
114
  feedback: boolean;
115
+ /** (154) the notification bell in the header */
116
+ notifications: boolean;
117
+ /** (154) refresh the badges every so many seconds (null: never) */
118
+ live_badges_s: number | null;
115
119
  /** (107) the application's version (Release 1.0), shown in its pages' footer */
116
120
  version: string | null;
117
121
  /** (085) schema.function(jsonb) returns jsonb that rewrites error messages; seconds of the session timeout warning */
@@ -322,7 +326,7 @@ export interface DynamicAction {
322
326
  /** (149) */
323
327
  | 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change'
324
328
  /** (153) component events: trigger = the grid, calendar, map or tree region */
325
- | 'grid_row_init' | 'grid_save' | 'calendar_date_select' | 'calendar_view_change' | 'map_object_click' | 'map_change' | 'tree_selection_change';
329
+ | 'grid_row_init' | 'grid_save' | 'calendar_date_select' | 'calendar_view_change' | 'map_object_click' | 'map_change' | 'tree_selection_change' | 'timer';
326
330
  trigger_element: string | null;
327
331
  condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | 'greater' | 'greater_equal' | 'less' | 'less_equal' | 'not_in_list' | null;
328
332
  condition_value: string | null;
@@ -383,7 +387,9 @@ export interface Process {
383
387
  type: 'form_dml' | 'grid_dml' | 'sql' | 'data_load' | 'invoke_api' | 'download' | 'chain' | 'workflow' | 'ai_generate' | 'plugin' | 'send_push' | 'send_email'
384
388
  /** (150) */
385
389
  | 'close_dialog' | 'clear_session_state' | 'reset_pagination' | 'user_preferences' | 'server_geocoding' | 'print_report'
386
- | 'human_task_create' | 'human_task_manage' | 'form_init';
390
+ | 'human_task_create' | 'human_task_manage' | 'form_init'
391
+ /** (154) */
392
+ | 'send_notification';
387
393
  region_id: number | null;
388
394
  code: string | null;
389
395
  config: Record<string, unknown> | null;
@@ -443,7 +449,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
443
449
  export async function loadApp(alias: string) {
444
450
  return runtime.one<App>(
445
451
  `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
446
- a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
452
+ a.availability, a.availability_message, a.availability_users, a.feedback, a.notifications, a.live_badges_s, a.error_function, a.session_warning_seconds, a.component_settings,
447
453
  a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
448
454
  coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
449
455
  coalesce((select jsonb_agg(jsonb_build_object('page_no', p.page_no, 'name', p.name, 'title', p.title,
@@ -18,7 +18,7 @@ import { parseDateMask } from './format.ts';
18
18
  import { conditionHolds, functionBody } from './logic.ts';
19
19
  import { runPluginProcess } from './plugins.ts';
20
20
  import { runDeclarative } from './process-types.ts';
21
- import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
21
+ import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendNotificationProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type Errors, type PageContext } from './context.ts';
23
23
 
24
24
  const ident = pg.escapeIdentifier;
@@ -621,6 +621,7 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
621
621
  case 'invoke_api': return await invokeApi(ctx, p, names);
622
622
  case 'workflow': return await workflowProcess(ctx, p, names);
623
623
  case 'send_push': return await sendPushProcess(ctx, p);
624
+ case 'send_notification': return await sendNotificationProcess(ctx, p);
624
625
  case 'send_email': return await sendEmailProcess(ctx, p);
625
626
  case 'ai_generate': return (await runAiProcess(ctx, p, names)).message;
626
627
  case 'plugin': return await runPluginProcess(ctx, p.config, p.name);
@@ -29,7 +29,7 @@ export function feedbackNode(ctx: PageContext): ListNode | null {
29
29
  if (!ctx.app.feedback || ctx.dialog) return null;
30
30
  return {
31
31
  label: ctx.locale.t('feedback.link'), href: `${ctx.base}/${ctx.page.page_no}/feedback`, modal: false, external: false,
32
- icon: 'chat', badge: null, description: null, current: false, inTrail: false, children: [],
32
+ icon: 'chat', badge: null, key: '__feedback', description: null, current: false, inTrail: false, children: [],
33
33
  };
34
34
  }
35
35
 
@@ -28,6 +28,8 @@ export interface ListNode {
28
28
  external: boolean;
29
29
  icon: string | null;
30
30
  badge: string | null;
31
+ /** (154) the entry's id (a query's id column, else its label): finds its badge again for a live refresh */
32
+ key: string;
31
33
  description: string | null;
32
34
  /** the entry's page is the current page */
33
35
  current: boolean;
@@ -50,6 +52,8 @@ interface Entry {
50
52
  url: string | null;
51
53
  icon: string | null;
52
54
  badge: string | null;
55
+ /** (154) a SELECT whose first value is the badge (static entries; wins over badge) */
56
+ badge_query?: string | null;
53
57
  description: string | null;
54
58
  condition?: string | null;
55
59
  authz?: string | null;
@@ -105,7 +109,7 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
105
109
  }
106
110
  const rows = (
107
111
  await runtime.query(
108
- `select id, parent_id, label, target_page, target_items, target_url, icon, badge, description, condition, authz
112
+ `select id, parent_id, label, target_page, target_items, target_url, icon, badge, badge_query, description, condition, authz
109
113
  from meta.list_entry where app_id = $1 and list_name = $2 and meta.build_option_on(app_id, build_option)
110
114
  order by seq, id`,
111
115
  [ctx.app.id, name.toUpperCase()],
@@ -113,10 +117,28 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
113
117
  ).rows;
114
118
  return rows.map((r) => ({
115
119
  id: r.id, parent_id: r.parent_id, label: ctx.locale.tr(r.label), page: r.target_page, items: r.target_items ?? {}, url: r.target_url,
116
- icon: r.icon, badge: r.badge, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
120
+ icon: r.icon, badge: r.badge, badge_query: r.badge_query, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
117
121
  }));
118
122
  }
119
123
 
124
+ /**
125
+ * (029, 154) A badge from SQL: the first value of the query's first row, run as the application's
126
+ * role with the page's binds; an error is a page error and no badge. Empty values give no badge.
127
+ */
128
+ export async function queryBadge(ctx: PageContext, query: string, what: string): Promise<string | null> {
129
+ const c = ctx.client;
130
+ if (!c) return null;
131
+ try {
132
+ const sql = stripSemicolon(applyBinds(query, bindValues(ctx)));
133
+ const res = await savepoint(c, () => c.query({ text: sql, rowMode: 'array' }));
134
+ const v = res.rows[0]?.[0];
135
+ return v === null || v === undefined ? null : String(v).trim().slice(0, 100) || null;
136
+ } catch (e) {
137
+ ctx.errors.page.push(await publicError(ctx, e, what));
138
+ return null;
139
+ }
140
+ }
141
+
120
142
  /** The current page and its parent pages (for "in trail"). */
121
143
  function trail(ctx: PageContext) {
122
144
  const pages = new Set<number>();
@@ -156,7 +178,10 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
156
178
  modal: !!e.page && isModal(ctx, e.page),
157
179
  external,
158
180
  icon: e.icon,
159
- badge: e.badge === null ? null : substitute(e.badge, ctx, (x) => x),
181
+ badge: e.badge_query?.trim()
182
+ ? await queryBadge(ctx, e.badge_query, `badge of list entry "${e.label}"`)
183
+ : e.badge === null ? null : substitute(e.badge, ctx, (x) => x),
184
+ key: String(e.id ?? e.label),
160
185
  description: e.description === null ? null : substitute(e.description, ctx, (x) => x),
161
186
  current: isCurrent,
162
187
  inTrail: isCurrent || (e.page !== null && current.has(e.page)) || children.some((c) => c.inTrail),
@@ -179,15 +204,22 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
179
204
 
180
205
  const linkAttrs = (n: ListNode, cls?: string) =>
181
206
  html`href="${n.href}"${cls ? html` class="${cls}"` : ''}${n.current ? raw(' aria-current="page"') : ''}${n.modal ? raw(' data-dialog') : ''}${n.external ? raw(' rel="noopener noreferrer"') : ''}`;
182
- const badge = (n: ListNode) => (n.badge !== null ? html` <span class="badge">${n.badge}</span>` : '');
207
+ /**
208
+ * An entry's badge. live: the prefix of the badge's data-badge key when the application refreshes
209
+ * badges (154); an empty badge is then a hidden placeholder the refresh can fill.
210
+ */
211
+ const badge = (n: ListNode, live?: string) =>
212
+ n.badge !== null
213
+ ? html` <span class="badge"${live ? html` data-badge="${live}${n.key}"` : ''}>${n.badge}</span>`
214
+ : live ? html` <span class="badge" data-badge="${live}${n.key}" hidden></span>` : '';
183
215
 
184
216
  /** Entries as the navigation menu (the same markup as navigation entries). */
185
- export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
217
+ export function navMarkup(nodes: ListNode[], topNav: boolean, live?: string): Raw[] {
186
218
  return nodes.map((n) => {
187
- const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n)}`;
219
+ const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n, live)}`;
188
220
  if (n.children.length)
189
221
  return html`<li><details${n.inTrail && !topNav ? raw(' open') : ''}><summary class="${n.inTrail ? 'in-trail' : null}">${label}</summary><ul>${
190
- n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav)}</ul></details></li>`;
222
+ n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav, live)}</ul></details></li>`;
191
223
  return html`<li><a ${linkAttrs(n, !n.current && n.inTrail ? 'in-trail' : undefined)}>${label}</a></li>`;
192
224
  });
193
225
  }
@@ -195,13 +227,13 @@ export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
195
227
  const hasIcon = (n: ListNode) => isIcon(n.icon);
196
228
 
197
229
  /** Entries as the navigation bar in the header: links, with a menu for entries with children (labels of entries with an icon hide on small screens). */
198
- export function navbarMarkup(nodes: ListNode[], label: string): Raw {
230
+ export function navbarMarkup(nodes: ListNode[], label: string, live?: string): Raw {
199
231
  if (!nodes.length) return raw('');
200
232
  return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
201
233
  n.children.length
202
- ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
203
- [...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c)}</a></li>`)}</ul></div></details></li>`
204
- : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
234
+ ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
235
+ [...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
236
+ : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
205
237
  }
206
238
 
207
239
  function linksMarkup(nodes: ListNode[]): Raw {
@@ -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
+ }