pgkiln 0.31.0 → 0.36.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 (131) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/db/migrations/087_login_page.sql +175 -0
  15. package/db/migrations/088_region_templates.sql +18 -0
  16. package/db/migrations/089_page_groups.sql +330 -0
  17. package/docs/apex-feature-parity.md +61 -39
  18. package/docs/guide/01-installation.md +1 -0
  19. package/docs/guide/03-builder.md +154 -4
  20. package/docs/guide/04-pages-and-regions.md +157 -3
  21. package/docs/guide/05-items.md +47 -0
  22. package/docs/guide/06-processing.md +210 -2
  23. package/docs/guide/07-dynamic-actions.md +60 -2
  24. package/docs/guide/08-security.md +56 -4
  25. package/docs/guide/09-reference.md +21 -0
  26. package/docs/guide/11-from-apex.md +9 -2
  27. package/docs/guide/12-development.md +40 -2
  28. package/docs/guide/14-globalization.md +15 -0
  29. package/docs/guide/15-extensions.md +1 -1
  30. package/docs/guide/17-mobile.md +3 -1
  31. package/docs/guide/18-cli.md +32 -1
  32. package/docs/guide/20-ai-agents.md +5 -2
  33. package/examples/hr/hr_50_search.sql +47 -0
  34. package/examples/hr/hr_51_mail.sql +70 -0
  35. package/examples/hr/hr_52_feedback.sql +15 -0
  36. package/examples/hr/hr_53_data_settings.sql +108 -0
  37. package/examples/hr/hr_54_region_templates.sql +67 -0
  38. package/examples/hr/hr_55_page_groups.sql +24 -0
  39. package/package.json +3 -1
  40. package/public/app.css +122 -3
  41. package/public/app.js +496 -13
  42. package/public/builder.css +22 -0
  43. package/public/builder.js +77 -0
  44. package/public/code-editor.css +3 -3
  45. package/public/sw.js +5 -3
  46. package/src/app.ts +10 -0
  47. package/src/appfiles.ts +13 -0
  48. package/src/automations.ts +12 -0
  49. package/src/builder/advisor.ts +68 -14
  50. package/src/builder/arrange.ts +2 -2
  51. package/src/builder/availability.ts +74 -0
  52. package/src/builder/backups.ts +134 -0
  53. package/src/builder/components.ts +123 -16
  54. package/src/builder/ddl-tools.ts +114 -0
  55. package/src/builder/designer.ts +35 -4
  56. package/src/builder/feedback.ts +82 -0
  57. package/src/builder/forms.ts +10 -5
  58. package/src/builder/home.ts +3 -0
  59. package/src/builder/locks.ts +15 -5
  60. package/src/builder/login-page.ts +82 -0
  61. package/src/builder/mail.ts +425 -0
  62. package/src/builder/newapp.ts +1 -1
  63. package/src/builder/region-help.ts +73 -0
  64. package/src/builder/region-settings.ts +19 -3
  65. package/src/builder/routes.ts +60 -5
  66. package/src/builder/search.ts +4 -4
  67. package/src/builder/shared.ts +3 -2
  68. package/src/builder/sql.ts +3 -0
  69. package/src/builder/ui.ts +9 -3
  70. package/src/cli/apps.ts +46 -5
  71. package/src/cli/main.ts +66 -5
  72. package/src/cli/mcp.ts +58 -4
  73. package/src/cli/replace.ts +40 -4
  74. package/src/ddl.ts +509 -0
  75. package/src/geocode.ts +44 -0
  76. package/src/i18n/ar.ts +56 -0
  77. package/src/i18n/cs.ts +56 -0
  78. package/src/i18n/da.ts +56 -0
  79. package/src/i18n/de.ts +56 -0
  80. package/src/i18n/el.ts +56 -0
  81. package/src/i18n/es.ts +56 -0
  82. package/src/i18n/fi.ts +56 -0
  83. package/src/i18n/fr.ts +56 -0
  84. package/src/i18n/he.ts +56 -0
  85. package/src/i18n/it.ts +56 -0
  86. package/src/i18n/ja.ts +56 -0
  87. package/src/i18n/ko.ts +56 -0
  88. package/src/i18n/nb.ts +56 -0
  89. package/src/i18n/pl.ts +56 -0
  90. package/src/i18n/pt.ts +56 -0
  91. package/src/i18n/ru.ts +56 -0
  92. package/src/i18n/sv.ts +56 -0
  93. package/src/i18n/tr.ts +56 -0
  94. package/src/i18n/uk.ts +56 -0
  95. package/src/i18n/zh.ts +56 -0
  96. package/src/i18n.ts +112 -0
  97. package/src/icons.ts +2 -1
  98. package/src/layout.ts +3 -2
  99. package/src/mail/mail.ts +148 -0
  100. package/src/mail/providers.ts +444 -0
  101. package/src/metadata.ts +34 -10
  102. package/src/runtime/ajax.ts +97 -0
  103. package/src/runtime/authz.ts +1 -1
  104. package/src/runtime/availability.ts +37 -0
  105. package/src/runtime/context.ts +46 -6
  106. package/src/runtime/data-export.ts +94 -0
  107. package/src/runtime/data-items.ts +194 -0
  108. package/src/runtime/devbar.ts +184 -0
  109. package/src/runtime/display-selector.ts +12 -4
  110. package/src/runtime/engine.ts +26 -1
  111. package/src/runtime/feedback.ts +128 -0
  112. package/src/runtime/grid.ts +170 -26
  113. package/src/runtime/items.ts +8 -0
  114. package/src/runtime/lists.ts +11 -1
  115. package/src/runtime/password-reset.ts +171 -0
  116. package/src/runtime/processes.ts +83 -2
  117. package/src/runtime/pwa.ts +7 -10
  118. package/src/runtime/regions.ts +41 -0
  119. package/src/runtime/render.ts +48 -7
  120. package/src/runtime/report-views.ts +4 -2
  121. package/src/runtime/report.ts +36 -22
  122. package/src/runtime/rest.ts +4 -0
  123. package/src/runtime/routes.ts +94 -37
  124. package/src/runtime/search.ts +252 -0
  125. package/src/runtime/signin.ts +80 -0
  126. package/src/runtime/url-region.ts +93 -0
  127. package/src/security.ts +11 -1
  128. package/src/server.ts +2 -0
  129. package/src/session.ts +8 -2
  130. package/src/static-version.ts +34 -0
  131. package/src/workingcopy.ts +2 -2
