@arcforgelabs/flow 2026.10.33 → 2026.10.35
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 +7 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +115 -0
- package/server/api/invoicing.js +43 -12
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +308 -0
- package/server/core/job-board.js +4 -0
- package/server/core/ledger.js +82 -2
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +64 -1
- package/server/index.js +6 -1
- package/server/integrations/port.js +8 -1
- package/server/integrations/seazona/index.js +60 -2
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +26 -0
- package/server/integrations/seazona/settings.js +35 -0
- package/server/integrations/seazona/stage-moves.js +133 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/release-channel.js +8 -6
- 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 +128 -10
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +185 -48
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +482 -49
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/README.md
CHANGED
|
@@ -31,18 +31,24 @@ run a lab.
|
|
|
31
31
|
| `CHANGELOG.md` | What changed in each release: an index of one file per release in `CHANGELOG/` |
|
|
32
32
|
| `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
|
|
33
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 |
|
|
34
37
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
35
38
|
| `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
|
|
36
39
|
| `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
|
|
37
40
|
| `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
|
|
38
41
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
39
42
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
40
|
-
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices,
|
|
43
|
+
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Finalise, a changed job, and Send all ready to the accounting system |
|
|
41
44
|
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
42
45
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
43
46
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
|
44
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 |
|
|
45
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 |
|
|
46
52
|
| `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
|
|
47
53
|
| `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
|
|
48
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.35",
|
|
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.35",
|
|
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.35",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"start": "node server/index.js",
|
|
33
33
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
34
34
|
"changelog:check": "node scripts/release/changelog.mjs check",
|
|
35
|
+
"release:stable": "node scripts/release/release-stable.mjs",
|
|
35
36
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
36
37
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
37
38
|
},
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import {
|
|
3
|
+
CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, 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, the work systems' own fields (read only),
|
|
12
|
+
// and the people a person field can name
|
|
13
|
+
// POST /api/custom-fields { name, type[, options] } owner or admin: make a field
|
|
14
|
+
// PUT /api/custom-fields/order { ids } owner or admin: the field order
|
|
15
|
+
// PUT /api/custom-fields/:id { name?, options? } owner or admin: rename, change options
|
|
16
|
+
// POST /api/custom-fields/:id/archive { archived } owner or admin: archive or bring back
|
|
17
|
+
// GET /api/custom-fields/values every job's values
|
|
18
|
+
// PUT /api/custom-fields/values { sourceSystem, sourceJobRef, fieldId, value[, expectedUpdatedAt] }
|
|
19
|
+
// set one job's value; a stale expectedUpdatedAt gives 409 with the current value
|
|
20
|
+
// GET /api/custom-fields/history?sourceSystem=&sourceJobRef= one job's changes, newest first
|
|
21
|
+
//
|
|
22
|
+
// Every route also takes `organisationId` (query or body) when the
|
|
23
|
+
// installation has more than one organisation. A value lives only in Flow:
|
|
24
|
+
// no route here writes to an integration.
|
|
25
|
+
//
|
|
26
|
+
// `work()` is the work system adapters that offer listWork; their job
|
|
27
|
+
// statuses show as a read-only Stage field.
|
|
28
|
+
const ERROR_STATUS = { not_found: 404, conflict: 409, organisation_required: 409 };
|
|
29
|
+
|
|
30
|
+
export function createCustomFieldsRouter({ db, personAuth, work = () => [], log = console }) {
|
|
31
|
+
const router = express.Router();
|
|
32
|
+
router.use(express.json({ limit: '32kb' }));
|
|
33
|
+
const signedIn = [personAuth.requireSession];
|
|
34
|
+
const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
|
|
35
|
+
|
|
36
|
+
const handle = (fn) => async (req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
res.set('Cache-Control', 'no-store');
|
|
39
|
+
await fn(req, res);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (!(error instanceof FlowError)) {
|
|
42
|
+
log.error?.(`[custom-fields] ${error.message}`);
|
|
43
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
44
|
+
}
|
|
45
|
+
const body = { ok: false, code: error.code, error: error.message };
|
|
46
|
+
if (error.details !== undefined) body.details = error.details;
|
|
47
|
+
return res.status(ERROR_STATUS[error.code] ?? 400).json(body);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const organisationOf = (req) => resolveOrganisation(db, req.body?.organisationId ?? req.query.organisationId);
|
|
51
|
+
|
|
52
|
+
// The work systems' own fields: their job statuses, as the Stage lanes on
|
|
53
|
+
// the Jobs board. Read from Flow's copy; a work system that fails to answer
|
|
54
|
+
// is left out, not an error.
|
|
55
|
+
async function systemFields() {
|
|
56
|
+
const fields = [];
|
|
57
|
+
for (const adapter of work()) {
|
|
58
|
+
try {
|
|
59
|
+
const { statuses = [] } = await adapter.capabilities.listWork();
|
|
60
|
+
fields.push({
|
|
61
|
+
id: `${adapter.id}:stage`, source: adapter.id, sourceLabel: adapter.name ?? adapter.id, name: 'Stage',
|
|
62
|
+
type: 'dropdown', readOnly: true, options: statuses.map((s) => ({ id: s.label, name: s.label, final: Boolean(s.final) })),
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
log.warn?.(`[custom-fields] ${adapter.id} stages: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return fields;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
router.get('/', ...signedIn, handle(async (req, res) => {
|
|
72
|
+
const organisationId = await organisationOf(req);
|
|
73
|
+
const archived = req.query.archived === '1' || req.query.archived === 'true';
|
|
74
|
+
const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
|
|
75
|
+
res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
router.post('/', ...manager, handle(async (req, res) => {
|
|
79
|
+
const field = await createField(db, await organisationOf(req), req.body, { personId: req.person.id });
|
|
80
|
+
res.status(201).json({ ok: true, field });
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
router.put('/order', ...manager, handle(async (req, res) => {
|
|
84
|
+
const fields = await orderFields(db, await organisationOf(req), req.body?.ids, { personId: req.person.id });
|
|
85
|
+
res.json({ ok: true, fields });
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
router.get('/values', ...signedIn, handle(async (req, res) => {
|
|
89
|
+
res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
router.put('/values', ...signedIn, handle(async (req, res) => {
|
|
93
|
+
const value = await setJobFieldValue(db, await organisationOf(req), req.body, { personId: req.person.id });
|
|
94
|
+
res.json({ ok: true, value });
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
router.get('/history', ...signedIn, handle(async (req, res) => {
|
|
98
|
+
const history = await jobFieldHistory(db, await organisationOf(req), req.query);
|
|
99
|
+
res.json({ ok: true, history });
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
router.put('/:id', ...manager, handle(async (req, res) => {
|
|
103
|
+
const field = await updateField(db, await organisationOf(req), req.params.id, req.body, { personId: req.person.id });
|
|
104
|
+
res.json({ ok: true, field });
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
router.post('/:id/archive', ...manager, handle(async (req, res) => {
|
|
108
|
+
const archived = req.body?.archived ?? true;
|
|
109
|
+
if (typeof archived !== 'boolean') throw new FlowError('invalid', 'archived must be true or false');
|
|
110
|
+
const field = await archiveField(db, await organisationOf(req), req.params.id, { archived, personId: req.person.id });
|
|
111
|
+
res.json({ ok: true, field });
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
return router;
|
|
115
|
+
}
|
package/server/api/invoicing.js
CHANGED
|
@@ -21,7 +21,9 @@ import { jobReference } from '../core/references.js';
|
|
|
21
21
|
import {
|
|
22
22
|
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
23
23
|
} from '../core/price-list.js';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
draftInvoiceForObligation, explainObligation, jobChangeFor, listObligations, setReady, useSourceLines,
|
|
26
|
+
} from '../core/ledger.js';
|
|
25
27
|
import { isHistoryHeld } from '../core/history.js';
|
|
26
28
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
27
29
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
@@ -241,15 +243,37 @@ async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
|
241
243
|
return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
|
|
242
244
|
}
|
|
243
245
|
|
|
244
|
-
|
|
246
|
+
// The job changed after its invoice was made (ledger.js, jobChangeFor): what
|
|
247
|
+
// changed, with customer names, and the work system's link to the job.
|
|
248
|
+
async function jobChangeDetail(db, organisationId, invoiceId, work) {
|
|
249
|
+
const change = await jobChangeFor(db, organisationId, invoiceId);
|
|
250
|
+
if (!change) return null;
|
|
251
|
+
let contact = null;
|
|
252
|
+
if (change.contact) {
|
|
253
|
+
const ids = [change.contact.was, change.contact.now].filter(Boolean);
|
|
254
|
+
const rows = ids.length ? await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
255
|
+
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, ids))) : [];
|
|
256
|
+
const nameOf = (id) => rows.find((r) => r.id === id)?.name ?? null;
|
|
257
|
+
contact = { was: nameOf(change.contact.was), now: nameOf(change.contact.now) };
|
|
258
|
+
}
|
|
259
|
+
const source = work.find((a) => a.id === change.sourceSystem) ?? null;
|
|
260
|
+
const link = typeof source?.capabilities.jobLink === 'function'
|
|
261
|
+
? source.capabilities.jobLink({ sourceJobRef: change.sourceJobRef, sourceInvoiceRef: change.sourceInvoiceRef })
|
|
262
|
+
: null;
|
|
263
|
+
return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
|
|
245
267
|
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
246
|
-
const [[contact], versions, jobs] = await Promise.all([
|
|
268
|
+
const [[contact], versions, jobs, jobChange] = await Promise.all([
|
|
247
269
|
db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
248
270
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, invoice.contactId))),
|
|
249
271
|
listInvoiceVersions(db, organisationId, invoiceId),
|
|
250
272
|
jobsByInvoice(db, organisationId, [invoiceId]),
|
|
273
|
+
jobChangeDetail(db, organisationId, invoiceId, work),
|
|
251
274
|
]);
|
|
252
275
|
return {
|
|
276
|
+
jobChange,
|
|
253
277
|
...invoice,
|
|
254
278
|
...(await invoiceView(db, organisationId, invoice, { accounting })),
|
|
255
279
|
// Where a draft goes when it is sent, and how: this customer's send mode.
|
|
@@ -277,7 +301,8 @@ export function createInvoicingRouter({
|
|
|
277
301
|
const rows = await db.select({
|
|
278
302
|
id: organisations.id, name: organisations.name, invoicePrefix: organisations.invoicePrefix, nextInvoiceNumber: organisations.nextInvoiceNumber,
|
|
279
303
|
}).from(organisations).orderBy(asc(organisations.name));
|
|
280
|
-
|
|
304
|
+
// sendsTo: the accounting system a sent invoice goes to, or null.
|
|
305
|
+
res.json({ ok: true, organisations: rows, canIssue: ['owner', 'admin'].includes(req.person.role), sendsTo: accounting()?.name ?? null });
|
|
281
306
|
}));
|
|
282
307
|
|
|
283
308
|
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -385,7 +410,7 @@ export function createInvoicingRouter({
|
|
|
385
410
|
router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
386
411
|
const invoiceId = pathId(req.params.id, 'invoice');
|
|
387
412
|
const organisationId = await organisationOf(req);
|
|
388
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
413
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
389
414
|
}));
|
|
390
415
|
|
|
391
416
|
router.get('/invoices/:id/pdf', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -414,7 +439,7 @@ export function createInvoicingRouter({
|
|
|
414
439
|
lines: body.lines === undefined ? [] : readLines(body.lines),
|
|
415
440
|
});
|
|
416
441
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made draft invoice ${draft.id}`);
|
|
417
|
-
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
|
|
442
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting(), work()) });
|
|
418
443
|
}));
|
|
419
444
|
|
|
420
445
|
router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -428,16 +453,22 @@ export function createInvoicingRouter({
|
|
|
428
453
|
if (body.lines !== undefined) changes.lines = readLines(body.lines);
|
|
429
454
|
if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
|
|
430
455
|
await updateDraftInvoice(db, organisationId, invoiceId, changes);
|
|
431
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
456
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
432
457
|
}));
|
|
433
458
|
|
|
434
459
|
router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
|
|
435
460
|
const invoiceId = pathId(req.params.id, 'invoice');
|
|
436
461
|
const organisationId = await organisationOf(req);
|
|
437
462
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
463
|
+
// A draft whose job changed after it was made waits for a person to fix
|
|
464
|
+
// the job at its source or adopt the change (ledger.js), as Move to
|
|
465
|
+
// Ready and Send do.
|
|
466
|
+
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
467
|
+
const held = problems.find((p) => p.code === 'job_review');
|
|
468
|
+
if (held) throw new FlowError('not_ready', `${held.message} Nothing was finalised.`);
|
|
438
469
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
439
470
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
440
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
471
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
441
472
|
}));
|
|
442
473
|
|
|
443
474
|
router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
|
|
@@ -445,7 +476,7 @@ export function createInvoicingRouter({
|
|
|
445
476
|
const organisationId = await organisationOf(req);
|
|
446
477
|
await reviseInvoice(db, organisationId, invoiceId);
|
|
447
478
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} opened invoice ${invoiceId} for revision`);
|
|
448
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
479
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
449
480
|
}));
|
|
450
481
|
|
|
451
482
|
router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
|
|
@@ -453,7 +484,7 @@ export function createInvoicingRouter({
|
|
|
453
484
|
const organisationId = await organisationOf(req);
|
|
454
485
|
await voidInvoice(db, organisationId, invoiceId);
|
|
455
486
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
|
|
456
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
487
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
457
488
|
}));
|
|
458
489
|
|
|
459
490
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -524,7 +555,7 @@ export function createInvoicingRouter({
|
|
|
524
555
|
const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: source }) : null;
|
|
525
556
|
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
|
|
526
557
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
527
|
-
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
558
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
|
|
528
559
|
}));
|
|
529
560
|
|
|
530
561
|
// "Use new lines": the job changed in the work system after its draft was
|
|
@@ -533,7 +564,7 @@ export function createInvoicingRouter({
|
|
|
533
564
|
const obligationId = pathId(req.params.id, 'job');
|
|
534
565
|
const organisationId = await organisationOf(req);
|
|
535
566
|
const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
|
|
536
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
567
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
|
|
537
568
|
}));
|
|
538
569
|
|
|
539
570
|
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -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,5 +1,7 @@
|
|
|
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';
|
|
@@ -35,6 +37,8 @@ import { createUiAssetsRouter } from './ui/routes.js';
|
|
|
35
37
|
// the migration journals this build ships (server/db/doctor.js): GET /health
|
|
36
38
|
// reports how many of each the database has applied, so the workspace's
|
|
37
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.
|
|
38
42
|
export function createApp({
|
|
39
43
|
build = deployedBuildStatus(),
|
|
40
44
|
database = null,
|
|
@@ -49,10 +53,12 @@ export function createApp({
|
|
|
49
53
|
clerkProxyRateLimitPerMinute = 300,
|
|
50
54
|
defaultFamily = DEFAULT_FAMILY,
|
|
51
55
|
log = console,
|
|
56
|
+
tracing = null,
|
|
52
57
|
} = {}) {
|
|
53
58
|
const app = express();
|
|
54
59
|
app.disable('x-powered-by');
|
|
55
60
|
app.set('trust proxy', trustProxy);
|
|
61
|
+
if (tracing) app.use(tracing.requests());
|
|
56
62
|
|
|
57
63
|
app.get('/health', async (_req, res) => {
|
|
58
64
|
let databaseOk = null;
|
|
@@ -136,6 +142,12 @@ export function createApp({
|
|
|
136
142
|
// items, Seazona products).
|
|
137
143
|
const priceSystems = () => integrations?.with('listPriceItems') ?? [];
|
|
138
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 }));
|
|
139
151
|
// Quiet alerts (server/core/alerts.js): what needs a person on each page.
|
|
140
152
|
// Integrations add their own through the `alerts` capability.
|
|
141
153
|
app.use('/api/alerts', createAlertsRouter({
|
package/server/auth/people.js
CHANGED
|
@@ -2,6 +2,7 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
|
|
|
2
2
|
import { people, PERSON_ROLES } from '../db/schema.js';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
4
|
import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
|
|
5
|
+
import { normaliseJobsView } from '../ui/job-view.js';
|
|
5
6
|
import { isPlainEmail } from './settings.js';
|
|
6
7
|
|
|
7
8
|
// The people who may sign in, and their roles. Only an owner changes this
|
|
@@ -50,6 +51,40 @@ export async function setPersonAppearance(db, { id, appearance }) {
|
|
|
50
51
|
return row.appearance;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// The pages a person can keep their own view of, each with its check.
|
|
55
|
+
// Each page is named in a switch, never looked up by the name a request
|
|
56
|
+
// sends, so a name such as "constructor" can only be refused.
|
|
57
|
+
function viewCheck(page) {
|
|
58
|
+
switch (page) {
|
|
59
|
+
case 'jobs': return normaliseJobsView;
|
|
60
|
+
default: return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const VIEW_PAGE_NAMES = ['jobs'];
|
|
64
|
+
|
|
65
|
+
// A person's saved views as Flow sends them: only the known pages, each
|
|
66
|
+
// checked, so a view saved by an older Flow still loads.
|
|
67
|
+
export function normaliseViews(input) {
|
|
68
|
+
const value = input && typeof input === 'object' ? input : {};
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const page of VIEW_PAGE_NAMES) if (value[page]) out[page] = viewCheck(page)(value[page]);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Each person keeps their own view of a page; saving one page leaves the
|
|
75
|
+
// others as they were.
|
|
76
|
+
export async function setPersonView(db, { id, page, view }) {
|
|
77
|
+
const normalise = viewCheck(page);
|
|
78
|
+
if (typeof normalise !== 'function') throw new FlowError('not_found', 'no such page');
|
|
79
|
+
const value = normalise(view);
|
|
80
|
+
const [row] = await db.update(people)
|
|
81
|
+
.set({ views: sql`coalesce(${people.views}, '{}'::jsonb) || ${JSON.stringify({ [page]: value })}::jsonb`, updatedAt: new Date() })
|
|
82
|
+
.where(eq(people.id, id))
|
|
83
|
+
.returning();
|
|
84
|
+
if (!row) throw new FlowError('not_found', 'person not found');
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
export async function findPersonById(db, id) {
|
|
54
89
|
const [row] = await db.select().from(people).where(eq(people.id, id));
|
|
55
90
|
return row ?? null;
|
package/server/auth/routes.js
CHANGED
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
listPeople,
|
|
9
9
|
normaliseEmail,
|
|
10
10
|
normaliseAppearance,
|
|
11
|
+
normaliseViews,
|
|
11
12
|
publicPerson,
|
|
12
13
|
removePerson,
|
|
13
14
|
setPersonAppearance,
|
|
15
|
+
setPersonView,
|
|
14
16
|
updatePerson,
|
|
15
17
|
} from './people.js';
|
|
16
18
|
import { isPlainEmail } from './settings.js';
|
|
@@ -99,6 +101,7 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
99
101
|
body.ownerActions = { allowed: Boolean(check.verified), by: check.by ?? null };
|
|
100
102
|
}
|
|
101
103
|
body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
|
|
104
|
+
body.views = normaliseViews(req.person.views);
|
|
102
105
|
res.json(body);
|
|
103
106
|
});
|
|
104
107
|
|
|
@@ -112,6 +115,17 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
112
115
|
}
|
|
113
116
|
});
|
|
114
117
|
|
|
118
|
+
// Each person sets their own view of a page (how Jobs groups, sorts and
|
|
119
|
+
// filters); it follows their account.
|
|
120
|
+
router.put('/api/me/views/:page', requireSession, express.json({ limit: '8kb' }), async (req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const view = await setPersonView(db, { id: req.person.id, page: req.params.page, view: req.body });
|
|
123
|
+
res.json({ ok: true, view });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
sendError(res, error, log);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
115
129
|
// Only an owner manages people.
|
|
116
130
|
const people = express.Router();
|
|
117
131
|
people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
|