biz-a-cli 2.3.80-15419 → 2.3.80
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/bin/app.js +20 -255
- package/bin/hub.js +0 -16
- package/bin/hubEvent.js +5 -98
- package/bin/tunnelTarget.js +0 -4
- package/engine/bpm/workflow.js +6 -7
- package/engine/cc/configValidate.js +0 -53
- package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -531
- package/engine/cc/layer3Behavior.js +1 -2
- package/engine/cc/layer3Schema.js +1 -3
- package/engine/cc/livePipelines.js +3 -9
- package/engine/cc/publish.js +105 -86
- package/engine/domain/appConfig.js +18 -108
- package/engine/domain/applicationConfig.js +12 -87
- package/engine/domain/dialect.js +0 -138
- package/engine/domain/domainNavigation.js +50 -86
- package/engine/domain/entityConfigLoader.js +4 -16
- package/engine/domain/numericScale.js +0 -2
- package/engine/domain/system.js +0 -2
- package/engine/ext/publicTheme.js +1 -8
- package/engine/orm/apiRoute.js +61 -187
- package/engine/orm/entityConfig.js +2 -32
- package/engine/orm/finaAuth.js +0 -0
- package/engine/orm/finaEndpoint.js +0 -19
- package/engine/orm/imgQuery.js +0 -92
- package/engine/orm/listQuery.js +1 -6
- package/package.json +1 -1
- package/worker/cliScriptWorker.js +0 -5
- package/engine/attachments/store.js +0 -195
- package/engine/domain/domainRegistration.js +0 -65
- package/engine/fina/approval.js +0 -141
- package/engine/fina/approvalService.js +0 -59
- package/engine/fina/attachments.js +0 -165
- package/engine/fina/client.js +0 -84
- package/engine/fina/config.js +0 -26
- package/engine/fina/context.js +0 -58
- package/engine/fina/document.js +0 -67
- package/engine/fina/documentWrite.js +0 -173
- package/engine/fina/documents/index.js +0 -28
- package/engine/fina/documents/jv.js +0 -105
- package/engine/fina/documents/jvWrite.js +0 -260
- package/engine/fina/documents/si.js +0 -40
- package/engine/fina/errors.js +0 -17
- package/engine/fina/features.js +0 -86
- package/engine/fina/guard.js +0 -102
- package/engine/fina/identity.js +0 -87
- package/engine/fina/live.js +0 -19
- package/engine/fina/lock.js +0 -35
- package/engine/fina/master.js +0 -331
- package/engine/fina/masters/coa.js +0 -104
- package/engine/fina/masters/currency.js +0 -39
- package/engine/fina/masters/department.js +0 -32
- package/engine/fina/masters/index.js +0 -21
- package/engine/fina/masters/project.js +0 -53
- package/engine/fina/period.js +0 -56
- package/engine/fina/print.js +0 -136
- package/engine/fina/readPolicy.js +0 -67
- package/engine/fina/recurring.js +0 -200
- package/engine/fina/route.js +0 -61
- package/engine/fina/routes/attachment.js +0 -41
- package/engine/fina/routes/document.js +0 -66
- package/engine/fina/routes/master.js +0 -26
- package/engine/fina/routes/period.js +0 -24
- package/engine/fina/routes/print.js +0 -19
- package/engine/fina/routes/recurring.js +0 -27
- package/engine/fina/routes/session.js +0 -23
- package/engine/fina/routes/setup.js +0 -58
- package/engine/fina/routes/submission.js +0 -46
- package/engine/fina/routes/template.js +0 -31
- package/engine/fina/settings.js +0 -159
- package/engine/fina/sql.js +0 -34
- package/engine/fina/submission.js +0 -290
- package/engine/fina/submissionCompare.js +0 -171
- package/engine/fina/submissionRepo.js +0 -114
- package/engine/fina/template.js +0 -118
- package/engine/fina/userMap.js +0 -79
- package/engine/orm/companyLocale.js +0 -74
- package/engine/orm/userApiDataset.js +0 -153
- package/migrations/postgres/1790600000000__fina.sql +0 -33
- package/migrations/postgres/1790700000000__attachment.sql +0 -29
- package/migrations/postgres/1790800000000__fina_jv_submission.sql +0 -41
package/engine/fina/template.js
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 — FINA form templates (appendix T-08 §3.1) and the extended fields mapped onto a document (T-12–T-16).
|
|
3
|
-
*
|
|
4
|
-
* TEMPLATE: TEMPLATEID, TEMPLATETYPE (14 JV, 15 Other Payment, 16 Other Deposit, 0 Sales Invoice…), NAME, ISDEFTEMPLATE.
|
|
5
|
-
* TEMPLDET: one row per element (SECTION, SEQ): CAPTION is its fixed internal name, DEFAULTCAPTION the user's caption
|
|
6
|
-
* (with the `&` accelerator), SCREEN 1 = shown, ENABLESCREEN 0 = locked by the designer, ORDERNUMBER = a detail
|
|
7
|
-
* column's place, FIELDDATA = the print expression (`[<table><field>]` for an extended field).
|
|
8
|
-
* EXTENDEDMAPPING: which extended field of which table is in use (managed in FINA desktop, read only here, T-13).
|
|
9
|
-
*
|
|
10
|
-
* M1 needed only the template list a console picks the JV default from; M4 reads the rest for the template engine.
|
|
11
|
-
*/
|
|
12
|
-
import { finaError } from "./errors.js";
|
|
13
|
-
import { sqlString } from "./sql.js";
|
|
14
|
-
|
|
15
|
-
export const TEMPLATE_TYPE = Object.freeze({ JV: 14 });
|
|
16
|
-
|
|
17
|
-
const integer = (v) => {
|
|
18
|
-
const n = Number(v);
|
|
19
|
-
return v !== null && v !== undefined && v !== "" && Number.isInteger(n) ? n : null;
|
|
20
|
-
};
|
|
21
|
-
const on = (v) => v === true || v === 1 || String(v ?? "").trim() === "1";
|
|
22
|
-
|
|
23
|
-
export const listTemplates = async (client, type) => {
|
|
24
|
-
const t = Number(type);
|
|
25
|
-
if (!Number.isInteger(t)) return [];
|
|
26
|
-
const rows = (await client.executeSql(`SELECT TEMPLATEID, NAME, ISDEFTEMPLATE FROM TEMPLATE WHERE TEMPLATETYPE = ${t} ORDER BY NAME`)) ?? [];
|
|
27
|
-
return rows.map((r) => ({ id: Number(r.templateid), name: String(r.name ?? ""), isDefault: String(r.isdeftemplate ?? "") === "1" }));
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/* The user's caption: the Windows accelerator `&` goes, `&&` stays one `&`. */
|
|
31
|
-
export const stripAccelerator = (caption) =>
|
|
32
|
-
String(caption ?? "")
|
|
33
|
-
.replace(/&&/g, "\u0000")
|
|
34
|
-
.replace(/&/g, "")
|
|
35
|
-
.replace(/\u0000/g, "&")
|
|
36
|
-
.trim();
|
|
37
|
-
|
|
38
|
-
/*
|
|
39
|
-
* Which template a form shows (T-22, T-23): the one asked for → the document's own → the console's default (there is no
|
|
40
|
-
* default per user) → FINA's own default → the first. Each must be one of the type's templates.
|
|
41
|
-
*/
|
|
42
|
-
export const chooseTemplateId = ({ templates = [], requested, documentTemplateId, settingDefault } = {}) => {
|
|
43
|
-
const ids = new Set(templates.map((t) => t.id));
|
|
44
|
-
for (const candidate of [requested, documentTemplateId, settingDefault]) {
|
|
45
|
-
const id = integer(candidate);
|
|
46
|
-
if (id !== null && ids.has(id)) return id;
|
|
47
|
-
}
|
|
48
|
-
return templates.find((t) => t.isDefault)?.id ?? templates[0]?.id ?? null;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/* A requested template must belong to the document's type: FINA would render any template id for any document. */
|
|
52
|
-
export const assertTemplateOfType = (templates, templateId) => {
|
|
53
|
-
const id = integer(templateId);
|
|
54
|
-
if (id === null || !templates.some((t) => t.id === id)) {
|
|
55
|
-
throw finaError("TEMPLATE_NOT_FOUND", `Template ${templateId} is not a template of this document.`);
|
|
56
|
-
}
|
|
57
|
-
return id;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export const readTemplateElements = async (client, templateId) => {
|
|
61
|
-
const id = integer(templateId);
|
|
62
|
-
if (id === null) return [];
|
|
63
|
-
const rows =
|
|
64
|
-
(await client.executeSql(
|
|
65
|
-
`SELECT SECTION, SEQ, CAPTION, DEFAULTCAPTION, SCREEN, ENABLESCREEN, ORDERNUMBER, FIELDDATA FROM TEMPLDET WHERE TEMPLATEID = ${id} ORDER BY SECTION, SEQ`,
|
|
66
|
-
)) ?? [];
|
|
67
|
-
return rows.map((r) => {
|
|
68
|
-
const caption = String(r.caption ?? "").trim();
|
|
69
|
-
return {
|
|
70
|
-
section: Number(r.section),
|
|
71
|
-
seq: Number(r.seq),
|
|
72
|
-
caption,
|
|
73
|
-
label: stripAccelerator(r.defaultcaption) || stripAccelerator(caption),
|
|
74
|
-
screen: on(r.screen),
|
|
75
|
-
enableScreen: on(r.enablescreen),
|
|
76
|
-
orderNumber: integer(r.ordernumber),
|
|
77
|
-
fieldData: String(r.fielddata ?? "").trim(),
|
|
78
|
-
};
|
|
79
|
-
});
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/*
|
|
83
|
-
* The extended fields mapped onto a document's tables (`{ master: 'JV', detail: 'JVDET' }`). A lookup field picks an
|
|
84
|
-
* EXTENDEDDET row of its EXTENDEDTYPE (the value is its EXTENDEDDETID); a custom field holds its own value, of
|
|
85
|
-
* CUSTOMFIELDTYPE 1–4.
|
|
86
|
-
*/
|
|
87
|
-
export const readMappings = async (client, tables) => {
|
|
88
|
-
const parts = Object.entries(tables ?? {}).filter(([, table]) => /^[A-Z0-9_$]+$/.test(String(table ?? "")));
|
|
89
|
-
if (parts.length === 0) return [];
|
|
90
|
-
const partOf = Object.fromEntries(parts.map(([part, table]) => [table, part]));
|
|
91
|
-
const rows =
|
|
92
|
-
(await client.executeSql(
|
|
93
|
-
"SELECT M.MAPTABLENAME, M.MAPFIELDNAME, M.CAPTION, M.EXTENDEDTYPEID, M.CUSTOMFIELDTYPE, T.EXTENDEDNAME, T.INFOCAPTION1, T.INFOCAPTION2 " +
|
|
94
|
-
"FROM EXTENDEDMAPPING M LEFT JOIN EXTENDEDTYPE T ON T.EXTENDEDTYPEID = M.EXTENDEDTYPEID " +
|
|
95
|
-
`WHERE M.MAPTABLENAME IN (${parts.map(([, table]) => sqlString(table)).join(", ")}) ORDER BY M.MAPTABLENAME, M.MAPFIELDNAME`,
|
|
96
|
-
)) ?? [];
|
|
97
|
-
const order = parts.map(([, table]) => table);
|
|
98
|
-
return rows
|
|
99
|
-
.map((r) => {
|
|
100
|
-
const table = String(r.maptablename ?? "").trim().toUpperCase();
|
|
101
|
-
const field = String(r.mapfieldname ?? "").trim().toUpperCase();
|
|
102
|
-
const lookup = /^LOOKUP\d+$/.test(field);
|
|
103
|
-
return {
|
|
104
|
-
part: partOf[table],
|
|
105
|
-
table,
|
|
106
|
-
field,
|
|
107
|
-
key: `ext_${field.toLowerCase()}`,
|
|
108
|
-
caption: String(r.caption ?? "").trim() || field,
|
|
109
|
-
kind: lookup ? "lookup" : "custom",
|
|
110
|
-
typeId: lookup ? integer(r.extendedtypeid) : null,
|
|
111
|
-
typeName: lookup && r.extendedname != null ? String(r.extendedname).trim() : null,
|
|
112
|
-
infoCaptions: lookup ? [r.infocaption1, r.infocaption2].map((c) => String(c ?? "").trim()).filter(Boolean) : [],
|
|
113
|
-
customType: lookup ? null : integer(r.customfieldtype),
|
|
114
|
-
};
|
|
115
|
-
})
|
|
116
|
-
.filter((m) => m.part && /^(LOOKUP|CUSTOMFIELD)\d+$/.test(m.field))
|
|
117
|
-
.sort((a, b) => order.indexOf(a.table) - order.indexOf(b.table));
|
|
118
|
-
};
|
package/engine/fina/userMap.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Doc 8 S-01 — mapping each Biz-A user (SYS$USERS) to a FINA user (FINA's USERS), in FINA$USER_MAP.
|
|
3
|
-
*
|
|
4
|
-
* - A Biz-A user maps to at most one FINA user; several Biz-A users may share one FINA user.
|
|
5
|
-
* - `finaUserId: null` removes a mapping. Rows not sent are left as they are.
|
|
6
|
-
* - Every id is checked before anything is written, and the save is ONE transaction: a refused row leaves the map
|
|
7
|
-
* exactly as it was.
|
|
8
|
-
* - ⚠️ No password column is read on either side.
|
|
9
|
-
*/
|
|
10
|
-
import { finaError } from "./errors.js";
|
|
11
|
-
import { ACTIVE_FINA_USER } from "./identity.js";
|
|
12
|
-
|
|
13
|
-
const asId = (v) => (typeof v === "number" ? (Number.isInteger(v) && v >= 0 ? v : null) : /^\d+$/.test(String(v ?? "")) ? Number(v) : null);
|
|
14
|
-
const actorSql = (actorId) => (asId(actorId) === null ? "NULL" : String(asId(actorId)));
|
|
15
|
-
|
|
16
|
-
export const createUserMap = ({ exec, client, transaction }) => {
|
|
17
|
-
const list = async () => {
|
|
18
|
-
const [biza, fina, map] = [
|
|
19
|
-
(await exec("SELECT USERID, USERNAME, FULLNAME, ACTIVE FROM SYS$USERS ORDER BY USERNAME")) ?? [],
|
|
20
|
-
(await client.executeSql(`SELECT USERID, USERNAME FROM USERS WHERE ${ACTIVE_FINA_USER} ORDER BY USERNAME`)) ?? [],
|
|
21
|
-
(await exec("SELECT BIZA_USER_ID, FINA_USER_ID FROM FINA$USER_MAP")) ?? [],
|
|
22
|
-
];
|
|
23
|
-
return {
|
|
24
|
-
bizaUsers: biza.map((u) => ({
|
|
25
|
-
userId: Number(u.userid),
|
|
26
|
-
userName: String(u.username ?? ""),
|
|
27
|
-
fullName: u.fullname === null || u.fullname === undefined ? null : String(u.fullname),
|
|
28
|
-
active: Number(u.active) === 1,
|
|
29
|
-
})),
|
|
30
|
-
finaUsers: fina.map((u) => ({ userId: Number(u.userid), userName: String(u.username ?? "") })),
|
|
31
|
-
map: map.map((m) => ({ bizaUserId: Number(m.biza_user_id), finaUserId: Number(m.fina_user_id) })),
|
|
32
|
-
};
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const save = async (payload, actorId) => {
|
|
36
|
-
const rows = payload?.rows;
|
|
37
|
-
if (!Array.isArray(rows)) throw finaError("INVALID", "rows must be a list of { bizaUserId, finaUserId }.");
|
|
38
|
-
|
|
39
|
-
const problems = [];
|
|
40
|
-
const seen = new Set();
|
|
41
|
-
const clean = [];
|
|
42
|
-
rows.forEach((row, i) => {
|
|
43
|
-
const bizaUserId = asId(row?.bizaUserId);
|
|
44
|
-
const rawFina = row?.finaUserId;
|
|
45
|
-
const finaUserId = rawFina === null || rawFina === undefined || rawFina === "" ? null : asId(rawFina);
|
|
46
|
-
if (bizaUserId === null) problems.push(`row ${i + 1}: bizaUserId must be a whole number`);
|
|
47
|
-
if (finaUserId === null && !(rawFina === null || rawFina === undefined || rawFina === "")) {
|
|
48
|
-
problems.push(`row ${i + 1}: finaUserId must be a whole number or empty`);
|
|
49
|
-
}
|
|
50
|
-
if (bizaUserId !== null && seen.has(bizaUserId)) problems.push(`Biz-A user ${bizaUserId} is listed twice`);
|
|
51
|
-
if (bizaUserId !== null) seen.add(bizaUserId);
|
|
52
|
-
clean.push({ bizaUserId, finaUserId });
|
|
53
|
-
});
|
|
54
|
-
if (problems.length > 0) throw finaError("INVALID", problems.join("; "));
|
|
55
|
-
|
|
56
|
-
const known = await list();
|
|
57
|
-
const bizaIds = new Set(known.bizaUsers.map((u) => u.userId));
|
|
58
|
-
const finaIds = new Set(known.finaUsers.map((u) => u.userId));
|
|
59
|
-
for (const { bizaUserId, finaUserId } of clean) {
|
|
60
|
-
if (!bizaIds.has(bizaUserId)) problems.push(`Biz-A user ${bizaUserId} does not exist`);
|
|
61
|
-
if (finaUserId !== null && !finaIds.has(finaUserId)) problems.push(`FINA user ${finaUserId} does not exist or is inactive`);
|
|
62
|
-
}
|
|
63
|
-
if (problems.length > 0) throw finaError("INVALID", problems.join("; "));
|
|
64
|
-
|
|
65
|
-
await transaction(async () => {
|
|
66
|
-
for (const { bizaUserId, finaUserId } of clean) {
|
|
67
|
-
await exec(
|
|
68
|
-
finaUserId === null
|
|
69
|
-
? `DELETE FROM FINA$USER_MAP WHERE BIZA_USER_ID = ${bizaUserId}`
|
|
70
|
-
: `INSERT INTO FINA$USER_MAP (BIZA_USER_ID, FINA_USER_ID, UPDATED_BY, UPDATED_AT) VALUES (${bizaUserId}, ${finaUserId}, ${actorSql(actorId)}, now()) ` +
|
|
71
|
-
"ON CONFLICT (BIZA_USER_ID) DO UPDATE SET FINA_USER_ID = EXCLUDED.FINA_USER_ID, UPDATED_BY = EXCLUDED.UPDATED_BY, UPDATED_AT = now()",
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
return list();
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
return { list, save };
|
|
79
|
-
};
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Agung 2026-10-10 — the company's NUMBER FORMAT (one per tenant): 'id' shows and reads 1.500.000,50, 'en-US'
|
|
3
|
-
* 1,500,000.50. The client formats numbers AND reads typed ones by it (client shared/number-locale.ts).
|
|
4
|
-
*
|
|
5
|
-
* ⚠️ THE COMPANY THEME'S PATH, ON PURPOSE (companyTheme.js, companyLogo.js): one SYS$CONFIG row of its own; the same
|
|
6
|
-
* administrator gate (principalGuards.js — only a verified administrator of the company changes it); the same audit
|
|
7
|
-
* table, so "who changed it, from what to what" lives where the other company settings' history does.
|
|
8
|
-
*
|
|
9
|
-
* ⚠️ READ NEVER THROWS: a missing or malformed row is Indonesian, the format the platform had before the setting
|
|
10
|
-
* existed. Throwing would take down every screen that formats a number.
|
|
11
|
-
*/
|
|
12
|
-
import { POSTGRES_SQL } from "./sqlDialect.js";
|
|
13
|
-
import { requirePrincipal, requireAdministrator } from "./principalGuards.js";
|
|
14
|
-
import { THEME_AUDIT_TABLE } from "./companyTheme.js";
|
|
15
|
-
|
|
16
|
-
export const COMPANY_LOCALE_NAME = "COMPANY_LOCALE";
|
|
17
|
-
|
|
18
|
-
/* One row, no version of its own — the theme's reason (companyTheme.js COMPANY_THEME_VERSION). */
|
|
19
|
-
export const COMPANY_LOCALE_VERSION = "1";
|
|
20
|
-
|
|
21
|
-
/* ⚠️ An allowlist: a value not named here is refused on write and read back as the default. */
|
|
22
|
-
export const NUMBER_LOCALES = ["id", "en-US"];
|
|
23
|
-
export const DEFAULT_LOCALE = Object.freeze({ number: "id" });
|
|
24
|
-
|
|
25
|
-
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
26
|
-
|
|
27
|
-
const parse = (raw) => {
|
|
28
|
-
if (raw === null || raw === undefined || raw === "") return null;
|
|
29
|
-
if (isPlainObject(raw)) return raw;
|
|
30
|
-
try {
|
|
31
|
-
const parsed = JSON.parse(String(raw));
|
|
32
|
-
return isPlainObject(parsed) ? parsed : null;
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export const readCompanyLocale = async ({ exec }) => {
|
|
39
|
-
const rows = await exec(`SELECT ID, DATA FROM SYS$CONFIG WHERE NAME = '${COMPANY_LOCALE_NAME}' ORDER BY ID`);
|
|
40
|
-
const stored = parse(Array.isArray(rows) && rows[0] ? (rows[0].DATA ?? rows[0].data) : null);
|
|
41
|
-
return { number: stored && NUMBER_LOCALES.includes(stored.number) ? stored.number : DEFAULT_LOCALE.number };
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/*
|
|
45
|
-
* Save the setting and record who changed it. A refusal of the VALUE is returned (`problems`, shown on the settings
|
|
46
|
-
* screen); a caller who may not change it at all is thrown (no admin can fix that).
|
|
47
|
-
*/
|
|
48
|
-
export const writeCompanyLocale = async ({ exec, execStatements, principal, locale, sql = POSTGRES_SQL }) => {
|
|
49
|
-
const userId = requirePrincipal(principal, "changing the company number format requires a verified principal");
|
|
50
|
-
await requireAdministrator(exec, userId, "only administrators may change the company number format");
|
|
51
|
-
|
|
52
|
-
if (!isPlainObject(locale)) return { ok: false, problems: ["locale must be an object such as { number: 'id' }."], locale: null };
|
|
53
|
-
/* ⚠️ CLOSED: an unknown key is refused, never stored (a userId in the payload is the case that matters). */
|
|
54
|
-
const problems = Object.keys(locale)
|
|
55
|
-
.filter((key) => key !== "number")
|
|
56
|
-
.map((key) => `locale.${key} is not a setting.`);
|
|
57
|
-
if (!NUMBER_LOCALES.includes(locale.number)) problems.push(`locale.number must be one of ${NUMBER_LOCALES.join(", ")}.`);
|
|
58
|
-
if (problems.length > 0) return { ok: false, problems, locale: null };
|
|
59
|
-
|
|
60
|
-
const previous = await readCompanyLocale({ exec });
|
|
61
|
-
const stored = { number: locale.number };
|
|
62
|
-
const json = JSON.stringify(stored);
|
|
63
|
-
|
|
64
|
-
/* ONE TRANSACTION, UPDATE-then-guarded-INSERT — companyTheme.js says why on both counts. */
|
|
65
|
-
await execStatements([
|
|
66
|
-
`INSERT INTO ${THEME_AUDIT_TABLE} (USER_ID, CHANGED_AT, OLD_THEME, NEW_THEME) ` +
|
|
67
|
-
`VALUES (${userId}, ${sql.now}, ${sql.json(JSON.stringify(previous))}, ${sql.json(json)})`,
|
|
68
|
-
`UPDATE SYS$CONFIG SET DATA = ${sql.text(json)} WHERE NAME = '${COMPANY_LOCALE_NAME}'`,
|
|
69
|
-
`INSERT INTO SYS$CONFIG (NAME, VERSION, DATA) ` +
|
|
70
|
-
`SELECT '${COMPANY_LOCALE_NAME}', '${COMPANY_LOCALE_VERSION}', ${sql.text(json)}${sql.fromNothing} ` +
|
|
71
|
-
`WHERE NOT EXISTS (SELECT 1 FROM SYS$CONFIG WHERE NAME = '${COMPANY_LOCALE_NAME}')`,
|
|
72
|
-
]);
|
|
73
|
-
return { ok: true, problems: [], locale: stored };
|
|
74
|
-
};
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* FINA's `TUserApiDataset`, served for a PostgreSQL index — the admin Users screen.
|
|
3
|
-
*
|
|
4
|
-
* User List → Add → Save (client/src/app/admin/userForm.component.ts modifyUser) and the list's
|
|
5
|
-
* Delete (usersList.component.ts) post TFinaMethod/"json2fina" with this data module. apiRoute did
|
|
6
|
-
* not route json2fina, so it went to FINA — which a PostgreSQL tenant does not run, and Add User
|
|
7
|
-
* failed as `502 ECONNREFUSED 127.0.0.1:212`. Users there live in SYS$USERS, beside the tenant data.
|
|
8
|
-
*
|
|
9
|
-
* The Delphi trail, so the behaviour is FINA's and not a guess:
|
|
10
|
-
* FINAMethod.pas:459 needAuthenticate — TUserApiDataset is the one module that demands `password`
|
|
11
|
-
* FINAMethod.pas:734 that password is the CALLER's (`userId`) own -> 'Incorrect password'
|
|
12
|
-
* FINAMethod.pas:499 isAppend — an `id` of '' or -1 inserts; `delete = 1` deletes; else edits
|
|
13
|
-
* FINAMethod.pas:538 AssignField — a master key with no matching column is ignored
|
|
14
|
-
* FINAMethod.pas:839 {"id": <key>} on success, {"error": <message>} on failure, HTTP 200 both
|
|
15
|
-
* UserApiDataset.pas USERNAME upper-cased; `plainpassword` hashed into USERPASSWORD
|
|
16
|
-
*
|
|
17
|
-
* ⚠️ ONE DELIBERATE DIFFERENCE: FINA skips the confirmation when `password` is '' (FINAMethod.pas:734
|
|
18
|
-
* tests `password <> Str_Empty`). Here an empty password is simply an incorrect one — the screen
|
|
19
|
-
* always asks for it, and a write to the user table must not be one empty field away from unguarded.
|
|
20
|
-
*
|
|
21
|
-
* Passwords are hashed with finaAuth.js's scrypt, the scheme the PostgreSQL login verifies, so a user
|
|
22
|
-
* created here can sign in straight away. The envelope carries plain-text passwords: nothing in this
|
|
23
|
-
* module logs or echoes it.
|
|
24
|
-
*/
|
|
25
|
-
import { hashPassword, verifyCredential } from "./finaAuth.js";
|
|
26
|
-
import { buildInsertUserSql } from "./userAdmin.js";
|
|
27
|
-
import { withoutStatement } from "./saveErrors.js";
|
|
28
|
-
import { logger } from "../../logger.js";
|
|
29
|
-
|
|
30
|
-
export const USER_API_DATASET = "TUserApiDataset";
|
|
31
|
-
|
|
32
|
-
export const isUserApiDataset = (param) => String(param?.dataModule ?? "") === USER_API_DATASET;
|
|
33
|
-
|
|
34
|
-
const quote = (value) => String(value ?? "").replace(/'/g, "''");
|
|
35
|
-
const upper = (value) => String(value ?? "").toUpperCase();
|
|
36
|
-
const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
37
|
-
const isBlank = (value) => String(value ?? "").trim() === "";
|
|
38
|
-
|
|
39
|
-
/* Delphi's FieldByName is case-insensitive; the client happens to send lower case. */
|
|
40
|
-
const lowerKeys = (object) =>
|
|
41
|
-
Object.fromEntries(Object.entries(object ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
42
|
-
|
|
43
|
-
/* The columns verifyCredential reads — the same ones the login selects. */
|
|
44
|
-
export const buildFindUserByIdSql = (userId) =>
|
|
45
|
-
`SELECT USERID, USERNAME, USERPASSWORD, USERLEVEL FROM SYS$USERS WHERE USERID = ${Number(userId)}`;
|
|
46
|
-
|
|
47
|
-
/* Only the keys the master carries are written. ⚠️ USERPASSWORD only for a non-empty plainpassword:
|
|
48
|
-
the edit form omits it when the password is unchanged, and writing it anyway would hash ''. */
|
|
49
|
-
export const buildUpdateUserSql = (userId, master = {}) => {
|
|
50
|
-
const fields = lowerKeys(master);
|
|
51
|
-
const sets = [];
|
|
52
|
-
if (has(fields, "username")) sets.push(`USERNAME = '${quote(upper(fields.username))}'`);
|
|
53
|
-
if (has(fields, "fullname")) {
|
|
54
|
-
sets.push(`FULLNAME = ${fields.fullname === null ? "NULL" : `'${quote(fields.fullname)}'`}`);
|
|
55
|
-
}
|
|
56
|
-
if (has(fields, "userlevel") && !isBlank(fields.userlevel) && Number.isInteger(Number(fields.userlevel))) {
|
|
57
|
-
sets.push(`USERLEVEL = ${Number(fields.userlevel)}`);
|
|
58
|
-
}
|
|
59
|
-
if (!isBlank(fields.plainpassword)) {
|
|
60
|
-
sets.push(`USERPASSWORD = '${quote(hashPassword(fields.plainpassword))}'`);
|
|
61
|
-
}
|
|
62
|
-
if (sets.length === 0) return null;
|
|
63
|
-
return `UPDATE SYS$USERS SET ${sets.join(", ")} WHERE USERID = ${Number(userId)} RETURNING USERID`;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/* SYS$SESSION, SYS$USER_TENANT and SYS$USER_PREFERENCES cascade from SYS$USERS. */
|
|
67
|
-
export const buildDeleteUserSql = (userId) =>
|
|
68
|
-
`DELETE FROM SYS$USERS WHERE USERID = ${Number(userId)} RETURNING USERID`;
|
|
69
|
-
|
|
70
|
-
const answer = (entry) => ({ status: 200, body: { result: [entry] } });
|
|
71
|
-
const refuse = (error) => answer({ error });
|
|
72
|
-
const keyOf = (rows) => rows?.[0]?.userid ?? rows?.[0]?.USERID;
|
|
73
|
-
|
|
74
|
-
/* FINAMethod.pas isAppend: '' or -1. A JSON null reads as '' in Delphi, so it appends too. */
|
|
75
|
-
const isAppend = (id) => id === undefined || id === null || String(id).trim() === "" || Number(id) === -1;
|
|
76
|
-
|
|
77
|
-
/* dialect.js decorate keeps the driver error on `cause` and copies `code` — read both. */
|
|
78
|
-
const describeFailure = (error, master) => {
|
|
79
|
-
const code = String(error?.code ?? error?.cause?.code ?? "");
|
|
80
|
-
if (code === "23505") return `User name '${upper(master.username)}' is already used.`;
|
|
81
|
-
return withoutStatement(error?.message ?? String(error));
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/*
|
|
85
|
-
* The caller whose password confirms the change.
|
|
86
|
-
*
|
|
87
|
-
* ⚠️ A verified principal WINS over the body's `userId`, and a body naming someone else is refused
|
|
88
|
-
* rather than quietly overridden: it is either a stale tab or a forged request, and neither should
|
|
89
|
-
* reach the user table. With no principal (observe mode) the body is all there is — FINA's own rule.
|
|
90
|
-
*/
|
|
91
|
-
const confirmingUserOf = (param, principal) => {
|
|
92
|
-
const claimed = param.userId ?? param.userid;
|
|
93
|
-
if (principal?.userId !== undefined && principal?.userId !== null) {
|
|
94
|
-
if (claimed !== undefined && claimed !== null && Number(claimed) !== Number(principal.userId)) {
|
|
95
|
-
return { error: "The request names a different user than the one signed in." };
|
|
96
|
-
}
|
|
97
|
-
return { userId: Number(principal.userId) };
|
|
98
|
-
}
|
|
99
|
-
return { userId: Number(claimed) };
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
/* Returns a DataSnap response — always, and never throws: the caller is a socket handler. */
|
|
103
|
-
export const saveUserApiDataset = async (param, { exec, dbIndex, principal = null }) => {
|
|
104
|
-
const master = lowerKeys(param?.data?.master);
|
|
105
|
-
const isDelete = String(param?.delete ?? "") === "1";
|
|
106
|
-
const inserting = !isDelete && isAppend(param?.id);
|
|
107
|
-
const id = Number(param?.id);
|
|
108
|
-
if (!inserting && !Number.isInteger(id)) return refuse(`Invalid user id: ${param?.id}`);
|
|
109
|
-
|
|
110
|
-
const confirming = confirmingUserOf(param ?? {}, principal);
|
|
111
|
-
if (confirming.error) return refuse(confirming.error);
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
const password = param?.password;
|
|
115
|
-
if (!Number.isInteger(confirming.userId) || typeof password !== "string" || password === "") {
|
|
116
|
-
return refuse("Incorrect password");
|
|
117
|
-
}
|
|
118
|
-
const confirmRows = (await exec(buildFindUserByIdSql(confirming.userId), dbIndex)) ?? [];
|
|
119
|
-
const confirmName = confirmRows[0]?.username ?? confirmRows[0]?.USERNAME;
|
|
120
|
-
if (verifyCredential(confirmRows, confirmName, password).userId !== confirming.userId) {
|
|
121
|
-
return refuse("Incorrect password");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (isDelete) {
|
|
125
|
-
const rows = await exec(buildDeleteUserSql(id), dbIndex);
|
|
126
|
-
return keyOf(rows) === undefined ? refuse(`User ${id} not found`) : answer({ id: String(keyOf(rows)) });
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (inserting) {
|
|
130
|
-
if (isBlank(master.username)) return refuse("A new user needs a user name.");
|
|
131
|
-
if (isBlank(master.plainpassword)) return refuse("A new user needs a password.");
|
|
132
|
-
const rows = await exec(
|
|
133
|
-
buildInsertUserSql({
|
|
134
|
-
username: master.username,
|
|
135
|
-
password: master.plainpassword,
|
|
136
|
-
fullname: master.fullname,
|
|
137
|
-
userlevel: master.userlevel,
|
|
138
|
-
}),
|
|
139
|
-
dbIndex,
|
|
140
|
-
);
|
|
141
|
-
return answer({ id: String(keyOf(rows) ?? "") });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const update = buildUpdateUserSql(id, master);
|
|
145
|
-
/* Nothing to change still has to name a user that exists, as FINA's DoEdit would. */
|
|
146
|
-
const rows = await exec(update ?? buildFindUserByIdSql(id), dbIndex);
|
|
147
|
-
return keyOf(rows) === undefined ? refuse(`User ${id} not found`) : answer({ id: String(keyOf(rows)) });
|
|
148
|
-
} catch (error) {
|
|
149
|
-
const message = describeFailure(error, master);
|
|
150
|
-
logger.error(`[json2fina] ${USER_API_DATASET}: ${message}`);
|
|
151
|
-
return refuse(message);
|
|
152
|
-
}
|
|
153
|
-
};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
-- Doc 8 (FINA Cloud) - engine/fina's own tables (D14), so it never reads a table an application declares.
|
|
2
|
-
--
|
|
3
|
-
-- FINA$SETTING holds what the SERVER enforces for FINA Cloud: the Biz-A date lock (C-14) and the JV settings
|
|
4
|
-
-- (Need Approval, default template, rate editable, department / project mandatory, attachment limits). One JSON
|
|
5
|
-
-- value per section, validated by engine/fina/settings.js, which is the only writer.
|
|
6
|
-
--
|
|
7
|
-
-- FINA$USER_MAP maps each Biz-A user (SYS$USERS) to a FINA user (FINA's own USERS table, in the FINA database),
|
|
8
|
-
-- S-01. A Biz-A user maps to at most one FINA user; several may share one. The mapping goes with its Biz-A user.
|
|
9
|
-
-- FINA_USER_ID has no foreign key: FINA's USERS lives in another database (Firebird, through the FINA API).
|
|
10
|
-
--
|
|
11
|
-
-- Server-only. The browser role (biza_app) gets NOTHING on either table: a user who could write the map could
|
|
12
|
-
-- become somebody else in FINA, and one who could write a setting could lift the date lock. The rls_role default
|
|
13
|
-
-- privileges grant every new table to biza_app, so they are taken back below.
|
|
14
|
-
|
|
15
|
-
CREATE TABLE IF NOT EXISTS FINA$SETTING (
|
|
16
|
-
KEY varchar(64) NOT NULL,
|
|
17
|
-
VALUE text NOT NULL,
|
|
18
|
-
UPDATED_BY integer,
|
|
19
|
-
UPDATED_AT timestamp NOT NULL DEFAULT now(),
|
|
20
|
-
CONSTRAINT PK_FINA$SETTING PRIMARY KEY (KEY)
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
CREATE TABLE IF NOT EXISTS FINA$USER_MAP (
|
|
24
|
-
BIZA_USER_ID integer NOT NULL,
|
|
25
|
-
FINA_USER_ID integer NOT NULL,
|
|
26
|
-
UPDATED_BY integer,
|
|
27
|
-
UPDATED_AT timestamp NOT NULL DEFAULT now(),
|
|
28
|
-
CONSTRAINT PK_FINA$USER_MAP PRIMARY KEY (BIZA_USER_ID),
|
|
29
|
-
CONSTRAINT FK_FINA$USER_MAP_USER FOREIGN KEY (BIZA_USER_ID) REFERENCES SYS$USERS (USERID) ON DELETE CASCADE
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
REVOKE ALL ON FINA$SETTING FROM biza_app;
|
|
33
|
-
REVOKE ALL ON FINA$USER_MAP FROM biza_app;
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
-- Doc 8 M6 (D11) - the platform attachment store: files belonging to a record, kept in the tenant database.
|
|
2
|
-
--
|
|
3
|
-
-- A file belongs to an OWNER (OWNER_TYPE + OWNER_ID, e.g. fina.jv / 140): no foreign key, the owner may live in
|
|
4
|
-
-- another database (a FINA document lives in FINA). engine/attachments/store.js is the only reader and writer; it
|
|
5
|
-
-- names the owner in every statement. Files arrive in chunks (STATUS UPLOADING) and are READY once every declared byte
|
|
6
|
-
-- is there; an upload left behind is cleared after an hour.
|
|
7
|
-
--
|
|
8
|
-
-- Server-only. The browser role (biza_app) gets NOTHING: who may read or change a file is decided by the CLI with the
|
|
9
|
-
-- verified principal (for FINA documents: engine/fina/attachments.js). The rls_role default privileges grant every
|
|
10
|
-
-- new table to biza_app, so they are taken back below.
|
|
11
|
-
|
|
12
|
-
CREATE TABLE IF NOT EXISTS SYS$ATTACHMENT (
|
|
13
|
-
ID bigserial NOT NULL,
|
|
14
|
-
OWNER_TYPE varchar(64) NOT NULL,
|
|
15
|
-
OWNER_ID varchar(64) NOT NULL,
|
|
16
|
-
FILE_NAME varchar(255) NOT NULL,
|
|
17
|
-
MIME_TYPE varchar(100) NOT NULL,
|
|
18
|
-
SIZE_BYTES integer NOT NULL,
|
|
19
|
-
CONTENT bytea NOT NULL,
|
|
20
|
-
STATUS varchar(12) NOT NULL,
|
|
21
|
-
CREATED_BY integer,
|
|
22
|
-
CREATED_AT timestamptz NOT NULL DEFAULT now(),
|
|
23
|
-
CONSTRAINT PK_SYS$ATTACHMENT PRIMARY KEY (ID)
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
CREATE INDEX IF NOT EXISTS IDX_SYS$ATTACHMENT_OWNER ON SYS$ATTACHMENT (OWNER_TYPE, OWNER_ID);
|
|
27
|
-
|
|
28
|
-
REVOKE ALL ON SYS$ATTACHMENT FROM biza_app;
|
|
29
|
-
REVOKE ALL ON SEQUENCE SYS$ATTACHMENT_ID_SEQ FROM biza_app;
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
-- Doc 8 M7 (FINA Cloud) - JV submissions (pengajuan): with Need Approval on, a NEW JV waits here for its approval
|
|
2
|
-
-- (A-05) instead of being saved to FINA, under a temporary number PJV/YYYY/MM/0001 (A-10, T-32).
|
|
3
|
-
--
|
|
4
|
-
-- engine/fina is the only writer (engine/fina/submissionRepo.js). DOCUMENT holds the JV as the server normalized it
|
|
5
|
-
-- (master and lines, JSON); STATUS is MENUNGGU while it waits, PROSES_POSTING while its approval posts it, then
|
|
6
|
-
-- DISETUJUI (FINA_JVID / FINA_JVNUMBER say which JV it became), DITOLAK or GAGAL_POSTING (POST_ERROR says why).
|
|
7
|
-
-- WF_INSTANCE_ID is its BPM instance (workflow FINA_JV_APPROVAL, entity type FINA_JV_SUBMISSION).
|
|
8
|
-
--
|
|
9
|
-
-- The browser role (biza_app) may READ the columns a list shows (Daftar Pengajuan JV, Persetujuan JV) and nothing
|
|
10
|
-
-- else: not DOCUMENT (the JV goes out through fina/submission/get, which checks the FINA right), and no write. The
|
|
11
|
-
-- rls_role default privileges grant every new table to biza_app, so everything is taken back first.
|
|
12
|
-
|
|
13
|
-
CREATE TABLE IF NOT EXISTS FINA$JV_SUBMISSION (
|
|
14
|
-
ID bigserial NOT NULL,
|
|
15
|
-
SUB_NUMBER varchar(30) NOT NULL,
|
|
16
|
-
STATUS varchar(16) NOT NULL,
|
|
17
|
-
TRANSDATE date NOT NULL,
|
|
18
|
-
TRANSDESCRIPTION varchar(80),
|
|
19
|
-
AMOUNT numeric(18,4) NOT NULL DEFAULT 0,
|
|
20
|
-
TEMPLATEID integer,
|
|
21
|
-
DOCUMENT text NOT NULL,
|
|
22
|
-
WF_INSTANCE_ID bigint,
|
|
23
|
-
FINA_JVID integer,
|
|
24
|
-
FINA_JVNUMBER varchar(20),
|
|
25
|
-
POST_ERROR text,
|
|
26
|
-
CREATED_BY integer NOT NULL,
|
|
27
|
-
CREATED_AT timestamp NOT NULL DEFAULT now(),
|
|
28
|
-
UPDATED_BY integer,
|
|
29
|
-
UPDATED_AT timestamp,
|
|
30
|
-
DECIDED_BY integer,
|
|
31
|
-
DECIDED_AT timestamp,
|
|
32
|
-
CONSTRAINT PK_FINA$JV_SUBMISSION PRIMARY KEY (ID),
|
|
33
|
-
CONSTRAINT UQ_FINA$JV_SUBMISSION_NUMBER UNIQUE (SUB_NUMBER),
|
|
34
|
-
CONSTRAINT CK_FINA$JV_SUBMISSION_STATUS CHECK (STATUS IN ('MENUNGGU', 'PROSES_POSTING', 'DISETUJUI', 'DITOLAK', 'GAGAL_POSTING'))
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
CREATE INDEX IF NOT EXISTS IDX_FINA$JV_SUBMISSION_STATUS ON FINA$JV_SUBMISSION (STATUS);
|
|
38
|
-
|
|
39
|
-
REVOKE ALL ON FINA$JV_SUBMISSION FROM biza_app;
|
|
40
|
-
REVOKE ALL ON SEQUENCE FINA$JV_SUBMISSION_ID_SEQ FROM biza_app;
|
|
41
|
-
GRANT SELECT (ID, SUB_NUMBER, STATUS, TRANSDATE, TRANSDESCRIPTION, AMOUNT, TEMPLATEID, WF_INSTANCE_ID, FINA_JVID, FINA_JVNUMBER, POST_ERROR, CREATED_BY, CREATED_AT, UPDATED_BY, UPDATED_AT, DECIDED_BY, DECIDED_AT) ON FINA$JV_SUBMISSION TO biza_app;
|