@arcforgelabs/flow 2026.10.32 → 2026.10.34
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/README.md +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +107 -4
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/README.md
CHANGED
|
@@ -27,9 +27,13 @@ run a lab.
|
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| `VISION.md` | What Flow is for, the core and its integrations, and what it will not do |
|
|
29
29
|
| `AGENTS.md` | Agent entry point (`CLAUDE.md` links to it) |
|
|
30
|
-
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, merging |
|
|
30
|
+
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, the changelog, merging |
|
|
31
|
+
| `CHANGELOG.md` | What changed in each release: an index of one file per release in `CHANGELOG/` |
|
|
31
32
|
| `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
|
|
32
33
|
| `LICENSE` | MIT licence |
|
|
34
|
+
| `docs/docs.json` | The docs site navigation. `docs/` is published at https://arcforgelabs.github.io/docs/flow/ by arcforgelabs/docs |
|
|
35
|
+
| `docs/index.md` | The docs site home page |
|
|
36
|
+
| `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
|
|
33
37
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
34
38
|
| `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
|
|
35
39
|
| `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
|
|
@@ -37,10 +41,14 @@ run a lab.
|
|
|
37
41
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
38
42
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
39
43
|
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
|
|
44
|
+
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
40
45
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
41
46
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
|
42
47
|
| `docs/LINE_MAPPING.md` | Line mapping: how each work system line finds its price item, and Price codes in Settings |
|
|
48
|
+
| `docs/CUSTOM_FIELDS.md` | Custom fields: the lab's own fields for its jobs, kept in Flow only, and how a board move checks for conflicts |
|
|
49
|
+
| `docs/JOB_BOARD_MOVES.md` | Moving jobs on the Jobs board: custom field moves stay in Flow; Seazona stage moves become a hand-off |
|
|
43
50
|
| `docs/PRICE_LIST.md` | The price list: Flow's own codes and prices, their links to Xero items and Seazona products, and the price check |
|
|
51
|
+
| `docs/LOW_RISK_MERGE.md` | The low-risk merge lane: which pull requests merge themselves when green, with ClawSweeper's risk labels |
|
|
44
52
|
| `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
|
|
45
53
|
| `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
|
|
46
54
|
| `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.34",
|
|
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.34",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.34",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "node server/index.js",
|
|
33
33
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
34
|
+
"changelog:check": "node scripts/release/changelog.mjs check",
|
|
34
35
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
35
36
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
36
37
|
},
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import {
|
|
3
|
+
CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, createField, jobFieldHistory, listFields,
|
|
4
|
+
listJobFieldValues, orderFields, setJobFieldValue, updateField,
|
|
5
|
+
} from '../core/custom-fields.js';
|
|
6
|
+
import { FlowError } from '../core/errors.js';
|
|
7
|
+
import { resolveOrganisation } from './invoicing.js';
|
|
8
|
+
|
|
9
|
+
// Custom fields for jobs (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md).
|
|
10
|
+
//
|
|
11
|
+
// GET /api/custom-fields[?archived=1] the lab's fields, and the work systems' own fields (read only)
|
|
12
|
+
// POST /api/custom-fields { name, type[, options] } owner or admin: make a field
|
|
13
|
+
// PUT /api/custom-fields/order { ids } owner or admin: the field order
|
|
14
|
+
// PUT /api/custom-fields/:id { name?, options? } owner or admin: rename, change options
|
|
15
|
+
// POST /api/custom-fields/:id/archive { archived } owner or admin: archive or bring back
|
|
16
|
+
// GET /api/custom-fields/values every job's values
|
|
17
|
+
// PUT /api/custom-fields/values { sourceSystem, sourceJobRef, fieldId, value[, expectedUpdatedAt] }
|
|
18
|
+
// set one job's value; a stale expectedUpdatedAt gives 409 with the current value
|
|
19
|
+
// GET /api/custom-fields/history?sourceSystem=&sourceJobRef= one job's changes, newest first
|
|
20
|
+
//
|
|
21
|
+
// Every route also takes `organisationId` (query or body) when the
|
|
22
|
+
// installation has more than one organisation. A value lives only in Flow:
|
|
23
|
+
// no route here writes to an integration.
|
|
24
|
+
//
|
|
25
|
+
// `work()` is the work system adapters that offer listWork; their job
|
|
26
|
+
// statuses show as a read-only Stage field.
|
|
27
|
+
const ERROR_STATUS = { not_found: 404, conflict: 409, organisation_required: 409 };
|
|
28
|
+
|
|
29
|
+
export function createCustomFieldsRouter({ db, personAuth, work = () => [], log = console }) {
|
|
30
|
+
const router = express.Router();
|
|
31
|
+
router.use(express.json({ limit: '32kb' }));
|
|
32
|
+
const signedIn = [personAuth.requireSession];
|
|
33
|
+
const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
|
|
34
|
+
|
|
35
|
+
const handle = (fn) => async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
res.set('Cache-Control', 'no-store');
|
|
38
|
+
await fn(req, res);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!(error instanceof FlowError)) {
|
|
41
|
+
log.error?.(`[custom-fields] ${error.message}`);
|
|
42
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
43
|
+
}
|
|
44
|
+
const body = { ok: false, code: error.code, error: error.message };
|
|
45
|
+
if (error.details !== undefined) body.details = error.details;
|
|
46
|
+
return res.status(ERROR_STATUS[error.code] ?? 400).json(body);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const organisationOf = (req) => resolveOrganisation(db, req.body?.organisationId ?? req.query.organisationId);
|
|
50
|
+
|
|
51
|
+
// The work systems' own fields: their job statuses, as the Stage lanes on
|
|
52
|
+
// the Jobs board. Read from Flow's copy; a work system that fails to answer
|
|
53
|
+
// is left out, not an error.
|
|
54
|
+
async function systemFields() {
|
|
55
|
+
const fields = [];
|
|
56
|
+
for (const adapter of work()) {
|
|
57
|
+
try {
|
|
58
|
+
const { statuses = [] } = await adapter.capabilities.listWork();
|
|
59
|
+
fields.push({
|
|
60
|
+
id: `${adapter.id}:stage`, source: adapter.id, sourceLabel: adapter.name ?? adapter.id, name: 'Stage',
|
|
61
|
+
type: 'dropdown', readOnly: true, options: statuses.map((s) => ({ id: s.label, name: s.label, final: Boolean(s.final) })),
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
log.warn?.(`[custom-fields] ${adapter.id} stages: ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return fields;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
router.get('/', ...signedIn, handle(async (req, res) => {
|
|
71
|
+
const organisationId = await organisationOf(req);
|
|
72
|
+
const archived = req.query.archived === '1' || req.query.archived === 'true';
|
|
73
|
+
const [fields, system] = await Promise.all([listFields(db, organisationId, { archived }), systemFields()]);
|
|
74
|
+
res.json({ ok: true, fields, systemFields: system, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
router.post('/', ...manager, handle(async (req, res) => {
|
|
78
|
+
const field = await createField(db, await organisationOf(req), req.body, { personId: req.person.id });
|
|
79
|
+
res.status(201).json({ ok: true, field });
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
router.put('/order', ...manager, handle(async (req, res) => {
|
|
83
|
+
const fields = await orderFields(db, await organisationOf(req), req.body?.ids, { personId: req.person.id });
|
|
84
|
+
res.json({ ok: true, fields });
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
router.get('/values', ...signedIn, handle(async (req, res) => {
|
|
88
|
+
res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
router.put('/values', ...signedIn, handle(async (req, res) => {
|
|
92
|
+
const value = await setJobFieldValue(db, await organisationOf(req), req.body, { personId: req.person.id });
|
|
93
|
+
res.json({ ok: true, value });
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
router.get('/history', ...signedIn, handle(async (req, res) => {
|
|
97
|
+
const history = await jobFieldHistory(db, await organisationOf(req), req.query);
|
|
98
|
+
res.json({ ok: true, history });
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
router.put('/:id', ...manager, handle(async (req, res) => {
|
|
102
|
+
const field = await updateField(db, await organisationOf(req), req.params.id, req.body, { personId: req.person.id });
|
|
103
|
+
res.json({ ok: true, field });
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
router.post('/:id/archive', ...manager, handle(async (req, res) => {
|
|
107
|
+
const archived = req.body?.archived ?? true;
|
|
108
|
+
if (typeof archived !== 'boolean') throw new FlowError('invalid', 'archived must be true or false');
|
|
109
|
+
const field = await archiveField(db, await organisationOf(req), req.params.id, { archived, personId: req.person.id });
|
|
110
|
+
res.json({ ok: true, field });
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
return router;
|
|
114
|
+
}
|
package/server/api/invoicing.js
CHANGED
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
saveCustomerBilling, saveInvoicingSettings, sendModeFor, sendModeSummary,
|
|
17
17
|
} from '../core/invoicing-rules.js';
|
|
18
18
|
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
|
+
import { readBeforeAct } from '../core/read-before-act.js';
|
|
19
20
|
import { jobReference } from '../core/references.js';
|
|
20
21
|
import {
|
|
21
22
|
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
22
23
|
} from '../core/price-list.js';
|
|
23
|
-
import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
|
|
24
|
+
import { draftInvoiceForObligation, explainObligation, listObligations, setReady, useSourceLines } from '../core/ledger.js';
|
|
24
25
|
import { isHistoryHeld } from '../core/history.js';
|
|
25
26
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
26
27
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
@@ -276,7 +277,8 @@ export function createInvoicingRouter({
|
|
|
276
277
|
const rows = await db.select({
|
|
277
278
|
id: organisations.id, name: organisations.name, invoicePrefix: organisations.invoicePrefix, nextInvoiceNumber: organisations.nextInvoiceNumber,
|
|
278
279
|
}).from(organisations).orderBy(asc(organisations.name));
|
|
279
|
-
|
|
280
|
+
// sendsTo: the accounting system a sent invoice goes to, or null.
|
|
281
|
+
res.json({ ok: true, organisations: rows, canIssue: ['owner', 'admin'].includes(req.person.role), sendsTo: accounting()?.name ?? null });
|
|
280
282
|
}));
|
|
281
283
|
|
|
282
284
|
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -287,25 +289,36 @@ export function createInvoicingRouter({
|
|
|
287
289
|
}
|
|
288
290
|
const q = text(req.query.q);
|
|
289
291
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
296
|
-
else if (status) where.push(eq(invoices.status, status));
|
|
297
|
-
if (q) {
|
|
298
|
-
const pattern = containing(q);
|
|
299
|
-
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
300
|
-
}
|
|
301
|
-
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
302
|
-
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
303
|
-
.where(and(...where))
|
|
304
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
305
|
-
.limit(limit);
|
|
306
|
-
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
307
|
-
const counted = await db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
292
|
+
// Each tab switch runs this, so the independent reads run side by side;
|
|
293
|
+
// only the to-check, ready and problem lists wait on the draft checks.
|
|
294
|
+
const system = accounting();
|
|
295
|
+
const checking = invoiceViews(db, organisationId, { accounting: system });
|
|
296
|
+
const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
308
297
|
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
298
|
+
const sendMode = system
|
|
299
|
+
? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
|
|
300
|
+
.then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
|
|
301
|
+
: null;
|
|
302
|
+
const listing = (async () => {
|
|
303
|
+
const views = VIEWS_OF_INVOICES.includes(status) ? await checking : null;
|
|
304
|
+
const where = [eq(invoices.organisationId, organisationId)];
|
|
305
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
306
|
+
if (status === 'to_check') where.push(only(views.toCheck));
|
|
307
|
+
else if (status === 'ready') where.push(only(views.ready));
|
|
308
|
+
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
309
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
310
|
+
if (q) {
|
|
311
|
+
const pattern = containing(q);
|
|
312
|
+
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
313
|
+
}
|
|
314
|
+
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
315
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
316
|
+
.where(and(...where))
|
|
317
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
318
|
+
.limit(limit);
|
|
319
|
+
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
320
|
+
})();
|
|
321
|
+
const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
|
|
309
322
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
310
323
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
311
324
|
counts.to_check = views.toCheck.length;
|
|
@@ -324,8 +337,8 @@ export function createInvoicingRouter({
|
|
|
324
337
|
problems: views.problems.get(invoice.id) ?? [],
|
|
325
338
|
})),
|
|
326
339
|
counts,
|
|
327
|
-
sendsTo:
|
|
328
|
-
sendMode:
|
|
340
|
+
sendsTo: system?.name ?? null,
|
|
341
|
+
sendMode: mode,
|
|
329
342
|
});
|
|
330
343
|
}));
|
|
331
344
|
|
|
@@ -355,7 +368,9 @@ export function createInvoicingRouter({
|
|
|
355
368
|
const organisationId = await organisationOf(req);
|
|
356
369
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
357
370
|
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
358
|
-
|
|
371
|
+
// Read before act (issue #141) reads each old job from the work system that can.
|
|
372
|
+
const reader = work().find((a) => typeof a.capabilities.readWorkItem === 'function') ?? null;
|
|
373
|
+
const result = await sendAllReady(db, organisationId, { accounting: accounting(), work: reader, personId: req.person.id, issueDate, invoiceIds: ids });
|
|
359
374
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
|
|
360
375
|
res.json({ ok: true, ...result });
|
|
361
376
|
}));
|
|
@@ -495,15 +510,33 @@ export function createInvoicingRouter({
|
|
|
495
510
|
const organisationId = await organisationOf(req);
|
|
496
511
|
// With no accounting system connected, Flow's own records are the check.
|
|
497
512
|
const liveCheck = liveCheckFor(organisationId) ?? null;
|
|
498
|
-
|
|
513
|
+
let [obligation] = await db.select().from(billingObligations)
|
|
499
514
|
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
515
|
+
const source = obligation ? work().find((a) => a.id === obligation.sourceSystem) ?? null : null;
|
|
516
|
+
if (obligation) {
|
|
517
|
+
// Read before act (issue #141): an old job report is read again first.
|
|
518
|
+
const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor: `person:${req.person.id}` })).get(obligation.id);
|
|
519
|
+
if (read.outcome === 'waiting' || read.outcome === 'review') {
|
|
520
|
+
throw new FlowError('not_ready', 'the draft invoice was refused', { reasons: [read.reason ?? read.obligation.holdReason ?? 'the job needs a look'] });
|
|
521
|
+
}
|
|
522
|
+
obligation = read.obligation;
|
|
523
|
+
}
|
|
500
524
|
const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
|
|
501
|
-
const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work:
|
|
525
|
+
const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: source }) : null;
|
|
502
526
|
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
|
|
503
527
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
504
528
|
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
505
529
|
}));
|
|
506
530
|
|
|
531
|
+
// "Use new lines": the job changed in the work system after its draft was
|
|
532
|
+
// made, and only there. Flow changes its own draft; no other system.
|
|
533
|
+
router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
|
|
534
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
535
|
+
const organisationId = await organisationOf(req);
|
|
536
|
+
const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
|
|
537
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
538
|
+
}));
|
|
539
|
+
|
|
507
540
|
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
508
541
|
const obligationId = pathId(req.params.id, 'job');
|
|
509
542
|
const organisationId = await organisationOf(req);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { FlowError } from '../core/errors.js';
|
|
3
|
+
|
|
4
|
+
// Jobs board moves that belong to the work system (docs/JOB_BOARD_MOVES.md).
|
|
5
|
+
//
|
|
6
|
+
// POST /api/work/moves { sourceSystem, sourceJobRef, toStatus }
|
|
7
|
+
// a person moved a job to another stage: the work system answers what
|
|
8
|
+
// happens. Seazona's API cannot change a stage, so it answers a
|
|
9
|
+
// hand-off: what to do in Seazona, and how long Flow waits.
|
|
10
|
+
// POST /api/work/moves/cancel { sourceSystem, sourceJobRef } take back a waiting move
|
|
11
|
+
// GET /api/work/my-settings[?integration=] this person's own integration choices
|
|
12
|
+
// PUT /api/work/my-settings { integration, key, value } change one
|
|
13
|
+
//
|
|
14
|
+
// A move of a Flow custom field never comes here: that is
|
|
15
|
+
// PUT /api/custom-fields/values, and it stays in Flow.
|
|
16
|
+
//
|
|
17
|
+
// `adapters(capability)` is the enabled, ready adapters that offer it.
|
|
18
|
+
const ERROR_STATUS = { not_found: 404, conflict: 409, not_supported: 409 };
|
|
19
|
+
const SYSTEM = /^[a-z][a-z0-9-]{1,31}$/;
|
|
20
|
+
|
|
21
|
+
export function createWorkMovesRouter({ adapters = () => [], personAuth, log = console }) {
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
router.use(express.json({ limit: '8kb' }));
|
|
24
|
+
const signedIn = [personAuth.requireSession];
|
|
25
|
+
|
|
26
|
+
const handle = (fn) => async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
res.set('Cache-Control', 'no-store');
|
|
29
|
+
await fn(req, res);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!(error instanceof FlowError)) {
|
|
32
|
+
log.error?.(`[work-moves] ${error.message}`);
|
|
33
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
34
|
+
}
|
|
35
|
+
return res.status(ERROR_STATUS[error.code] ?? 400).json({ ok: false, code: error.code, error: error.message });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The enabled adapter `id` that offers `capability`.
|
|
40
|
+
const adapterFor = (id, capability) => {
|
|
41
|
+
if (typeof id !== 'string' || !SYSTEM.test(id)) throw new FlowError('invalid', 'name an integration');
|
|
42
|
+
const adapter = adapters(capability).find((a) => a.id === id) ?? null;
|
|
43
|
+
if (!adapter) throw new FlowError('not_supported', 'that integration is not on, or cannot do that');
|
|
44
|
+
return adapter;
|
|
45
|
+
};
|
|
46
|
+
const jobRef = (raw) => {
|
|
47
|
+
const ref = typeof raw === 'string' ? raw.trim() : '';
|
|
48
|
+
if (!ref || ref.length > 200) throw new FlowError('invalid', 'sourceJobRef is required');
|
|
49
|
+
return ref;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
router.post('/moves', ...signedIn, handle(async (req, res) => {
|
|
53
|
+
const adapter = adapterFor(req.body?.sourceSystem, 'moveWork');
|
|
54
|
+
const move = await adapter.capabilities.moveWork({ sourceJobRef: jobRef(req.body?.sourceJobRef), toStatus: req.body?.toStatus, personId: req.person.id });
|
|
55
|
+
res.json({ ok: true, move });
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
router.post('/moves/cancel', ...signedIn, handle(async (req, res) => {
|
|
59
|
+
const adapter = adapterFor(req.body?.sourceSystem, 'cancelMove');
|
|
60
|
+
res.json({ ok: true, ...(await adapter.capabilities.cancelMove({ sourceJobRef: jobRef(req.body?.sourceJobRef) })) });
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
router.get('/my-settings', ...signedIn, handle(async (req, res) => {
|
|
64
|
+
const wanted = typeof req.query.integration === 'string' ? req.query.integration : null;
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const adapter of adapters('personSettings')) {
|
|
67
|
+
if (wanted && adapter.id !== wanted) continue;
|
|
68
|
+
out.push({ integration: adapter.id, name: adapter.name, settings: await adapter.capabilities.personSettings({ personId: req.person.id }) });
|
|
69
|
+
}
|
|
70
|
+
res.json({ ok: true, integrations: out });
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
router.put('/my-settings', ...signedIn, handle(async (req, res) => {
|
|
74
|
+
const adapter = adapterFor(req.body?.integration, 'setPersonSetting');
|
|
75
|
+
const settings = await adapter.capabilities.setPersonSetting({ personId: req.person.id, key: req.body?.key, value: req.body?.value });
|
|
76
|
+
res.json({ ok: true, integration: adapter.id, settings });
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
return router;
|
|
80
|
+
}
|
package/server/app.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createAlertsRouter } from './api/alerts.js';
|
|
3
|
+
import { createCustomFieldsRouter } from './api/custom-fields.js';
|
|
4
|
+
import { createWorkMovesRouter } from './api/work-moves.js';
|
|
3
5
|
import { createConnectorApi } from './api/connector.js';
|
|
4
6
|
import { createInvoicingRouter } from './api/invoicing.js';
|
|
5
7
|
import { createSignInRouter } from './auth/routes.js';
|
|
6
8
|
import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
|
|
9
|
+
import { reconcileHealth } from './core/reconcile.js';
|
|
7
10
|
import { appliedMigrationCounts } from './db/doctor.js';
|
|
8
11
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
9
12
|
import { integrationsHealth } from './integrations/health.js';
|
|
@@ -34,6 +37,8 @@ import { createUiAssetsRouter } from './ui/routes.js';
|
|
|
34
37
|
// the migration journals this build ships (server/db/doctor.js): GET /health
|
|
35
38
|
// reports how many of each the database has applied, so the workspace's
|
|
36
39
|
// doctor can tell what Replit's publish step will offer (docs/REPLIT.md).
|
|
40
|
+
// `tracing` (server/core/tracing.js) names each request's performance trace
|
|
41
|
+
// by its route, while a tracing service is on.
|
|
37
42
|
export function createApp({
|
|
38
43
|
build = deployedBuildStatus(),
|
|
39
44
|
database = null,
|
|
@@ -48,10 +53,12 @@ export function createApp({
|
|
|
48
53
|
clerkProxyRateLimitPerMinute = 300,
|
|
49
54
|
defaultFamily = DEFAULT_FAMILY,
|
|
50
55
|
log = console,
|
|
56
|
+
tracing = null,
|
|
51
57
|
} = {}) {
|
|
52
58
|
const app = express();
|
|
53
59
|
app.disable('x-powered-by');
|
|
54
60
|
app.set('trust proxy', trustProxy);
|
|
61
|
+
if (tracing) app.use(tracing.requests());
|
|
55
62
|
|
|
56
63
|
app.get('/health', async (_req, res) => {
|
|
57
64
|
let databaseOk = null;
|
|
@@ -72,6 +79,9 @@ export function createApp({
|
|
|
72
79
|
.then((migrations) => ({ migrations }), () => null);
|
|
73
80
|
}
|
|
74
81
|
if (integrations) body.integrations = await integrationsHealth(integrations);
|
|
82
|
+
// The daily reconcile's last run (server/core/reconcile.js): counts only.
|
|
83
|
+
// A difference does not fail the health check.
|
|
84
|
+
if (databaseOk && database.db) body.reconcile = await reconcileHealth(database.db).catch(() => null);
|
|
75
85
|
res.status(ok ? 200 : 503).json(body);
|
|
76
86
|
});
|
|
77
87
|
|
|
@@ -132,6 +142,12 @@ export function createApp({
|
|
|
132
142
|
// items, Seazona products).
|
|
133
143
|
const priceSystems = () => integrations?.with('listPriceItems') ?? [];
|
|
134
144
|
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems }));
|
|
145
|
+
// Custom fields for jobs (server/core/custom-fields.js): kept in Flow
|
|
146
|
+
// only; the work systems' statuses show beside them, read only.
|
|
147
|
+
app.use('/api/custom-fields', createCustomFieldsRouter({ db: database.db, personAuth: auth.personAuth, work, log }));
|
|
148
|
+
// Stage moves the work system must make, and each person's own
|
|
149
|
+
// integration choices (server/api/work-moves.js).
|
|
150
|
+
app.use('/api/work', createWorkMovesRouter({ adapters: (capability) => integrations?.with(capability) ?? [], personAuth: auth.personAuth, log }));
|
|
135
151
|
// Quiet alerts (server/core/alerts.js): what needs a person on each page.
|
|
136
152
|
// Integrations add their own through the `alerts` capability.
|
|
137
153
|
app.use('/api/alerts', createAlertsRouter({
|