pgkiln 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +6 -5
- package/docs/guide/03-builder.md +107 -4
- package/docs/guide/04-pages-and-regions.md +3 -1
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +15 -1
- package/docs/guide/12-development.md +22 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +45 -0
- package/src/app.ts +10 -0
- package/src/builder/advisor.ts +2 -2
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +3 -1
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/i18n/de.ts +4 -0
- package/src/builder/i18n/en.ts +4 -0
- package/src/builder/i18n/es.ts +4 -0
- package/src/builder/i18n/fr.ts +4 -0
- package/src/builder/i18n/it.ts +4 -0
- package/src/builder/i18n/ja.ts +4 -0
- package/src/builder/i18n/ko.ts +4 -0
- package/src/builder/i18n/nl.ts +4 -0
- package/src/builder/i18n/pt.ts +4 -0
- package/src/builder/i18n/zh-cn.ts +4 -0
- package/src/builder/i18n/zh-tw.ts +4 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/routes.ts +13 -2
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +9 -1
- package/src/history.ts +155 -0
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { applyBinds, splitStatements } from '../binds.ts';
|
|
4
|
+
import { owner } from '../db.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
8
|
+
import { EXPLAINABLE, sqlFields, type SqlShape } from './advisor.ts';
|
|
9
|
+
import { COMPONENTS } from './components.ts';
|
|
10
|
+
import { appEntries, type Entry } from './search.ts';
|
|
11
|
+
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
12
|
+
|
|
13
|
+
// (sprint 138, AP-5) App Builder → Utilities → Database object dependencies
|
|
14
|
+
// (APEX: Database Object Dependencies). Every piece of SQL the Advisor plans
|
|
15
|
+
// (region sources, lists of values, conditions, processes, …) and the tables of
|
|
16
|
+
// form and grid regions are parsed as the application's database role into a
|
|
17
|
+
// temporary SQL-standard function (CREATE FUNCTION … BEGIN ATOMIC), whose
|
|
18
|
+
// dependencies PostgreSQL records in pg_depend: the tables, views, sequences
|
|
19
|
+
// and functions the statement uses, resolved with the role's search path.
|
|
20
|
+
// Nothing runs; the transaction is rolled back. A statement that names an
|
|
21
|
+
// object that doesn't exist is reported as a missing object (in red), and the
|
|
22
|
+
// role's privileges are compared with what the statements need (SELECT for
|
|
23
|
+
// queries, INSERT / UPDATE / DELETE for the target of a DML statement, USAGE
|
|
24
|
+
// on sequences, EXECUTE on functions). PL/pgSQL blocks (DO) can't be parsed this
|
|
25
|
+
// way and are listed as not scanned.
|
|
26
|
+
|
|
27
|
+
export type ObjKind = 'table' | 'view' | 'matview' | 'sequence' | 'foreign table' | 'function' | 'procedure' | 'relation';
|
|
28
|
+
|
|
29
|
+
export interface Use {
|
|
30
|
+
entry: Entry | null;
|
|
31
|
+
field: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DepObject {
|
|
35
|
+
key: string;
|
|
36
|
+
kind: ObjKind;
|
|
37
|
+
schema: string;
|
|
38
|
+
name: string;
|
|
39
|
+
/** schema-qualified display name (functions with their argument types) */
|
|
40
|
+
display: string;
|
|
41
|
+
/** the Object Browser's reference (regclass / regprocedure text), null for a missing object */
|
|
42
|
+
ref: string | null;
|
|
43
|
+
missing: boolean;
|
|
44
|
+
/** privileges the statements need that the role doesn't have */
|
|
45
|
+
lacking: string[];
|
|
46
|
+
/** the role's privileges on the object */
|
|
47
|
+
privileges: string[];
|
|
48
|
+
usedBy: Use[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Dependencies {
|
|
52
|
+
objects: DepObject[];
|
|
53
|
+
role: string | null;
|
|
54
|
+
scanned: number;
|
|
55
|
+
unscanned: (Use & { message: string })[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Fragment extends Use {
|
|
59
|
+
sql: string;
|
|
60
|
+
shape: SqlShape | 'relation';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SYSTEM = new Set(['pg_catalog', 'information_schema', 'meta', 'pg_toast']);
|
|
64
|
+
|
|
65
|
+
/** Every piece of SQL of an application, and the tables of its form and grid regions. */
|
|
66
|
+
async function fragments(appId: number): Promise<Fragment[]> {
|
|
67
|
+
const entries = await appEntries(appId);
|
|
68
|
+
const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
|
|
69
|
+
const out: Fragment[] = [];
|
|
70
|
+
for (const [kind, spec] of Object.entries(COMPONENTS)) {
|
|
71
|
+
const rows = spec.scope === 'page'
|
|
72
|
+
? (await owner.query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1`, [appId])).rows
|
|
73
|
+
: (await owner.query(`select * from ${spec.table} where app_id = $1`, [appId])).rows;
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
76
|
+
const label = (name: string) => spec.fields.find((x) => x.name === name)?.label ?? name;
|
|
77
|
+
for (const f of sqlFields(kind, row)) if (f.shape !== 'regex' && typeof row[f.name] === 'string' && row[f.name].trim()) out.push({ entry, field: label(f.name), sql: row[f.name], shape: f.shape });
|
|
78
|
+
if (kind === 'region' && (row.type === 'form' || row.type === 'grid') && typeof row.table_name === 'string' && row.table_name.trim() && !row.rest_source)
|
|
79
|
+
out.push({ entry, field: label('table_name'), sql: row.table_name.trim(), shape: 'relation' });
|
|
80
|
+
if (kind === 'region' && row.type === 'map')
|
|
81
|
+
for (const l of Array.isArray(row.config?.layers) ? row.config.layers : [])
|
|
82
|
+
if (typeof l?.source === 'string' && l.source.trim()) out.push({ entry, field: `Layer "${typeof l.name === 'string' ? l.name : ''}" query`, sql: l.source, shape: 'select' });
|
|
83
|
+
if (kind === 'search_config' && row.source_type !== 'list' && typeof row.source === 'string' && row.source.trim())
|
|
84
|
+
out.push({ entry, field: 'Source', sql: row.source.trim(), shape: row.source_type === 'table' ? 'relation' : 'select' });
|
|
85
|
+
if (kind === 'rest_module')
|
|
86
|
+
for (const h of Array.isArray(row.handlers) ? row.handlers : [])
|
|
87
|
+
if (typeof h?.source === 'string' && h.source.trim() && h.type !== 'plpgsql') out.push({ entry, field: `${h.method} ${h.path}`, sql: h.source, shape: h.type === 'sql' ? 'statements' : 'select' });
|
|
88
|
+
if (kind === 'workflow_definition')
|
|
89
|
+
for (const st of Array.isArray(row.steps) ? row.steps : []) {
|
|
90
|
+
if (st?.type === 'sql' && typeof st.code === 'string') out.push({ entry, field: `step ${st.name}: code`, sql: st.code, shape: 'statements' });
|
|
91
|
+
if (st?.type === 'task' && typeof st.owners === 'string' && st.owners.trim()) out.push({ entry, field: `step ${st.name}: owners`, sql: st.owners, shape: 'select' });
|
|
92
|
+
if (st?.type === 'switch' && Array.isArray(st.cases)) for (const cs of st.cases) if (typeof cs?.when === 'string') out.push({ entry, field: `step ${st.name}: when`, sql: cs.when, shape: 'boolean' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The DML target of a statement (INSERT INTO x, UPDATE x, DELETE FROM x, MERGE INTO x) and the privilege it needs. */
|
|
100
|
+
export function dmlTarget(stmt: string): { name: string; privilege: string } | null {
|
|
101
|
+
const m = /^\s*(?:with\b[\s\S]*?\)\s*)?(insert\s+into|update|delete\s+from|merge\s+into)\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i.exec(stmt);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const verb = m[1].toLowerCase().split(/\s+/)[0];
|
|
104
|
+
return { name: m[2].replace(/\s+/g, ''), privilege: verb === 'merge' ? 'UPDATE' : verb.toUpperCase() };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The statements of a fragment as the body of a BEGIN ATOMIC function, and what can't be parsed that way. */
|
|
108
|
+
export function bodyOf(sql: string, shape: SqlShape | 'relation'): { statements: string[]; skipped: string[] } {
|
|
109
|
+
const plain = applyBinds(sql.trim().replace(/;+\s*$/, ''), {});
|
|
110
|
+
if (shape === 'boolean') return { statements: [`select (${plain})::boolean`], skipped: [] };
|
|
111
|
+
if (shape === 'select') return EXPLAINABLE.test(plain) ? { statements: [plain], skipped: [] } : { statements: [], skipped: ['not a query'] };
|
|
112
|
+
const statements: string[] = [];
|
|
113
|
+
const skipped: string[] = [];
|
|
114
|
+
for (const stmt of splitStatements(plain)) {
|
|
115
|
+
if (EXPLAINABLE.test(stmt)) statements.push(stmt);
|
|
116
|
+
else if (stmt.trim()) skipped.push(stmt.trim().split(/\s+/).slice(0, 2).join(' '));
|
|
117
|
+
}
|
|
118
|
+
return { statements, skipped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A missing object named by an error message of the parser. */
|
|
122
|
+
export function missingOf(message: string): { kind: ObjKind; name: string } | null {
|
|
123
|
+
let m = /relation "([^"]+)" does not exist/.exec(message);
|
|
124
|
+
if (m) return { kind: 'relation', name: m[1] };
|
|
125
|
+
m = /^(function|procedure) (.+?\)) does not exist/.exec(message);
|
|
126
|
+
if (m) return { kind: m[1] as ObjKind, name: m[2] };
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const RELKINDS: Record<string, ObjKind> = { r: 'table', p: 'table', v: 'view', m: 'matview', S: 'sequence', f: 'foreign table' };
|
|
131
|
+
|
|
132
|
+
/** Scan an application. */
|
|
133
|
+
export async function appDependencies(appId: number): Promise<Dependencies> {
|
|
134
|
+
const app = await owner.one<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId]);
|
|
135
|
+
if (!app) throw new Error('application not found');
|
|
136
|
+
const frags = await fragments(appId);
|
|
137
|
+
const found = new Map<string, { cls: 'pg_class' | 'pg_proc'; oid: number; needs: Set<string>; uses: Use[] }>();
|
|
138
|
+
const missing = new Map<string, DepObject>();
|
|
139
|
+
const unscanned: Dependencies['unscanned'] = [];
|
|
140
|
+
let scanned = 0;
|
|
141
|
+
const note = (cls: 'pg_class' | 'pg_proc', oid: number, use: Use, need: string | null) => {
|
|
142
|
+
const k = `${cls}:${oid}`;
|
|
143
|
+
if (!found.has(k)) found.set(k, { cls, oid, needs: new Set(), uses: [] });
|
|
144
|
+
const f = found.get(k)!;
|
|
145
|
+
if (need) f.needs.add(need);
|
|
146
|
+
if (!f.uses.some((u) => u.entry === use.entry && u.field === use.field)) f.uses.push(use);
|
|
147
|
+
};
|
|
148
|
+
const own = await devDbForApp(appId);
|
|
149
|
+
const c = await own.pool.connect();
|
|
150
|
+
try {
|
|
151
|
+
await c.query('begin');
|
|
152
|
+
await c.query(`set local statement_timeout = '5s'`);
|
|
153
|
+
await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
|
|
154
|
+
if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
|
|
155
|
+
for (const f of frags) {
|
|
156
|
+
const use: Use = { entry: f.entry, field: f.field };
|
|
157
|
+
await c.query('savepoint deps');
|
|
158
|
+
try {
|
|
159
|
+
if (f.shape === 'relation') {
|
|
160
|
+
scanned++;
|
|
161
|
+
const r = (await c.query<{ oid: number | null }>('select to_regclass($1)::oid::int as oid', [f.sql])).rows[0];
|
|
162
|
+
if (r?.oid) note('pg_class', r.oid, use, 'SELECT');
|
|
163
|
+
else addMissing(missing, { kind: 'relation', name: f.sql }, use);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const { statements, skipped } = bodyOf(f.sql, f.shape);
|
|
167
|
+
if (skipped.length) unscanned.push({ ...use, message: `Not scanned (only SELECT, INSERT, UPDATE, DELETE and MERGE are parsed): ${skipped.join(', ')}` });
|
|
168
|
+
if (!statements.length) continue;
|
|
169
|
+
scanned++;
|
|
170
|
+
// the extended protocol takes exactly one statement: SQL that ends the function early (…; end; drop …) is refused
|
|
171
|
+
await c.query({ text: `create function pg_temp.__pgkiln_deps() returns void language sql begin atomic ${statements.join(';\n')}; end`, queryMode: 'extended' } as pg.QueryConfig);
|
|
172
|
+
const deps = (await c.query<{ cls: 'pg_class' | 'pg_proc'; oid: number }>(
|
|
173
|
+
`select distinct d.refclassid::regclass::text as cls, d.refobjid::int as oid from pg_depend d
|
|
174
|
+
where d.classid = 'pg_proc'::regclass and d.objid = 'pg_temp.__pgkiln_deps()'::regprocedure
|
|
175
|
+
and d.refclassid in ('pg_class'::regclass, 'pg_proc'::regclass)`)).rows;
|
|
176
|
+
// what the statements need: the target of a DML statement its verb's privilege, everything else SELECT
|
|
177
|
+
const targets = new Map<number, string>();
|
|
178
|
+
for (const st of statements) {
|
|
179
|
+
const t = dmlTarget(st);
|
|
180
|
+
if (!t) continue;
|
|
181
|
+
const oid = (await c.query<{ oid: number | null }>('select to_regclass($1)::oid::int as oid', [t.name])).rows[0]?.oid;
|
|
182
|
+
if (oid) targets.set(oid, t.privilege);
|
|
183
|
+
}
|
|
184
|
+
for (const d of deps) note(d.cls, d.oid, use, d.cls === 'pg_proc' ? 'EXECUTE' : (targets.get(d.oid) ?? 'SELECT'));
|
|
185
|
+
} catch (e) {
|
|
186
|
+
const message = (e as Error).message;
|
|
187
|
+
const m = missingOf(message);
|
|
188
|
+
if (m) addMissing(missing, m, use);
|
|
189
|
+
else unscanned.push({ ...use, message });
|
|
190
|
+
} finally {
|
|
191
|
+
await c.query('rollback to savepoint deps');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
await c.query('rollback').catch(() => {});
|
|
196
|
+
c.release();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// the objects, their kinds and the role's privileges (as the owner: the catalog)
|
|
200
|
+
const objects: DepObject[] = [...missing.values()];
|
|
201
|
+
const rels = [...found.values()].filter((f) => f.cls === 'pg_class');
|
|
202
|
+
const procs = [...found.values()].filter((f) => f.cls === 'pg_proc');
|
|
203
|
+
const role = app.db_role;
|
|
204
|
+
if (rels.length) {
|
|
205
|
+
const rows = (await owner.query(
|
|
206
|
+
`select c.oid::int as oid, c.relkind::text as relkind, n.nspname as schema, c.relname as name, c.oid::regclass::text as ref,
|
|
207
|
+
array_remove(array[
|
|
208
|
+
case when $2::text is null or has_table_privilege($2, c.oid, 'SELECT') then 'SELECT' end,
|
|
209
|
+
case when c.relkind <> 'S' and ($2::text is null or has_table_privilege($2, c.oid, 'INSERT')) then 'INSERT' end,
|
|
210
|
+
case when c.relkind <> 'S' and ($2::text is null or has_table_privilege($2, c.oid, 'UPDATE')) then 'UPDATE' end,
|
|
211
|
+
case when c.relkind <> 'S' and ($2::text is null or has_table_privilege($2, c.oid, 'DELETE')) then 'DELETE' end,
|
|
212
|
+
case when c.relkind = 'S' and ($2::text is null or has_sequence_privilege($2, c.oid, 'USAGE')) then 'USAGE' end], null) as privileges,
|
|
213
|
+
$2::text is null or has_schema_privilege($2, n.oid, 'USAGE') as schema_usage
|
|
214
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.oid = any ($1::oid[])`,
|
|
215
|
+
[rels.map((r) => r.oid), role])).rows;
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
if (SYSTEM.has(r.schema)) continue;
|
|
218
|
+
const f = found.get(`pg_class:${r.oid}`)!;
|
|
219
|
+
const kind = RELKINDS[r.relkind] ?? 'relation';
|
|
220
|
+
// a sequence used by nextval() needs USAGE (or UPDATE); one only read, SELECT
|
|
221
|
+
const needs = kind === 'sequence' ? ['USAGE'] : [...f.needs];
|
|
222
|
+
const lacking = [...(r.schema_usage ? [] : [`USAGE on schema ${r.schema}`]), ...needs.filter((p) => !r.privileges.includes(p))];
|
|
223
|
+
objects.push({ key: `pg_class:${r.oid}`, kind, schema: r.schema, name: r.name, display: `${r.schema}.${r.name}`, ref: r.ref, missing: false, lacking, privileges: r.privileges, usedBy: f.uses });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (procs.length) {
|
|
227
|
+
const rows = (await owner.query(
|
|
228
|
+
`select p.oid::int as oid, p.prokind::text as prokind, n.nspname as schema, p.proname as name, p.oid::regprocedure::text as ref,
|
|
229
|
+
pg_get_function_identity_arguments(p.oid) as args,
|
|
230
|
+
$2::text is null or has_function_privilege($2, p.oid, 'EXECUTE') as exec,
|
|
231
|
+
$2::text is null or has_schema_privilege($2, n.oid, 'USAGE') as schema_usage
|
|
232
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace where p.oid = any ($1::oid[])`,
|
|
233
|
+
[procs.map((r) => r.oid), role])).rows;
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
if (SYSTEM.has(r.schema)) continue;
|
|
236
|
+
const f = found.get(`pg_proc:${r.oid}`)!;
|
|
237
|
+
const lacking = [...(r.schema_usage ? [] : [`USAGE on schema ${r.schema}`]), ...(r.exec ? [] : ['EXECUTE'])];
|
|
238
|
+
objects.push({ key: `pg_proc:${r.oid}`, kind: r.prokind === 'p' ? 'procedure' : 'function', schema: r.schema, name: r.name, display: `${r.schema}.${r.name}(${r.args})`, ref: r.ref, missing: false, lacking, privileges: r.exec ? ['EXECUTE'] : [], usedBy: f.uses });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const rank = (o: DepObject) => (o.missing ? 0 : o.lacking.length ? 1 : 2);
|
|
242
|
+
objects.sort((a, b) => rank(a) - rank(b) || (a.display < b.display ? -1 : a.display > b.display ? 1 : 0));
|
|
243
|
+
return { objects, role, scanned, unscanned };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function addMissing(missing: Map<string, DepObject>, m: { kind: ObjKind; name: string }, use: Use) {
|
|
247
|
+
const key = `missing:${m.kind}:${m.name}`;
|
|
248
|
+
if (!missing.has(key)) {
|
|
249
|
+
const dot = m.name.indexOf('.');
|
|
250
|
+
missing.set(key, { key, kind: m.kind, schema: dot > 0 ? m.name.slice(0, dot) : '', name: dot > 0 ? m.name.slice(dot + 1) : m.name, display: m.name, ref: null, missing: true, lacking: [], privileges: [], usedBy: [] });
|
|
251
|
+
}
|
|
252
|
+
const o = missing.get(key)!;
|
|
253
|
+
if (!o.usedBy.some((u) => u.entry === use.entry && u.field === use.field)) o.usedBy.push(use);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const OB_KIND: Partial<Record<ObjKind, string>> = { table: 'table', view: 'view', matview: 'matview', sequence: 'sequence', function: 'function', procedure: 'procedure' };
|
|
257
|
+
const KIND_ICON: Record<ObjKind, string> = { table: 'table', view: 'layers', matview: 'layers', sequence: 'hash', 'foreign table': 'table', function: 'code', procedure: 'code', relation: 'table' };
|
|
258
|
+
|
|
259
|
+
/** The Object Browser's address of an object (null: it has no page there). */
|
|
260
|
+
export const objectUrl = (o: Pick<DepObject, 'kind' | 'schema' | 'ref'>) =>
|
|
261
|
+
o.ref && OB_KIND[o.kind] ? `${BASE}/sql/objects?${new URLSearchParams({ schema: o.schema, k: OB_KIND[o.kind]!, o: o.ref })}` : null;
|
|
262
|
+
|
|
263
|
+
const useLink = (u: Use) =>
|
|
264
|
+
u.entry
|
|
265
|
+
? html`<li><a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>${u.entry.pageNo !== null && u.entry.kind !== 'page' ? html` <span class="tag">page ${u.entry.pageNo}</span>` : ''} <span class="muted small">${u.field}</span></li>`
|
|
266
|
+
: html`<li><span class="muted small">${u.field}</span></li>`;
|
|
267
|
+
|
|
268
|
+
export async function dependencyRoutes(app: FastifyInstance) {
|
|
269
|
+
app.get(`${BASE}/apps/:id/utilities/dependencies`, async (req: Req, reply) => {
|
|
270
|
+
const s = await developer(req, reply);
|
|
271
|
+
if (!s) return;
|
|
272
|
+
const a = /^\d{1,9}$/.test(req.params.id) ? await owner.one('select * from meta.app where id = $1', [req.params.id]) : undefined;
|
|
273
|
+
if (!a) return reply.code(404).send('Not found');
|
|
274
|
+
const deps = await appDependencies(a.id);
|
|
275
|
+
const filter = String(req.query?.q ?? '').trim().slice(0, 100).toLowerCase();
|
|
276
|
+
const onlyProblems = req.query?.show === 'problems';
|
|
277
|
+
const shown = deps.objects.filter((o) => (!filter || o.display.toLowerCase().includes(filter)) && (!onlyProblems || o.missing || o.lacking.length));
|
|
278
|
+
const missingN = deps.objects.filter((o) => o.missing).length;
|
|
279
|
+
const grantsN = deps.objects.filter((o) => o.lacking.length).length;
|
|
280
|
+
const pages = (o: DepObject) => [...new Set(o.usedBy.map((u) => u.entry?.pageNo).filter((p): p is number => p !== null && p !== undefined))].sort((x, y) => x - y);
|
|
281
|
+
const rows: Raw = shown.length
|
|
282
|
+
? html`<div class="table-wrap"><table class="table dep-table report-reflow"><thead><tr><th scope="col">Object</th><th scope="col">Type</th><th scope="col">Status</th><th scope="col">Pages</th><th scope="col">Used by</th></tr></thead>
|
|
283
|
+
<tbody>${shown.map((o) => {
|
|
284
|
+
const url = objectUrl(o);
|
|
285
|
+
const status = o.missing
|
|
286
|
+
? html`<span class="tag tag-error">Missing</span>`
|
|
287
|
+
: o.lacking.length
|
|
288
|
+
? html`<span class="tag tag-error">No grant</span> <span class="small dep-missing">${deps.role} lacks ${o.lacking.join(', ')}</span>`
|
|
289
|
+
: html`<span class="tag tag-success">OK</span> <span class="muted small">${o.privileges.join(', ')}</span>`;
|
|
290
|
+
return html`<tr${o.missing || o.lacking.length ? raw(' class="dep-problem"') : ''}>
|
|
291
|
+
<td data-label="Object">${icon(KIND_ICON[o.kind])} ${url ? html`<a href="${url}">${o.display}</a>` : html`<span class="dep-missing">${o.display}</span>`}</td>
|
|
292
|
+
<td data-label="Type">${o.kind === 'relation' ? 'table or view' : o.kind}</td>
|
|
293
|
+
<td data-label="Status">${status}</td>
|
|
294
|
+
<td data-label="Pages">${pages(o).join(', ')}</td>
|
|
295
|
+
<td data-label="Used by"><details class="dep-uses"><summary>${o.usedBy.length} component${o.usedBy.length === 1 ? '' : 's'}</summary><ul class="dep-list">${o.usedBy.map(useLink)}</ul></details></td>
|
|
296
|
+
</tr>`;
|
|
297
|
+
})}</tbody></table></div>`
|
|
298
|
+
: html`<p class="muted">No objects${filter || onlyProblems ? ' match' : ''}.</p>`;
|
|
299
|
+
const main = html`${appHeader(a, 'utilities', s)}
|
|
300
|
+
${region('Database object dependencies', html`<p class="muted u-mt0">The tables, views, sequences and functions the application's SQL uses (${deps.scanned} statements parsed as the role <code>${deps.role ?? '(the runtime connection)'}</code>; nothing is run), with the components that use them. Missing objects and privileges the role lacks are marked.</p>
|
|
301
|
+
<p><span class="tag">${deps.objects.length} objects</span> <span class="tag tag-error">${missingN} missing</span> <span class="tag tag-warning">${grantsN} without the grants they need</span></p>
|
|
302
|
+
<form method="get" class="util-filters" role="search">
|
|
303
|
+
<div class="field"><label class="label" for="dep-q">Filter</label><input id="dep-q" name="q" type="search" value="${filter}" placeholder="e.g. sales.orders"></div>
|
|
304
|
+
<div class="field"><label class="label" for="dep-show">Show</label><select id="dep-show" name="show"><option value="">All objects</option><option value="problems"${onlyProblems ? raw(' selected') : ''}>Only problems</option></select></div>
|
|
305
|
+
<div class="buttons"><button class="btn">${icon('filter')} Filter</button></div>
|
|
306
|
+
</form>
|
|
307
|
+
${rows}`)}
|
|
308
|
+
${deps.unscanned.length ? region('Not scanned', html`<ul class="hits findings">${deps.unscanned.map((u) => html`<li class="finding-info">${u.entry ? html`<a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>` : html`<span>${u.field}</span>`}<span></span><span class="tag tag-info">Note</span><span class="hit-snippet"><b>${u.field}:</b> ${u.message}</span></li>`)}</ul>`) : ''}`;
|
|
309
|
+
return send(reply, s, shell(s, `Object dependencies · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Object dependencies']], main));
|
|
310
|
+
});
|
|
311
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner, type Client } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { appPublicKey } from '../push.ts';
|
|
6
|
+
import { secretKeyConfigured } from '../secrets.ts';
|
|
7
|
+
import { appOr404 } from './forms.ts';
|
|
8
|
+
import { createLoginPage } from './login-page.ts';
|
|
9
|
+
import { ABOUT_PAGE, ACTIVITY_PAGE, addAboutPage, addAccessSchemes, addActivityPage, ensureAdminScheme } from './newapp.ts';
|
|
10
|
+
import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
11
|
+
|
|
12
|
+
// (sprint 137, AP-3) Add feature (APEX: Create a Page → Feature, and App home → Tasks → Add Feature Page):
|
|
13
|
+
// the Create Application wizard's features (newapp.ts) and a few more, added to an existing application.
|
|
14
|
+
// Each says what it adds before it creates anything, refuses to install twice, and shows as installed
|
|
15
|
+
// (greyed out) on the Create page grid's Feature tab afterwards. Everything is a plain form.
|
|
16
|
+
|
|
17
|
+
/** The page number of the Configuration page (Configuration options). */
|
|
18
|
+
export const CONFIG_PAGE = 9020;
|
|
19
|
+
|
|
20
|
+
interface AppRow {
|
|
21
|
+
id: number;
|
|
22
|
+
alias: string;
|
|
23
|
+
name: string;
|
|
24
|
+
authentication: string;
|
|
25
|
+
feedback: boolean;
|
|
26
|
+
pwa: boolean;
|
|
27
|
+
pwa_push: boolean;
|
|
28
|
+
login_page: number | null;
|
|
29
|
+
theme: Record<string, unknown> | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Feature {
|
|
33
|
+
key: string;
|
|
34
|
+
label: string;
|
|
35
|
+
icon: string;
|
|
36
|
+
help: string;
|
|
37
|
+
/** what installing adds, in words */
|
|
38
|
+
adds: (a: AppRow) => string[];
|
|
39
|
+
/** installed already */
|
|
40
|
+
installed: (c: Client, a: AppRow) => Promise<boolean>;
|
|
41
|
+
/** why it can't be installed (when not installed), or null */
|
|
42
|
+
blocked?: (c: Client, a: AppRow) => Promise<string | null>;
|
|
43
|
+
/** fields of the review form */
|
|
44
|
+
fields?: (a: AppRow) => Promise<Raw | string>;
|
|
45
|
+
/** install (in the caller's transaction); returns the page to open, if any */
|
|
46
|
+
install: (c: Client, a: AppRow, b: Record<string, unknown>) => Promise<number | null>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pageExists = async (c: Client, appId: number, no: number) => !!(await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, no])).rowCount;
|
|
50
|
+
const pageId = async (c: Client, appId: number, no: number) => (await c.query<{ id: number }>('select id from meta.page where app_id = $1 and page_no = $2', [appId, no])).rows[0]?.id ?? null;
|
|
51
|
+
/** a page of the feature: the page number holds a component whose code mentions the marker */
|
|
52
|
+
const pageHas = async (c: Client, appId: number, no: number, marker: string) =>
|
|
53
|
+
!!(await c.query(
|
|
54
|
+
`select 1 from meta.page p where p.app_id = $1 and p.page_no = $2
|
|
55
|
+
and (exists (select 1 from meta.region r where r.page_id = p.id and r.source like '%' || $3 || '%')
|
|
56
|
+
or exists (select 1 from meta.item i where i.page_id = p.id and i.lov like '%' || $3 || '%')
|
|
57
|
+
or exists (select 1 from meta.process x where x.page_id = p.id and x.code like '%' || $3 || '%'))`,
|
|
58
|
+
[appId, no, marker],
|
|
59
|
+
)).rowCount;
|
|
60
|
+
|
|
61
|
+
const EMAIL = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/;
|
|
62
|
+
|
|
63
|
+
export const FEATURE_LIST: Feature[] = [
|
|
64
|
+
{
|
|
65
|
+
key: 'access',
|
|
66
|
+
label: 'Access control',
|
|
67
|
+
icon: 'lock',
|
|
68
|
+
help: 'Authorization schemes for the roles admin, contributor and reader, to put on pages, regions and buttons.',
|
|
69
|
+
adds: () => [
|
|
70
|
+
'Authorization schemes CONTRIBUTOR (role admin or contributor) and READER (admin, contributor or reader), and ADMIN when the application has none',
|
|
71
|
+
'Give users their roles under Shared Components → Users (application access)',
|
|
72
|
+
],
|
|
73
|
+
installed: async (c, a) => (await c.query(`select count(*)::int as n from meta.authz_scheme where app_id = $1 and name in ('CONTRIBUTOR', 'READER')`, [a.id])).rows[0].n === 2,
|
|
74
|
+
install: async (c, a) => {
|
|
75
|
+
await addAccessSchemes(c, a.id);
|
|
76
|
+
return null;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: 'activity',
|
|
81
|
+
label: 'Activity reporting',
|
|
82
|
+
icon: 'activity',
|
|
83
|
+
help: 'A page for administrators with the page views per day and the latest activity.',
|
|
84
|
+
adds: () => [
|
|
85
|
+
`Page ${ACTIVITY_PAGE} "Activity" (authorization ADMIN): a chart of the page views per day and a report of the latest activity, from meta.app_activity()`,
|
|
86
|
+
'A navigation entry Activity for administrators',
|
|
87
|
+
'Authorization scheme ADMIN (role admin) when the application has none',
|
|
88
|
+
],
|
|
89
|
+
installed: (c, a) => pageHas(c, a.id, ACTIVITY_PAGE, 'meta.app_activity'),
|
|
90
|
+
blocked: async (c, a) => ((await pageExists(c, a.id, ACTIVITY_PAGE)) ? `Page ${ACTIVITY_PAGE} is in use by another page.` : null),
|
|
91
|
+
install: async (c, a) => {
|
|
92
|
+
await addActivityPage(c, a.id);
|
|
93
|
+
return pageId(c, a.id, ACTIVITY_PAGE);
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: 'config',
|
|
98
|
+
label: 'Configuration options',
|
|
99
|
+
icon: 'sliders',
|
|
100
|
+
help: 'A page where the application\'s administrators switch build options on and off while it runs.',
|
|
101
|
+
adds: () => [
|
|
102
|
+
`Page ${CONFIG_PAGE} "Configuration" (authorization ADMIN): the configurable build options as switches, and Save`,
|
|
103
|
+
'The build options ticked below become configurable (Shared Components → Build options → Configurable)',
|
|
104
|
+
'A navigation entry Configuration for administrators',
|
|
105
|
+
],
|
|
106
|
+
installed: (c, a) => pageHas(c, a.id, CONFIG_PAGE, 'meta.set_configurable_options'),
|
|
107
|
+
blocked: async (c, a) => ((await pageExists(c, a.id, CONFIG_PAGE)) ? `Page ${CONFIG_PAGE} is in use by another page.` : null),
|
|
108
|
+
fields: async (a) => {
|
|
109
|
+
const opts = (await owner.query<{ name: string; description: string | null; status: string; configurable: boolean }>(
|
|
110
|
+
'select name, description, status, configurable from meta.build_option where app_id = $1 order by name', [a.id])).rows;
|
|
111
|
+
return opts.length
|
|
112
|
+
? html`<fieldset class="prop-group"><legend>Configurable build options</legend><div class="qb-pick">${opts.map((o) =>
|
|
113
|
+
html`<label class="check"><input type="checkbox" name="options" value="${o.name}"${o.configurable ? raw(' checked') : ''}> ${o.name} <span class="muted small">${o.status}${o.description ? `, ${o.description}` : ''}</span></label>`)}</div></fieldset>`
|
|
114
|
+
: html`<p class="muted">The application has no build options yet: add them under Shared Components → Build options and tick <em>Configurable</em>; the page shows them as they come.</p>`;
|
|
115
|
+
},
|
|
116
|
+
install: async (c, a, b) => {
|
|
117
|
+
const chosen = ([] as unknown[]).concat(b.options ?? []).map(String);
|
|
118
|
+
await c.query('update meta.build_option set configurable = (name = any($2)) where app_id = $1', [a.id, chosen]);
|
|
119
|
+
await ensureAdminScheme(c, a.id);
|
|
120
|
+
const home = (await c.query('select home_page from meta.app where id = $1', [a.id])).rows[0].home_page;
|
|
121
|
+
const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page, authz) values ($1, $2, 'Configuration', 'Configuration', $3, 'ADMIN') returning id`, [a.id, CONFIG_PAGE, home])).rows[0].id;
|
|
122
|
+
const regionId = (await c.query(`insert into meta.region (page_id, seq, title, type) values ($1, 10, 'Configuration options', 'form') returning id`, [page])).rows[0].id;
|
|
123
|
+
const item = `P${CONFIG_PAGE}_OPTIONS`;
|
|
124
|
+
await c.query(
|
|
125
|
+
`insert into meta.item (page_id, region_id, seq, name, label, type, lov, config, help) values ($1, $2, 10, $3, 'Options', 'checkbox_group',
|
|
126
|
+
'select description as d, name as r from meta.configurable_options()', '{"switches": true}', 'Switched on: the parts of the application that belong to the option are shown.')`,
|
|
127
|
+
[page, regionId, item],
|
|
128
|
+
);
|
|
129
|
+
await c.query(
|
|
130
|
+
`insert into meta.computation (page_id, seq, item_name, point, type, expression) values ($1, 10, $2, 'before_header', 'sql_query',
|
|
131
|
+
'select string_agg(name, '':'' order by name) from meta.configurable_options() where enabled')`,
|
|
132
|
+
[page, item],
|
|
133
|
+
);
|
|
134
|
+
await c.query(`insert into meta.button (page_id, region_id, seq, name, label, action, hot) values ($1, $2, 10, 'SAVE', 'Save', 'submit', true)`, [page, regionId]);
|
|
135
|
+
await c.query(
|
|
136
|
+
`insert into meta.process (page_id, seq, name, type, code, when_button, success_message) values ($1, 10, 'Save the configuration', 'sql', $2, 'SAVE', 'Configuration saved.')`,
|
|
137
|
+
[page, `select meta.set_configurable_options(:${item})`],
|
|
138
|
+
);
|
|
139
|
+
await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page, authz) values ($1, 9020, 'Configuration', 'sliders', $2, 'ADMIN')`, [a.id, CONFIG_PAGE]);
|
|
140
|
+
return page;
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'email',
|
|
145
|
+
label: 'E-mail reporting',
|
|
146
|
+
icon: 'mail',
|
|
147
|
+
help: 'A weekly e-mail to the administrators with the application\'s activity, the rows as a CSV file.',
|
|
148
|
+
adds: () => [
|
|
149
|
+
'Application setting EMAIL_REPORT_TO: the addresses the report goes to (Shared Components → Application settings)',
|
|
150
|
+
'Automation EMAIL_REPORTING, every Monday at 07:00 UTC: meta.send_activity_report() with the last 7 days (Shared Components → Automations)',
|
|
151
|
+
'Mail goes out through the application\'s mail service (Workspace utilities → Mail services); without addresses nothing is sent',
|
|
152
|
+
],
|
|
153
|
+
installed: async (c, a) => !!(await c.query(`select 1 from meta.automation where app_id = $1 and name = 'EMAIL_REPORTING'`, [a.id])).rowCount,
|
|
154
|
+
fields: async () => html`<div class="field"><label class="label" for="f_report_to">Send it to</label>
|
|
155
|
+
<input id="f_report_to" name="report_to" type="text" placeholder="admin@example.com, boss@example.com" autocomplete="off">
|
|
156
|
+
<small class="help">Comma separated; may stay empty and be set later in Shared Components → Application settings (EMAIL_REPORT_TO).</small></div>`,
|
|
157
|
+
install: async (c, a, b) => {
|
|
158
|
+
const to = String(b.report_to ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
159
|
+
const bad = to.find((x) => !EMAIL.test(x));
|
|
160
|
+
if (bad) throw new Error(`"${bad.slice(0, 100)}" is not an e-mail address.`);
|
|
161
|
+
if (to.join(', ').length > 4000) throw new Error('Too many addresses.');
|
|
162
|
+
await c.query(
|
|
163
|
+
`insert into meta.app_setting (app_id, name, value, description) values ($1, 'EMAIL_REPORT_TO', $2, 'E-mail reporting: the addresses of the weekly activity report (comma separated)')
|
|
164
|
+
on conflict (app_id, name) do nothing`,
|
|
165
|
+
[a.id, to.join(', ') || null],
|
|
166
|
+
);
|
|
167
|
+
await c.query(
|
|
168
|
+
`insert into meta.automation (app_id, name, description, schedule, time_zone) values ($1, 'EMAIL_REPORTING', 'E-mail reporting: the activity of the last 7 days to EMAIL_REPORT_TO', '0 7 * * 1', 'UTC')`,
|
|
169
|
+
[a.id],
|
|
170
|
+
);
|
|
171
|
+
await c.query(`insert into meta.automation_action (app_id, automation_name, seq, name, code) values ($1, 'EMAIL_REPORTING', 10, 'Send the activity report', $2)`, [a.id, CODE_EMAIL]);
|
|
172
|
+
return null;
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
key: 'feedback',
|
|
177
|
+
label: 'Feedback',
|
|
178
|
+
icon: 'message-square',
|
|
179
|
+
help: 'Users send feedback from the header; you read it under the application\'s Activity → Feedback.',
|
|
180
|
+
adds: () => ['A Feedback entry in the header for signed-in users (Settings → Feedback)', 'Their feedback, with the page, under Activity → Feedback in the builder'],
|
|
181
|
+
installed: async (_c, a) => a.feedback,
|
|
182
|
+
install: async (c, a) => {
|
|
183
|
+
await c.query('update meta.app set feedback = true, updated_at = now() where id = $1', [a.id]);
|
|
184
|
+
return null;
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
key: 'about',
|
|
189
|
+
label: 'About page',
|
|
190
|
+
icon: 'info',
|
|
191
|
+
help: 'An About page in a Help menu of the header.',
|
|
192
|
+
adds: () => [`Page ${ABOUT_PAGE} "About" with the application's name and the pages of its menu`, 'A Help menu with About in the navigation bar (list NAVBAR), unless the application has a navigation bar list already'],
|
|
193
|
+
installed: (c, a) => pageExists(c, a.id, ABOUT_PAGE),
|
|
194
|
+
install: async (c, a) => {
|
|
195
|
+
await addAboutPage(c, a.id);
|
|
196
|
+
return pageId(c, a.id, ABOUT_PAGE);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: 'login',
|
|
201
|
+
label: 'Login page',
|
|
202
|
+
icon: 'log-in',
|
|
203
|
+
help: 'The application\'s own login page instead of the built-in sign-in page, editable in the Page Designer.',
|
|
204
|
+
adds: () => ['Page 9999 (or the next free number) "Login": public, page template Minimal, with a Login region', 'It becomes the login page (Settings → Sign-in methods)'],
|
|
205
|
+
installed: async (_c, a) => a.login_page !== null,
|
|
206
|
+
blocked: async (_c, a) => (a.authentication === 'none' ? 'The application has no sign-in (authentication none).' : null),
|
|
207
|
+
install: async (c, a) => (await createLoginPage(c, a)).page,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
key: 'theme',
|
|
211
|
+
label: 'Theme style selection',
|
|
212
|
+
icon: 'palette',
|
|
213
|
+
help: 'Users choose the light or the dark theme in their user menu (Appearance).',
|
|
214
|
+
adds: () => ['Appearance in the user menu: light, dark or the device\'s setting (Settings → Theme → Users may choose)'],
|
|
215
|
+
installed: async (_c, a) => a.theme?.user_choice !== false,
|
|
216
|
+
install: async (c, a) => {
|
|
217
|
+
await c.query(`update meta.app set theme = coalesce(theme, '{}') - 'user_choice', updated_at = now() where id = $1`, [a.id]);
|
|
218
|
+
return null;
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
key: 'push',
|
|
223
|
+
label: 'Push notifications',
|
|
224
|
+
icon: 'bell',
|
|
225
|
+
help: 'Notifications to the users\' devices, from processes and SQL (meta.send_push); the application becomes installable.',
|
|
226
|
+
adds: () => ['Install as an app (PWA): a manifest, a service worker and an Install button', 'Push notifications: users subscribe on My account; send with the send_push process or meta.send_push', 'The application\'s push key pair (kept encrypted)'],
|
|
227
|
+
installed: async (_c, a) => a.pwa_push,
|
|
228
|
+
blocked: async () => (secretKeyConfigured() ? null : 'Push notifications need PGKILN_SECRET_KEY in the server\'s environment.'),
|
|
229
|
+
install: async (c, a) => {
|
|
230
|
+
await c.query('update meta.app set pwa = true, pwa_push = true, updated_at = now() where id = $1', [a.id]);
|
|
231
|
+
return null;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const CODE_EMAIL = `select meta.send_activity_report(meta.get_app_setting('EMAIL_REPORT_TO'), 7)`;
|
|
237
|
+
|
|
238
|
+
const FEATURES_BY_KEY = new Map(FEATURE_LIST.map((f) => [f.key, f]));
|
|
239
|
+
|
|
240
|
+
async function appRow(id: string | number, c?: Client): Promise<AppRow | null> {
|
|
241
|
+
const q = 'select id, alias, name, authentication, feedback, pwa, pwa_push, login_page, theme from meta.app where id = $1';
|
|
242
|
+
return c ? ((await c.query<AppRow>(q, [id])).rows[0] ?? null) : ((await owner.one<AppRow>(q, [id])) ?? null);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Each feature with whether it is installed or blocked, for the Feature tab. */
|
|
246
|
+
export async function featureStates(appId: number) {
|
|
247
|
+
return owner.tx(async (c) => {
|
|
248
|
+
const a = (await appRow(appId, c))!;
|
|
249
|
+
const out: { f: Feature; installed: boolean; blocked: string | null }[] = [];
|
|
250
|
+
for (const f of FEATURE_LIST) {
|
|
251
|
+
const installed = await f.installed(c, a);
|
|
252
|
+
out.push({ f, installed, blocked: installed ? null : ((await f.blocked?.(c, a)) ?? null) });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function featureRoutes(app: FastifyInstance) {
|
|
259
|
+
// the review: what the feature adds, and Install (or why not)
|
|
260
|
+
app.get(`${BASE}/apps/:id/features/:key`, async (req: Req, reply) => {
|
|
261
|
+
const s = await developer(req, reply);
|
|
262
|
+
if (!s) return;
|
|
263
|
+
const a0 = await appOr404(req.params.id);
|
|
264
|
+
const f = FEATURES_BY_KEY.get(String(req.params.key));
|
|
265
|
+
if (!a0 || !f) return reply.code(404).send('Not found');
|
|
266
|
+
const a = (await appRow(a0.id))!;
|
|
267
|
+
const { installed, blocked } = await owner.tx(async (c) => {
|
|
268
|
+
const installed = await f.installed(c, a);
|
|
269
|
+
return { installed, blocked: installed ? null : ((await f.blocked?.(c, a)) ?? null) };
|
|
270
|
+
});
|
|
271
|
+
const main = html`${appHeader(a0, 'pages', s)}
|
|
272
|
+
${region(f.label, html`
|
|
273
|
+
<p class="muted u-mt0">${icon(f.icon)} ${f.help}</p>
|
|
274
|
+
${installed
|
|
275
|
+
? html`<p class="alert alert-info" role="status">${f.label} is installed in this application already.</p>`
|
|
276
|
+
: blocked ? html`<p class="alert alert-error" role="status">${blocked}</p>` : ''}
|
|
277
|
+
<h3>${installed ? 'It added' : 'It adds'}</h3>
|
|
278
|
+
<ul class="cp-adds">${f.adds(a).map((x) => html`<li>${x}</li>`)}</ul>
|
|
279
|
+
${installed || blocked
|
|
280
|
+
? html`<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=feature">Back to the features</a></div>`
|
|
281
|
+
: html`<form method="post" action="${BASE}/apps/${a.id}/features/${f.key}">${csrf(s)}
|
|
282
|
+
${f.fields ? await f.fields(a) : ''}
|
|
283
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=feature">Cancel</a><button class="btn btn-hot">Add ${f.label.toLowerCase()}</button></div>
|
|
284
|
+
</form>`}`)}`;
|
|
285
|
+
return send(reply, s, shell(s, `${f.label} · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=feature`], [f.label]], main));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
app.post(`${BASE}/apps/:id/features/:key`, async (req: Req, reply) => {
|
|
289
|
+
const s = await developer(req, reply);
|
|
290
|
+
if (!s) return;
|
|
291
|
+
const f = FEATURES_BY_KEY.get(String(req.params.key));
|
|
292
|
+
if (!f || !(await appRow(req.params.id))) return reply.code(404).send('Not found');
|
|
293
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
294
|
+
try {
|
|
295
|
+
const page = await owner.tx(async (c) => {
|
|
296
|
+
// one at a time per application, and never twice
|
|
297
|
+
await c.query('select 1 from meta.app where id = $1 for update', [req.params.id]);
|
|
298
|
+
const a = (await appRow(req.params.id, c))!;
|
|
299
|
+
if (await f.installed(c, a)) throw new Error(`${f.label} is installed in this application already.`);
|
|
300
|
+
const blocked = await f.blocked?.(c, a);
|
|
301
|
+
if (blocked) throw new Error(blocked);
|
|
302
|
+
return f.install(c, a, b);
|
|
303
|
+
});
|
|
304
|
+
// the push key pair after the commit (as the Create Application wizard does)
|
|
305
|
+
if (f.key === 'push') await appPublicKey(Number(req.params.id)).catch(() => undefined);
|
|
306
|
+
flash(s, `${f.label} added.`);
|
|
307
|
+
return back(reply, s, page ? `${BASE}/pages/${page}` : `${BASE}/apps/${req.params.id}/create-page?tab=feature`);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
flash(s, (e as Error).message, 'error');
|
|
310
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/features/${f.key}`);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
package/src/builder/home.ts
CHANGED
|
@@ -295,6 +295,8 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
295
295
|
: s.workspace && (await workspaceAdmin(s.username, s.workspace.id))
|
|
296
296
|
? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), `Workspace ${s.workspace.name}`, 'Its name, developers, preferences and extension links (workspace administrators).')
|
|
297
297
|
: ''}
|
|
298
|
+
${card(`${BASE}/ui-defaults`, icon('sliders-horizontal'), 'UI defaults', 'Labels, format masks, help and lists of values per table column, used by the page wizards.')}
|
|
299
|
+
${card(`${BASE}/icons`, icon('image'), 'Icons', 'Browse the icons of the icon picker.')}
|
|
298
300
|
${card(`${BASE}/vector-providers`, icon('search'), 'Vector providers', 'Embeddings for search by meaning: the applications\' vector providers, their vector columns, and whether pgvector is installed.')}
|
|
299
301
|
${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
|
|
300
302
|
${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
|