@arcforgelabs/flow 2026.10.20-beta.1 → 2026.10.21
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/SECURITY.md +6 -0
- package/VISION.md +9 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/import/at-start.js +52 -0
- package/server/import/earlier-flow.js +10 -4
- package/server/index.js +16 -0
package/SECURITY.md
CHANGED
|
@@ -31,6 +31,10 @@ paste a credential, in full or in part.
|
|
|
31
31
|
- **Integrations.** Seazona, Xero and every later adapter reach the core only
|
|
32
32
|
through the integration port. Their credentials are stored per
|
|
33
33
|
organisation, encrypted, and are write-only in Settings.
|
|
34
|
+
- **Records and files.** Every record is in the installation's PostgreSQL.
|
|
35
|
+
Files reach the core only through the object storage port; PostgreSQL
|
|
36
|
+
holds each file's key, owner, size and hash, and the hash is checked on
|
|
37
|
+
read. Download links are signed.
|
|
34
38
|
- **Issued documents are records.** An invoice a customer has been sent is
|
|
35
39
|
never changed in place; a change produces a new version.
|
|
36
40
|
|
|
@@ -53,6 +57,8 @@ paste a credential, in full or in part.
|
|
|
53
57
|
- Session cookies are `Secure`, `HttpOnly` and `SameSite`.
|
|
54
58
|
- Flow stores no clinical notes, images or scans, and keeps a patient
|
|
55
59
|
reference only where an invoice must print one.
|
|
60
|
+
- Object keys and file names carry a case number at most, never a patient
|
|
61
|
+
name or other patient data.
|
|
56
62
|
- CI runs gitleaks on every push and pull request
|
|
57
63
|
(`.github/workflows/secret-scan.yml`).
|
|
58
64
|
|
package/VISION.md
CHANGED
|
@@ -47,6 +47,15 @@ sensitive. Flow keeps the case number and the practice. It keeps a patient
|
|
|
47
47
|
reference only when a practice needs one printed on its invoice, and it keeps
|
|
48
48
|
no clinical notes or images.
|
|
49
49
|
|
|
50
|
+
### Why does Flow keep its records in PostgreSQL only?
|
|
51
|
+
|
|
52
|
+
One database is less to run, back up and upgrade. Every record lives in
|
|
53
|
+
PostgreSQL, with `jsonb` for evidence and documents. Files (PDFs, payment
|
|
54
|
+
proof, exports) go through one object storage port, and PostgreSQL keeps only
|
|
55
|
+
each file's key, owner, size, type and hash. A need that seems to call for
|
|
56
|
+
another kind of database is raised as a decision first. The goal is an
|
|
57
|
+
installation that needs little maintenance (`docs/DATA_MODEL.md`, "Storage").
|
|
58
|
+
|
|
50
59
|
### Why does each lab get its own installation?
|
|
51
60
|
|
|
52
61
|
Labs' customers, prices and credentials must never meet. Each installation has
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.21",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.21",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
package/package.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { organisations } from '../db/schema.js';
|
|
3
|
+
import { applyImport, planImport, readEarlierFlow, summarisePlan } from './earlier-flow.js';
|
|
4
|
+
|
|
5
|
+
// The import from the earlier codebase, run once by the server itself. On a
|
|
6
|
+
// host with no shell next to the app (a published Replit app) the operator
|
|
7
|
+
// moves the earlier tables into their own schema, then sets
|
|
8
|
+
// FLOW_IMPORT_EARLIER_SCHEMA and FLOW_IMPORT_ORGANISATION. While the database
|
|
9
|
+
// has no organisation, the next start reads that schema in a read-only
|
|
10
|
+
// transaction and writes the records (server/import/earlier-flow.js). Once an
|
|
11
|
+
// organisation exists, it does nothing.
|
|
12
|
+
|
|
13
|
+
const SCHEMA_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
14
|
+
|
|
15
|
+
export function resolveImportAtStart(env = process.env) {
|
|
16
|
+
const schema = (env.FLOW_IMPORT_EARLIER_SCHEMA ?? '').trim();
|
|
17
|
+
if (!schema) return null;
|
|
18
|
+
if (!SCHEMA_NAME.test(schema)) throw new Error('FLOW_IMPORT_EARLIER_SCHEMA must be a plain lower-case schema name, e.g. earlier');
|
|
19
|
+
const organisationName = (env.FLOW_IMPORT_ORGANISATION ?? '').trim();
|
|
20
|
+
if (!organisationName) throw new Error('FLOW_IMPORT_ORGANISATION (the lab\'s name) is required with FLOW_IMPORT_EARLIER_SCHEMA');
|
|
21
|
+
return { schema, organisationName };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Returns the import's counts, or null when there was nothing to do.
|
|
25
|
+
export async function importEarlierAtStart({ db, pool, settings, log = console }) {
|
|
26
|
+
if (!settings) return null;
|
|
27
|
+
const [{ count }] = (await db.execute(sql`select count(*)::int as count from ${organisations}`)).rows;
|
|
28
|
+
if (count > 0) {
|
|
29
|
+
log.info?.('[import] skipped: this database already has an organisation (unset FLOW_IMPORT_EARLIER_SCHEMA)');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let plan;
|
|
34
|
+
const client = await pool.connect();
|
|
35
|
+
try {
|
|
36
|
+
await client.query('BEGIN TRANSACTION READ ONLY');
|
|
37
|
+
// The schema name was checked against SCHEMA_NAME, so it is safe to quote.
|
|
38
|
+
await client.query(`SET LOCAL search_path TO "${settings.schema}"`);
|
|
39
|
+
plan = planImport(await readEarlierFlow(client));
|
|
40
|
+
await client.query('COMMIT');
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
43
|
+
throw new Error(`could not read schema "${settings.schema}": ${error.message}`);
|
|
44
|
+
} finally {
|
|
45
|
+
client.release();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
log.info?.(`[import] plan from schema "${settings.schema}": ${JSON.stringify(summarisePlan(plan))}`);
|
|
49
|
+
const result = await applyImport(db, plan, { organisationName: settings.organisationName });
|
|
50
|
+
log.info?.(`[import] imported: ${JSON.stringify(result.counts)}`);
|
|
51
|
+
return result.counts;
|
|
52
|
+
}
|
|
@@ -45,6 +45,12 @@ function parseJson(text) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// The earlier codebase stored some accounting payloads as the API sent them
|
|
49
|
+
// (`UnitPrice`) and some as its SDK returned them (`unitPrice`).
|
|
50
|
+
function field(object, name) {
|
|
51
|
+
return object[name] ?? object[name[0].toLowerCase() + name.slice(1)];
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
function isTrue(value) {
|
|
49
55
|
return value === true || value === 1 || value === '1' || value === 'true';
|
|
50
56
|
}
|
|
@@ -82,8 +88,8 @@ function planContacts(rows, skipped) {
|
|
|
82
88
|
skipped.push({ table: 'contacts', key, reason: 'unreadable_raw_json' });
|
|
83
89
|
continue;
|
|
84
90
|
}
|
|
85
|
-
const customer = isTrue(raw
|
|
86
|
-
const supplier = isTrue(raw
|
|
91
|
+
const customer = isTrue(field(raw, 'IsCustomer'));
|
|
92
|
+
const supplier = isTrue(field(raw, 'IsSupplier'));
|
|
87
93
|
// A contact the accounting system has never billed or paid is most often
|
|
88
94
|
// a new customer, so it comes across as one.
|
|
89
95
|
const kinds = supplier && !customer ? ['supplier'] : supplier ? ['customer', 'supplier'] : ['customer'];
|
|
@@ -120,12 +126,12 @@ function planPriceItems(rows, skipped, defaulted) {
|
|
|
120
126
|
skipped.push({ table: 'price_items', key, reason: 'unreadable_sales_details' });
|
|
121
127
|
continue;
|
|
122
128
|
}
|
|
123
|
-
const unitAmountCents = toCents(sales
|
|
129
|
+
const unitAmountCents = toCents(field(sales, 'UnitPrice'));
|
|
124
130
|
if (unitAmountCents === null) {
|
|
125
131
|
skipped.push({ table: 'price_items', key, reason: 'no_sales_price' });
|
|
126
132
|
continue;
|
|
127
133
|
}
|
|
128
|
-
const taxType = String(sales
|
|
134
|
+
const taxType = String(field(sales, 'TaxType') ?? '').trim().toUpperCase();
|
|
129
135
|
if (!taxType) defaulted.push({ table: 'price_items', key, reason: 'no_tax_type_used_gst' });
|
|
130
136
|
const taxCode = !taxType || GST_TAX_TYPES.has(taxType) ? 'GST' : 'GST_FREE';
|
|
131
137
|
planned.push({ sourceKey: key, code, name, unitAmountCents, taxCode });
|
package/server/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { seedFirstOwner } from './auth/people.js';
|
|
|
5
5
|
import { createPersonAuth } from './auth/session.js';
|
|
6
6
|
import { resolveSignInSettings } from './auth/settings.js';
|
|
7
7
|
import { migrateDatabase, openDatabase } from './db/index.js';
|
|
8
|
+
import { importEarlierAtStart, resolveImportAtStart } from './import/at-start.js';
|
|
8
9
|
import { createIntegrations } from './integrations/index.js';
|
|
9
10
|
import { resolveDefaultFamily } from './theme/themes.js';
|
|
10
11
|
import { createUpdateChecker, UPDATE_CHECK_INTERVAL_MS } from './update-check.js';
|
|
@@ -34,11 +35,26 @@ try {
|
|
|
34
35
|
process.exit(1);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
let importAtStart;
|
|
39
|
+
try {
|
|
40
|
+
importAtStart = resolveImportAtStart();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`[import] ${error.message}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
// Flow's records live in PostgreSQL. Migrations run before the server accepts
|
|
38
47
|
// requests, so a release never serves an older schema. Then each enabled
|
|
39
48
|
// integration's own tables, and no others (docs/INTEGRATIONS.md).
|
|
40
49
|
const database = openDatabase();
|
|
41
50
|
await migrateDatabase(database.db);
|
|
51
|
+
// A failed import stops the start, so Flow never opens with half its records.
|
|
52
|
+
try {
|
|
53
|
+
await importEarlierAtStart({ db: database.db, pool: database.pool, settings: importAtStart });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(`[import] ${error.message}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
42
58
|
await integrations.start(database.db);
|
|
43
59
|
const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => a.id);
|
|
44
60
|
console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'} (FLOW_INTEGRATIONS)`);
|