@@ -0,0 +1,184 @@
1
+ // Developer toolbar (APEX: Runtime Developer Toolbar) and session state
2
+ // viewer (APEX: Session → View session state), migration 079.
3
+ //
4
+ // A builder session hands the browser a second random token in the cookie
5
+ // pgkiln_devbar (path /a, HttpOnly, SameSite=Lax); the database keeps its
6
+ // sha256 on the builder session (meta.session.devbar_hash). A page of an
7
+ // application shows the toolbar when that token belongs to a live builder
8
+ // session of a developer who may open the application (an administrator, or
9
+ // a member of its workspace). The token does nothing else: it is not the
10
+ // builder session's token, and every builder request still needs that one.
11
+ //
12
+ // The toolbar links to the App Builder, the application and the page in the
13
+ // Page Designer, (sprint 59) Quick edit: click a region, item or button of the
14
+ // page to open it in the Page Designer (app.js; GET /builder/pages/:id/quick), shows the session state (GET /a/:alias/:page/devbar/session),
15
+ // clears the page's items, and switches debug messages on for this session
16
+ // only (POST …/devbar/debug; meta.session.state.__DEBUG_LEVEL, read by
17
+ // loadContext).
18
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
19
+ import { owner, runtime } from '../db.ts';
20
+ import { DEBUG_LEVELS } from '../debug.ts';
21
+ import { html, raw, type Raw } from '../html.ts';
22
+ import { hashToken, newToken } from '../security.ts';
23
+ import { saveState, sessionLimits, type Session } from '../session.ts';
24
+ import { documentShell } from '../layout.ts';
25
+ import type { PageContext } from './context.ts';
26
+
27
+ export const DEVBAR_COOKIE = 'pgkiln_devbar';
28
+ const COOKIE_PATH = '/a';
29
+ const BUILDER = '/builder';
30
+ /** The session state key of the session's own debug level. */
31
+ export const SESSION_DEBUG = '__DEBUG_LEVEL';
32
+
33
+ export interface Devbar {
34
+ username: string;
35
+ }
36
+
37
+ /** Builder: give the browser a toolbar token for this developer session (when it has none that matches). */
38
+ export async function ensureDevbar(req: FastifyRequest, reply: FastifyReply, s: Session) {
39
+ if (!s.username) return;
40
+ const token = req.cookies[DEVBAR_COOKIE];
41
+ if (token && token.length <= 100 && s.devbar_hash && hashToken(token) === s.devbar_hash) return;
42
+ const fresh = newToken();
43
+ s.devbar_hash = hashToken(fresh);
44
+ await runtime.query('update meta.session set devbar_hash = $2 where id = $1 and app_id is null', [s.id, s.devbar_hash]);
45
+ reply.setCookie(DEVBAR_COOKIE, fresh, { path: COOKIE_PATH, httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true' });
46
+ }
47
+
48
+ /** Builder sign-out: the toolbar goes with the session. */
49
+ export function clearDevbar(reply: FastifyReply) {
50
+ reply.clearCookie(DEVBAR_COOKIE, { path: COOKIE_PATH });
51
+ }
52
+
53
+ /** The developer whose live builder session the request's toolbar token belongs to, if they may open this application. */
54
+ export async function devbarOf(req: FastifyRequest, appId: number): Promise<Devbar | null> {
55
+ const token = req.cookies?.[DEVBAR_COOKIE];
56
+ if (!token || token.length > 100) return null;
57
+ const { idleMinutes, maxHours } = sessionLimits();
58
+ const row = await owner.one<{ username: string }>(
59
+ `select s.username from meta.session s
60
+ where s.devbar_hash = $1 and s.app_id is null and s.username is not null
61
+ and s.last_seen > now() - make_interval(mins => $2) and s.created_at > now() - make_interval(hours => $3)
62
+ and exists (select 1 from meta.developer d where d.username = s.username
63
+ and (d.is_admin or exists (select 1 from meta.workspace_member m
64
+ where m.username = d.username and m.workspace_id = meta.app_workspace($4))))`,
65
+ [hashToken(token), idleMinutes, maxHours, appId],
66
+ );
67
+ return row ? { username: row.username } : null;
68
+ }
69
+
70
+ /** The session's own debug level (0: none), set from the toolbar. */
71
+ export const sessionDebugLevel = (s: Session) => {
72
+ const n = Number(s.state[SESSION_DEBUG] ?? 0);
73
+ return DEBUG_LEVELS.some(([level]) => level === n) ? n : 0;
74
+ };
75
+
76
+ /** The toolbar at the bottom of a page (not in dialogs). */
77
+ export function devbarHtml(ctx: PageContext): Raw | '' {
78
+ if (!ctx.devbar || ctx.dialog) return '';
79
+ const level = sessionDebugLevel(ctx.session);
80
+ const appLevel = Number(ctx.app.debug_level ?? 0);
81
+ const page = ctx.page.page_no;
82
+ return html`<details class="devbar" open>
83
+ <summary><span>Developer</span></summary>
84
+ <nav aria-label="Developer toolbar"><ul>
85
+ <li><a href="${BUILDER}">App Builder</a></li>
86
+ <li><a href="${BUILDER}/apps/${ctx.app.id}">Application ${ctx.app.id}</a></li>
87
+ <li><a href="${BUILDER}/pages/${ctx.page.id}">Edit page ${page}</a></li>
88
+ <li><button type="button" class="devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" hidden>Quick edit</button></li>
89
+ <li><a href="${ctx.base}/${page}/devbar/session">Session</a></li>
90
+ <li><form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
91
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
92
+ <label for="devbar-debug">Debug</label>
93
+ <select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
94
+ html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
95
+ <button>Set</button>
96
+ </form></li>
97
+ ${level || appLevel ? html`<li><a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a></li>` : ''}
98
+ </ul></nav>
99
+ </details>`;
100
+ }
101
+
102
+ const INTERNAL = /^__/;
103
+
104
+ /** Session state viewer and the toolbar's actions. */
105
+ export function devbarRoutes(app: FastifyInstance, loadContext: (req: any, reply: FastifyReply) => Promise<PageContext | null>) {
106
+ const developerContext = async (req: FastifyRequest, reply: FastifyReply) => {
107
+ const ctx = await loadContext(req, reply);
108
+ if (!ctx) return null;
109
+ if (!ctx.devbar) {
110
+ reply.code(404).type('text/plain').send('Not found');
111
+ return null;
112
+ }
113
+ return ctx;
114
+ };
115
+
116
+ app.get('/a/:alias/:page/devbar/session', async (req, reply) => {
117
+ const ctx = await developerContext(req, reply);
118
+ if (!ctx) return;
119
+ const state = ctx.session.state;
120
+ const show = (name: string, type?: string) => {
121
+ const v = state[name];
122
+ if (v === undefined || v === null) return html`<span class="muted">(null)</span>`;
123
+ if (type === 'password') return html`<span class="muted">(set, not shown)</span>`;
124
+ return html`<code>${v.length > 2000 ? `${v.slice(0, 2000)}…` : v}</code>`;
125
+ };
126
+ const pageItems = ctx.page.items.map((i) => i.name);
127
+ const known = new Set([...pageItems, ...ctx.app.app_items]);
128
+ const others = Object.keys(state).filter((k) => !INTERNAL.test(k) && !known.has(k)).sort();
129
+ const table = (rows: Raw[], cols: string[]) =>
130
+ rows.length
131
+ ? html`<div class="table-wrap"><table class="report"><thead><tr>${cols.map((c) => html`<th scope="col">${c}</th>`)}</tr></thead><tbody>${rows}</tbody></table></div>`
132
+ : html`<p class="muted">None.</p>`;
133
+ const body = html`<main class="t-main devbar-session" id="main">
134
+ <div class="t-titlebar"><h1>Session state</h1></div>
135
+ <div class="t-content">
136
+ <p><a href="${ctx.base}/${ctx.page.page_no}">Back to page ${ctx.page.page_no}</a></p>
137
+ <dl class="devbar-facts">
138
+ <dt>Application</dt><dd>${ctx.app.name} (${ctx.app.alias}, ${ctx.app.id})</dd>
139
+ <dt>User</dt><dd>${ctx.user}</dd>
140
+ <dt>Roles</dt><dd>${ctx.roles.join(', ') || '–'}</dd>
141
+ <dt>Session</dt><dd><code>${ctx.session.id}</code></dd>
142
+ <dt>Language</dt><dd>${ctx.locale.lang}</dd>
143
+ <dt>Time zone</dt><dd>${ctx.locale.timeZone ?? '–'}</dd>
144
+ <dt>Debug</dt><dd>${sessionDebugLevel(ctx.session) ? `level ${sessionDebugLevel(ctx.session)} for this session` : Number(ctx.app.debug_level) ? `level ${ctx.app.debug_level} (application)` : 'off'}</dd>
145
+ </dl>
146
+ <h2>Page ${ctx.page.page_no}: ${ctx.page.name}</h2>
147
+ ${table(ctx.page.items.map((i) => html`<tr><td><code>${i.name}</code></td><td>${i.type}</td><td>${show(i.name, i.type)}</td></tr>`), ['Item', 'Type', 'Value'])}
148
+ <form method="post" action="${ctx.base}/${ctx.page.page_no}/devbar/clear">
149
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
150
+ <button class="btn">Clear the items of page ${ctx.page.page_no}</button>
151
+ </form>
152
+ <h2>Application items</h2>
153
+ ${table(ctx.app.app_items.map((n) => html`<tr><td><code>${n}</code></td><td>${show(n)}</td></tr>`), ['Item', 'Value'])}
154
+ <h2>Other values (items of other pages)</h2>
155
+ ${table(others.map((n) => html`<tr><td><code>${n}</code></td><td>${show(n)}</td></tr>`), ['Name', 'Value'])}
156
+ </div>
157
+ </main>`;
158
+ return reply.type('text/html').send(
159
+ documentShell(`Session state · ${ctx.app.name}`, body, 't-app devbar-page', { 'data-base': ctx.base }, '', {
160
+ lang: 'en', dir: 'ltr', theme: ctx.locale.theme, style: ctx.locale.style,
161
+ }),
162
+ );
163
+ });
164
+
165
+ app.post('/a/:alias/:page/devbar/clear', async (req: any, reply) => {
166
+ const ctx = await developerContext(req, reply);
167
+ if (!ctx) return;
168
+ if (req.body?.__csrf !== ctx.session.csrf_token) return reply.code(403).type('text/plain').send('Invalid CSRF token');
169
+ for (const i of ctx.page.items) delete ctx.session.state[i.name];
170
+ await saveState(ctx.session);
171
+ return reply.redirect(`${ctx.base}/${ctx.page.page_no}/devbar/session`, 303);
172
+ });
173
+
174
+ app.post('/a/:alias/:page/devbar/debug', async (req: any, reply) => {
175
+ const ctx = await developerContext(req, reply);
176
+ if (!ctx) return;
177
+ if (req.body?.__csrf !== ctx.session.csrf_token) return reply.code(403).type('text/plain').send('Invalid CSRF token');
178
+ const level = Number(req.body?.level);
179
+ if (level && DEBUG_LEVELS.some(([n]) => n === level)) ctx.session.state[SESSION_DEBUG] = String(level);
180
+ else delete ctx.session.state[SESSION_DEBUG];
181
+ await saveState(ctx.session);
182
+ return reply.redirect(`${ctx.base}/${ctx.page.page_no}`, 303);
183
+ });
184
+ }
@@ -8,7 +8,12 @@ import type { Region } from '../metadata.ts';
8
8
  // named after the region), or "display_selector": "Tab name" (regions with
9
9
  // the same name share one tab, e.g. a smart filters region and its report).
10
10
  // They stay where the page puts them.
11
- // config: {"style": "tabs" | "select", "show_all": true, "remember": true}
11
+ // config: {"style": "tabs" | "select" | "carousel", "show_all": true, "remember": true, "interval": 6}
12
+ //
13
+ // (088) "carousel" (APEX: the Carousel region template): one region at a
14
+ // time with Previous and Next buttons and a dot per region; "interval"
15
+ // seconds turn to the next one by themselves (not while the pointer or the
16
+ // focus is in it, nor for users who prefer reduced motion). No "Show all".
12
17
  //
13
18
  // Without JavaScript the bar is a list of links to the regions (#R<id>) and
14
19
  // every region shows. app.js turns it into ARIA tabs (arrow keys, Home,
@@ -40,12 +45,14 @@ export function renderDisplaySelector(ctx: PageContext, r: Region): Raw {
40
45
  const t = ctx.locale.t;
41
46
  const tabs = selectorTabs(ctx, (x) => `${t('rds.region')} ${x.id}`);
42
47
  if (!tabs.length) return html`<p class="muted">${t('rds.empty')}</p>`;
43
- const style = r.config.style === 'select' ? 'select' : 'tabs';
44
- const showAll = r.config.show_all !== false;
48
+ const style = r.config.style === 'select' ? 'select' : r.config.style === 'carousel' ? 'carousel' : 'tabs';
49
+ const showAll = style !== 'carousel' && r.config.show_all !== false;
50
+ const interval = style === 'carousel' ? Math.max(0, Math.min(120, Number(r.config.interval) || 0)) : 0;
45
51
  const id = `rds${r.id}`;
46
52
  const label = r.title || t('rds.label');
47
53
  const targets = (x: SelectorTab) => x.regions.map((n) => `R${n}`).join(' ');
48
- return html`<nav class="rds rds-${style}" id="${id}" aria-label="${label}" data-rds="${r.id}"${r.config.remember !== false ? html` data-rds-remember` : ''}>
54
+ return html`<nav class="rds rds-${style}" id="${id}" aria-label="${label}" data-rds="${r.id}"${r.config.remember !== false ? html` data-rds-remember` : ''}${interval ? html` data-rds-interval="${interval}"` : ''}${style === 'carousel' ? html` aria-roledescription="carousel"` : ''}>
55
+ ${style === 'carousel' ? html`<button type="button" class="btn rds-prev" data-rds-step="-1" aria-label="${t('report.previous')}" hidden>‹</button>` : ''}
49
56
  <ul class="rds-list">
50
57
  ${showAll ? html`<li><a class="rds-tab" id="${id}_all" href="#${id}" data-rds-all>${t('rds.show_all')}</a></li>` : ''}
51
58
  ${tabs.map((x, i) => html`<li><a class="rds-tab" id="${id}_${i}" href="#R${x.regions[0]}" data-rds-target="${targets(x)}">${x.label}</a></li>`)}
@@ -57,5 +64,6 @@ export function renderDisplaySelector(ctx: PageContext, r: Region): Raw {
57
64
  ${tabs.map((x, i) => html`<option value="${i}">${x.label}</option>`)}
58
65
  </select></label>`
59
66
  : ''}
67
+ ${style === 'carousel' ? html`<button type="button" class="btn rds-next" data-rds-step="1" aria-label="${t('report.next')}" hidden>›</button>` : ''}
60
68
  </nav>`;
61
69
  }
@@ -16,7 +16,7 @@ import { itemMask, LOV_CHECKED, lovContains, MULTI_VALUE, ratingMax, splitValues
16
16
  import { formatNumber, isPlainNumber } from '../numformat.ts';
17
17
  import { conditionHolds } from './logic.ts';
18
18
  import { runPluginProcess } from './plugins.ts';
19
- import { BACKGROUND_TYPES, downloadFile, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
19
+ import { BACKGROUND_TYPES, downloadFile, sendEmailProcess, sendPushProcess, workflowProcess, type ChainConfig } from './processes.ts';
20
20
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, toState, type Errors, type PageContext } from './context.ts';
21
21
 
22
22
  const ident = pg.escapeIdentifier;
@@ -35,6 +35,16 @@ export async function resolveTable(c: Client, name: string) {
35
35
 
36
36
  const formItems = (ctx: PageContext, r: Region) => ctx.page.items.filter((i) => i.region_id === r.id && i.source_column);
37
37
 
38
+ /**
39
+ * (076) Lost update detection: on unless the form's attributes say
40
+ * {"lost_update": false}. The checksum covers the form's columns except files.
41
+ */
42
+ const detectsLostUpdates = (r: Region) => (r.config as { lost_update?: unknown } | null)?.lost_update !== false;
43
+ const versionColumns = (ctx: PageContext, r: Region) =>
44
+ [...new Set(formItems(ctx, r).filter((i) => i.type !== 'file').map((i) => i.source_column!))].sort();
45
+ const rowVersion = async (c: Client, table: string, r: Region, columns: string[], pk: string, lock: boolean) =>
46
+ ((await c.query('select meta.row_version($1::regclass, $2, $3, $4, $5) as v', [table, r.pk_column, String(pk), columns, lock])).rows[0]?.v as string | null) ?? null;
47
+
38
48
  export function clearPageItems(ctx: PageContext) {
39
49
  for (const i of ctx.page.items) delete ctx.session.state[i.name];
40
50
  }
@@ -90,6 +100,10 @@ export async function fetchForms(ctx: PageContext) {
90
100
  continue;
91
101
  }
92
102
  items.forEach((item, idx) => (ctx.session.state[item.name] = toState(res.rows[0][idx])));
103
+ if (detectsLostUpdates(r)) {
104
+ const v = await savepoint(c, () => rowVersion(c, table, r, versionColumns(ctx, r), pk, false));
105
+ if (v) (ctx.rowVersions ??= new Map()).set(r.id, v);
106
+ }
93
107
  } catch (e) {
94
108
  ctx.errors.page.push(await publicError(ctx, e, `fetch of ${r.title ?? 'form'}`));
95
109
  }
@@ -305,6 +319,16 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
305
319
  }
306
320
 
307
321
  if (pk === null) throw new Error(ctx.locale.t('form.no_record'));
322
+ // (076) the row is locked, and refused when someone changed it since the page was shown
323
+ const shown = ctx.rowVersions?.get(r.id);
324
+ if (shown && detectsLostUpdates(r)) {
325
+ const now = await rowVersion(c, table, r, versionColumns(ctx, r), pk, true);
326
+ if (now === null) throw new ProcessFailed(ctx.locale.t('form.changed'), null);
327
+ if (now !== shown) {
328
+ dbg(ctx, 2, 'process', `lost update prevented: the row of "${r.title ?? 'form'}" changed since the page was shown`);
329
+ throw new ProcessFailed(ctx.locale.t('form.lost_update'), null);
330
+ }
331
+ }
308
332
  if (op === 'update') {
309
333
  if (assignments.length) {
310
334
  const res = await c.query(`update ${table} set ${assignments.map(([col, v]) => `${col} = ${v}`).join(', ')} where ${pkCol} = ${literal(pk)}`);
@@ -511,6 +535,7 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
511
535
  case 'invoke_api': return await invokeApi(ctx, p, names);
512
536
  case 'workflow': return await workflowProcess(ctx, p, names);
513
537
  case 'send_push': return await sendPushProcess(ctx, p);
538
+ case 'send_email': return await sendEmailProcess(ctx, p);
514
539
  case 'ai_generate': return (await runAiProcess(ctx, p, names)).message;
515
540
  case 'plugin': return await runPluginProcess(ctx, p.config, p.name);
516
541
  case 'download':
@@ -0,0 +1,128 @@
1
+ // Feedback (APEX: Feedback), migration 084. With the application's feedback
2
+ // setting on, the navigation bar links to GET /a/:alias/:page/feedback: a
3
+ // form for a comment, an idea or a problem about that page (an optional
4
+ // rating), and the user's own earlier feedback with the developers' answers.
5
+ // Developers answer in the builder (src/builder/feedback.ts).
6
+ //
7
+ // The page's own access rules apply (no feedback about a page the user may
8
+ // not open). Feedback is stored with the user, page, session and browser
9
+ // name; at most FEEDBACK_PER_HOUR per session. Everything shown is escaped.
10
+ import type { FastifyInstance, FastifyReply } from 'fastify';
11
+ import { appTx, runtime } from '../db.ts';
12
+ import { html, raw } from '../html.ts';
13
+ import { icon } from '../icons.ts';
14
+ import { saveState } from '../session.ts';
15
+ import { checkPageAccess, Forbidden } from './authz.ts';
16
+ import type { PageContext } from './context.ts';
17
+ import type { ListNode } from './lists.ts';
18
+ import { chrome } from './render.ts';
19
+
20
+ type Loader = (req: any, reply: FastifyReply) => Promise<PageContext | null>;
21
+ type Tx = (ctx: PageContext) => Parameters<typeof appTx>[0];
22
+
23
+ const KINDS = ['comment', 'idea', 'problem'] as const;
24
+ export const FEEDBACK_PER_HOUR = 10;
25
+ const MAX_TEXT = 4000;
26
+
27
+ /** The navigation bar's Feedback entry (when the application asks for it). */
28
+ export function feedbackNode(ctx: PageContext): ListNode | null {
29
+ if (!ctx.app.feedback || ctx.dialog) return null;
30
+ return {
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: [],
33
+ };
34
+ }
35
+
36
+ const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
37
+
38
+ export function feedbackRoutes(app: FastifyInstance, loadContext: Loader, txContext: Tx) {
39
+ const page = async (ctx: PageContext, reply: FastifyReply, status: number, notice: { ok: boolean; text: string } | null, draft: { kind?: string; rating?: string; body?: string } = {}) => {
40
+ const t = ctx.locale.t;
41
+ const mine = ctx.user === 'nobody' ? [] : (await runtime.query(
42
+ `select page_no, kind, rating, body, status, response, responded_at, created_at from meta.feedback
43
+ where app_id = $1 and lower(username) = lower($2) order by created_at desc limit 50`, [ctx.app.id, ctx.user])).rows;
44
+ const status_ = (s: string) => t(`feedback.status.${s}` as never);
45
+ const main = html`<div class="t-titlebar"><h1>${t('feedback.title')}</h1></div>
46
+ <div class="t-content feedback-page">
47
+ <div class="messages" aria-live="polite">${notice ? html`<div class="alert ${notice.ok ? 'alert-success' : 'alert-error'}" role="${notice.ok ? 'status' : 'alert'}">${notice.text}</div>` : ''}</div>
48
+ <section class="region region-standard"><div class="region-body">
49
+ <p class="u-mt0">${t('feedback.intro', { page: ctx.page.title ?? ctx.page.name })}</p>
50
+ <form method="post" action="${ctx.base}/${ctx.page.page_no}/feedback" class="feedback-form">
51
+ <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
52
+ <fieldset class="field"><legend class="label">${t('feedback.kind')}</legend>
53
+ <div class="choices">${KINDS.map((k) => html`<label class="check"><input type="radio" name="kind" value="${k}"${(draft.kind ?? 'comment') === k ? raw(' checked') : ''}> ${t(`feedback.kind.${k}` as never)}</label>`)}</div>
54
+ </fieldset>
55
+ <div class="field"><label class="label" for="feedback_rating">${t('feedback.rating')}</label>
56
+ <select id="feedback_rating" name="rating"><option value="">–</option>${[5, 4, 3, 2, 1].map((n) =>
57
+ html`<option value="${n}"${draft.rating === String(n) ? raw(' selected') : ''}>${'★'.repeat(n)}</option>`)}</select></div>
58
+ <div class="field" data-wide><label class="label" for="feedback_body">${t('feedback.text')}<span class="req" aria-hidden="true">*</span></label>
59
+ <textarea id="feedback_body" name="body" rows="6" maxlength="${MAX_TEXT}" required>${draft.body ?? ''}</textarea></div>
60
+ <div class="buttons"><a class="btn" href="${ctx.base}/${ctx.page.page_no}">${t('feedback.back')}</a><button class="btn btn-hot">${icon('send')} ${t('feedback.send')}</button></div>
61
+ </form>
62
+ </div></section>
63
+ ${ctx.user === 'nobody' ? '' : html`<section class="region region-standard"><header class="region-header"><h2>${t('feedback.mine')}</h2></header><div class="region-body">
64
+ ${mine.length ? html`<ul class="feedback-list">${mine.map((f) => html`<li>
65
+ <div class="feedback-meta"><span class="tag">${t(`feedback.kind.${f.kind}` as never)}</span> <span class="tag${f.status === 'done' ? ' tag-ok' : ''}">${status_(f.status)}</span>
66
+ <span class="muted">${when(f.created_at)}${f.page_no ? ` · ${t('feedback.page', { page: String(f.page_no) })}` : ''}${f.rating ? ` · ${'★'.repeat(f.rating)}` : ''}</span></div>
67
+ <p class="feedback-body">${f.body}</p>
68
+ ${f.response ? html`<div class="feedback-response"><strong>${t('feedback.response')}</strong><p>${f.response}</p></div>` : ''}
69
+ </li>`)}</ul>` : html`<p class="muted">${t('feedback.none')}</p>`}
70
+ </div></section>`}
71
+ </div>`;
72
+ ctx.vis = { regions: new Set(), items: new Set(), editable: new Set(), buttons: new Map(), dynamicActions: new Set() };
73
+ const body = await appTx(txContext(ctx), async (c) => {
74
+ ctx.client = c;
75
+ return chrome(ctx, main, t('feedback.title'));
76
+ });
77
+ await saveState(ctx.session);
78
+ return reply.code(status).type('text/html').send(body);
79
+ };
80
+
81
+ /** The context of a feedback request: the application allows feedback and the user may open the page. */
82
+ const context = async (req: any, reply: FastifyReply) => {
83
+ const ctx = await loadContext(req, reply);
84
+ if (!ctx) return null;
85
+ if (!ctx.app.feedback) {
86
+ reply.code(404).type('text/plain').send('Not found');
87
+ return null;
88
+ }
89
+ try {
90
+ await appTx(txContext(ctx), async (c) => {
91
+ ctx.client = c;
92
+ await checkPageAccess(ctx);
93
+ });
94
+ } catch (e) {
95
+ if (e instanceof Forbidden) {
96
+ reply.code(403).type('text/plain').send(e.message);
97
+ return null;
98
+ }
99
+ throw e;
100
+ }
101
+ return ctx;
102
+ };
103
+
104
+ app.get('/a/:alias/:page/feedback', async (req, reply) => {
105
+ const ctx = await context(req, reply);
106
+ if (!ctx) return;
107
+ return page(ctx, reply, 200, null);
108
+ });
109
+
110
+ app.post('/a/:alias/:page/feedback', async (req: any, reply) => {
111
+ const ctx = await context(req, reply);
112
+ if (!ctx) return;
113
+ const t = ctx.locale.t;
114
+ const b = req.body ?? {};
115
+ if (b.__csrf !== ctx.session.csrf_token) return page(ctx, reply, 403, { ok: false, text: t('error.session_reload') });
116
+ const kind = (KINDS as readonly string[]).includes(b.kind) ? b.kind : 'comment';
117
+ const rating = /^[1-5]$/.test(String(b.rating ?? '')) ? Number(b.rating) : null;
118
+ const text = String(b.body ?? '').replace(/\r\n?/g, '\n').trim();
119
+ if (!text || text.length > MAX_TEXT) return page(ctx, reply, 422, { ok: false, text: t('feedback.required') }, { kind, rating: b.rating, body: text.slice(0, MAX_TEXT) });
120
+ const recent = (await runtime.one<{ n: number }>(
121
+ `select count(*)::int as n from meta.feedback where app_id = $1 and session_id = $2 and created_at > now() - interval '1 hour'`, [ctx.app.id, ctx.session.id]))!.n;
122
+ if (recent >= FEEDBACK_PER_HOUR) return page(ctx, reply, 429, { ok: false, text: t('feedback.too_many') }, { kind, rating: b.rating, body: text });
123
+ await runtime.query(
124
+ `insert into meta.feedback (app_id, page_no, username, session_id, kind, rating, body, user_agent) values ($1, $2, $3, $4, $5, $6, $7, $8)`,
125
+ [ctx.app.id, ctx.page.page_no, ctx.user, ctx.session.id, kind, rating, text, String(req.headers['user-agent'] ?? '').slice(0, 300) || null]);
126
+ return page(ctx, reply, 200, { ok: true, text: t('feedback.thanks') });
127
+ });
128
+ }