@arcforgelabs/flow 2026.10.46 → 2026.10.48-1
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 +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ run a lab.
|
|
|
43
43
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
44
44
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
45
45
|
| `docs/INVOICE_SENDING.md` | The invoice tabs (Pending, Ready, Draft, Approved, Sent, Void), a changed job, and Send all ready to the accounting system |
|
|
46
|
+
| `docs/CONTACTS.md` | Contacts: Flow's own customers and suppliers, and creating a missing Xero client from one |
|
|
46
47
|
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
47
48
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
48
49
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
package/VISION.md
CHANGED
|
@@ -54,16 +54,16 @@ column per stage, a card per job), with a list view of the same jobs. It
|
|
|
54
54
|
shows every job from the case system and from Flow, with its practice, due
|
|
55
55
|
date and department. Lab staff and the manager work from it.
|
|
56
56
|
|
|
57
|
-
Two parts follow the board
|
|
58
|
-
|
|
59
|
-
- **An intake reader
|
|
60
|
-
action: update a job, create one, or add a note. A handwriting reader
|
|
61
|
-
low-cost decision model propose it; a person confirms it. Nothing
|
|
62
|
-
until a person accepts. The model steps are optional and cost money
|
|
63
|
-
page, so the manager sees the month's cost.
|
|
64
|
-
- **A practice portal
|
|
65
|
-
on desktop first, with a simpler view on phones. The server limits
|
|
66
|
-
person to their practice.
|
|
57
|
+
Two parts follow the board:
|
|
58
|
+
|
|
59
|
+
- **An intake reader** (#267). A photo, a form or a note becomes a proposed
|
|
60
|
+
job action: update a job, create one, or add a note. A handwriting reader
|
|
61
|
+
and a low-cost decision model propose it; a person confirms it. Nothing
|
|
62
|
+
changes until a person accepts. The model steps are optional and cost money
|
|
63
|
+
per page, so the manager sees the month's cost.
|
|
64
|
+
- **A practice portal** (#268). Practice staff sign in to follow their own
|
|
65
|
+
jobs, on desktop first, with a simpler view on phones. The server limits
|
|
66
|
+
each person to their practice.
|
|
67
67
|
|
|
68
68
|
Job management is a bundled module, built the same way as the integrations,
|
|
69
69
|
and a lab can remove it. Where the case system cannot take a change through
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.48-1",
|
|
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.48-1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
|
@@ -1254,18 +1254,18 @@
|
|
|
1254
1254
|
"license": "MIT"
|
|
1255
1255
|
},
|
|
1256
1256
|
"node_modules/body-parser": {
|
|
1257
|
-
"version": "2.
|
|
1258
|
-
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.
|
|
1259
|
-
"integrity": "sha512-
|
|
1257
|
+
"version": "2.4.0",
|
|
1258
|
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
|
1259
|
+
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
|
1260
1260
|
"license": "MIT",
|
|
1261
1261
|
"dependencies": {
|
|
1262
1262
|
"bytes": "^3.1.2",
|
|
1263
|
-
"content-type": "^2.
|
|
1263
|
+
"content-type": "^2.1.0",
|
|
1264
1264
|
"debug": "^4.4.3",
|
|
1265
1265
|
"http-errors": "^2.0.1",
|
|
1266
|
-
"iconv-lite": "^0.7.
|
|
1266
|
+
"iconv-lite": "^0.7.3",
|
|
1267
1267
|
"on-finished": "^2.4.1",
|
|
1268
|
-
"qs": "^6.
|
|
1268
|
+
"qs": "^6.16.1",
|
|
1269
1269
|
"raw-body": "^3.0.2",
|
|
1270
1270
|
"type-is": "^2.1.0"
|
|
1271
1271
|
},
|
|
@@ -2031,9 +2031,9 @@
|
|
|
2031
2031
|
"license": "ISC"
|
|
2032
2032
|
},
|
|
2033
2033
|
"node_modules/ip-address": {
|
|
2034
|
-
"version": "10.7.
|
|
2035
|
-
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.
|
|
2036
|
-
"integrity": "sha512-
|
|
2034
|
+
"version": "10.7.5",
|
|
2035
|
+
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
|
|
2036
|
+
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
|
|
2037
2037
|
"license": "MIT",
|
|
2038
2038
|
"engines": {
|
|
2039
2039
|
"node": ">= 12"
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
|
-
import { invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
|
|
3
|
+
import { invoiceKindOf, invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
|
|
4
|
+
import { fieldIdOf, filterRows } from '../ui/job-view.js';
|
|
5
|
+
import { listFields, listJobFieldValues } from '../core/custom-fields.js';
|
|
4
6
|
import { FlowError } from '../core/errors.js';
|
|
5
7
|
import { createActionGuard } from '../integrations/call-guard.js';
|
|
6
8
|
import {
|
|
7
|
-
createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
9
|
+
createDraftInvoice, deleteDraft, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
8
10
|
} from '../core/invoices.js';
|
|
9
11
|
import { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
|
|
10
12
|
import {
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
import { isHistoryHeld } from '../core/history.js';
|
|
32
34
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
33
35
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
36
|
+
import { createContact, getContact, listContacts, presentContact, updateContact } from '../core/contacts.js';
|
|
34
37
|
import {
|
|
35
38
|
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
36
39
|
} from '../db/schema.js';
|
|
@@ -45,7 +48,9 @@ import {
|
|
|
45
48
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
46
49
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
47
50
|
// accounting system { issueDate, invoiceIds? (only these; one that fails
|
|
48
|
-
// a check now stays on Pending and counts as held
|
|
51
|
+
// a check now stays on Pending and counts as held; one id is all or
|
|
52
|
+
// nothing when its job cannot be confirmed, more than one id sends the
|
|
53
|
+
// rest and counts the unconfirmed in waiting) }
|
|
49
54
|
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
50
55
|
// GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
|
|
51
56
|
// GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
|
|
@@ -56,6 +61,8 @@ import {
|
|
|
56
61
|
// POST /invoices/:id/issue owner or admin: number it and keep the document as sent
|
|
57
62
|
// POST /invoices/:id/revise owner or admin: an issued invoice back to a draft
|
|
58
63
|
// POST /invoices/:id/void owner or admin
|
|
64
|
+
// DELETE /invoices/:id owner or admin: a draft that was never issued.
|
|
65
|
+
// Frees its job, and the lab's rules then decide.
|
|
59
66
|
// GET /contacts?q=&limit= customers, by name
|
|
60
67
|
// GET /price-items?q= the price list, by code
|
|
61
68
|
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
@@ -124,8 +131,9 @@ const MAX_UNIT_CENTS = 100_000_000;
|
|
|
124
131
|
|
|
125
132
|
const ERROR_STATUS = {
|
|
126
133
|
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
127
|
-
never_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
128
|
-
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
134
|
+
never_issued: 409, was_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
135
|
+
accounting_not_connected: 409, accounting_writes_off: 409, accounting_unavailable: 409, accounting_approved: 409,
|
|
136
|
+
conflict: 409,
|
|
129
137
|
};
|
|
130
138
|
|
|
131
139
|
function sendError(res, error, log) {
|
|
@@ -449,17 +457,43 @@ export function createInvoicingRouter({
|
|
|
449
457
|
const pattern = containing(q);
|
|
450
458
|
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
451
459
|
}
|
|
452
|
-
// First the
|
|
453
|
-
//
|
|
454
|
-
|
|
460
|
+
// First the columns the filter and sort read, for every row; then the
|
|
461
|
+
// whole rows of the one page. A job column and a custom field read the
|
|
462
|
+
// invoice's job, so those values are on every row before the page is cut.
|
|
463
|
+
const lightRows = await db.select({
|
|
455
464
|
id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
|
|
456
|
-
createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
|
|
465
|
+
dueDate: invoices.dueDate, createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
|
|
457
466
|
}).from(invoices)
|
|
458
467
|
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
459
468
|
.where(and(...where))
|
|
460
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
461
|
-
|
|
462
|
-
const
|
|
469
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id));
|
|
470
|
+
const jobs = await jobsByInvoice(db, organisationId, lightRows.map((r) => r.id));
|
|
471
|
+
const customOn = filterRows(view).some((f) => fieldIdOf(f.field));
|
|
472
|
+
const [fieldDefs, fieldValues] = customOn
|
|
473
|
+
? await Promise.all([
|
|
474
|
+
listFields(db, organisationId),
|
|
475
|
+
listJobFieldValues(db, organisationId, { jobs: [...jobs.values()].filter(Boolean) }),
|
|
476
|
+
])
|
|
477
|
+
: [[], []];
|
|
478
|
+
const valuesOf = new Map();
|
|
479
|
+
for (const v of fieldValues) {
|
|
480
|
+
const key = `${v.sourceSystem}\u0000${v.sourceJobRef}`;
|
|
481
|
+
const bag = valuesOf.get(key) ?? {};
|
|
482
|
+
bag[v.fieldId] = v.value;
|
|
483
|
+
valuesOf.set(key, bag);
|
|
484
|
+
}
|
|
485
|
+
const light = lightRows.map((row) => {
|
|
486
|
+
const job = jobs.get(row.id) ?? null;
|
|
487
|
+
return {
|
|
488
|
+
...row,
|
|
489
|
+
problems: tabs.problems.get(row.id) ?? [],
|
|
490
|
+
tab: tabs.tabOf.get(row.id) ?? null,
|
|
491
|
+
sending: tabs.sending.has(row.id),
|
|
492
|
+
job,
|
|
493
|
+
fields: job ? valuesOf.get(`${job.sourceSystem}\u0000${job.sourceJobRef}`) ?? {} : {},
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
const page = invoicePage(light, { view, sort, dir, offset, limit, today: day, kindOf: invoiceKindOf(fieldDefs), me: req.person.id });
|
|
463
497
|
const pageIds = page.rows.map((r) => r.id);
|
|
464
498
|
const full = pageIds.length
|
|
465
499
|
? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
@@ -468,7 +502,6 @@ export function createInvoicingRouter({
|
|
|
468
502
|
: [];
|
|
469
503
|
const byId = new Map(full.map((r) => [r.invoice.id, r]));
|
|
470
504
|
const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
|
|
471
|
-
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
472
505
|
const systems = work();
|
|
473
506
|
res.json({
|
|
474
507
|
ok: true,
|
|
@@ -486,8 +519,9 @@ export function createInvoicingRouter({
|
|
|
486
519
|
limit,
|
|
487
520
|
total: page.total,
|
|
488
521
|
totalCents: page.totalCents,
|
|
489
|
-
// The Filter chip's
|
|
522
|
+
// The Filter chip's choices, from every row of the tab.
|
|
490
523
|
clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
|
524
|
+
practitioners: [...new Set(light.map((r) => r.job?.practitioner).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
|
491
525
|
sendsTo: system?.name ?? null,
|
|
492
526
|
sendMode: mode,
|
|
493
527
|
// The Linked column: one icon for each connected system.
|
|
@@ -593,8 +627,8 @@ export function createInvoicingRouter({
|
|
|
593
627
|
// the job at its source or adopt the change (ledger.js), as Move to
|
|
594
628
|
// Ready and Send do.
|
|
595
629
|
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
596
|
-
// A lab's pending rule (invoice-rules.js)
|
|
597
|
-
// or turn the rule off.
|
|
630
|
+
// A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
|
|
631
|
+
// is a gate too: fix its cause, void the draft, or turn the rule off.
|
|
598
632
|
const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
|
|
599
633
|
if (held) throw new FlowError('not_ready', `${held.message.replace(/\.?$/, '.')} Nothing was approved.`);
|
|
600
634
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
@@ -618,16 +652,49 @@ export function createInvoicingRouter({
|
|
|
618
652
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
619
653
|
}));
|
|
620
654
|
|
|
655
|
+
router.delete('/invoices/:id', ...manager, handle(log, async (req, res) => {
|
|
656
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
657
|
+
const organisationId = await organisationOf(req);
|
|
658
|
+
const removed = await deleteDraft(db, organisationId, invoiceId, { actor: `person:${req.person.id}`, accounting: accounting() });
|
|
659
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} ${removed.status === 'void' ? 'voided invoice' : 'deleted draft invoice'} ${invoiceId}`);
|
|
660
|
+
res.json({ ok: true });
|
|
661
|
+
}));
|
|
662
|
+
|
|
621
663
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
622
664
|
const organisationId = await organisationOf(req);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
665
|
+
const kind = text(req.query.kind);
|
|
666
|
+
if (kind && !['customer', 'supplier', 'all'].includes(kind)) throw invalid('kind must be customer, supplier or all');
|
|
667
|
+
const active = text(req.query.active);
|
|
668
|
+
if (active && !['true', 'false', 'all'].includes(active)) throw invalid('active must be true, false or all');
|
|
669
|
+
const rows = await listContacts(db, organisationId, {
|
|
670
|
+
q: text(req.query.q),
|
|
671
|
+
kind: kind === 'all' ? null : (kind || 'customer'),
|
|
672
|
+
active: active === 'all' ? null : active === 'false' ? false : true,
|
|
673
|
+
limit: limitOf(req.query.limit, 50, 500),
|
|
674
|
+
offset: offsetOf(req.query.offset),
|
|
675
|
+
});
|
|
628
676
|
res.json({ ok: true, contacts: rows });
|
|
629
677
|
}));
|
|
630
678
|
|
|
679
|
+
router.post('/contacts', ...manager, handle(log, async (req, res) => {
|
|
680
|
+
const organisationId = await organisationOf(req);
|
|
681
|
+
const contact = presentContact(await createContact(db, organisationId, req.body ?? {}));
|
|
682
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} created contact ${contact.id}`);
|
|
683
|
+
res.status(201).json({ ok: true, contact });
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
router.get('/contacts/:id', ...signedIn, handle(log, async (req, res) => {
|
|
687
|
+
const organisationId = await organisationOf(req);
|
|
688
|
+
res.json({ ok: true, contact: presentContact(await getContact(db, organisationId, pathId(req.params.id, 'contact'))) });
|
|
689
|
+
}));
|
|
690
|
+
|
|
691
|
+
router.patch('/contacts/:id', ...manager, handle(log, async (req, res) => {
|
|
692
|
+
const organisationId = await organisationOf(req);
|
|
693
|
+
const contact = presentContact(await updateContact(db, organisationId, pathId(req.params.id, 'contact'), req.body ?? {}));
|
|
694
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} updated contact ${contact.id}`);
|
|
695
|
+
res.json({ ok: true, contact });
|
|
696
|
+
}));
|
|
697
|
+
|
|
631
698
|
router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
|
|
632
699
|
const organisationId = await organisationOf(req);
|
|
633
700
|
const q = text(req.query.q);
|
|
@@ -842,7 +909,7 @@ export function createInvoicingRouter({
|
|
|
842
909
|
res.json({
|
|
843
910
|
ok: true,
|
|
844
911
|
fields: Object.entries(FIELDS).map(([key, f]) => ({
|
|
845
|
-
key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
|
|
912
|
+
key, label: f.label, kind: f.kind, options: f.options ?? null, tests: f.tests ?? TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
|
|
846
913
|
})),
|
|
847
914
|
actions: ACTIONS,
|
|
848
915
|
options: {
|
package/server/auth/people.js
CHANGED
|
@@ -4,6 +4,7 @@ import { FlowError } from '../core/errors.js';
|
|
|
4
4
|
import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
|
|
5
5
|
import { invoiceColumnsView, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
|
|
6
6
|
import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
7
|
+
import { normaliseNavPins } from '../ui/nav-pins.js';
|
|
7
8
|
import { isPlainEmail } from './settings.js';
|
|
8
9
|
|
|
9
10
|
// The people who may sign in, and their roles. Only an owner changes this
|
|
@@ -61,10 +62,11 @@ function viewCheck(page) {
|
|
|
61
62
|
case 'invoices': return invoiceColumnsView;
|
|
62
63
|
case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
|
|
63
64
|
case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
|
|
65
|
+
case 'nav': return normaliseNavPins;
|
|
64
66
|
default: return null;
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
|
-
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list'];
|
|
69
|
+
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list', 'nav'];
|
|
68
70
|
|
|
69
71
|
// A person's saved views as Flow sends them: only the known pages, each
|
|
70
72
|
// checked, so a view saved by an older Flow still loads.
|
package/server/auth/routes.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './people.js';
|
|
20
20
|
import { isPlainEmail } from './settings.js';
|
|
21
21
|
import { APP_PATHS, appPage } from '../ui/app-page.js';
|
|
22
|
+
import { navPinsFromViews } from '../ui/nav-pins.js';
|
|
22
23
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
24
|
const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
|
|
24
25
|
|
|
@@ -104,6 +105,8 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
104
105
|
}
|
|
105
106
|
body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
|
|
106
107
|
body.views = normaliseViews(req.person.views);
|
|
108
|
+
// No pins saved yet: today's sidebar. A saved choice is checked the same way.
|
|
109
|
+
body.views.nav = navPinsFromViews(req.person.views);
|
|
107
110
|
res.json(body);
|
|
108
111
|
});
|
|
109
112
|
|
|
@@ -118,7 +121,7 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
// Each person sets their own view of a page (how Jobs groups, sorts and
|
|
121
|
-
// filters)
|
|
124
|
+
// filters, and which pages are pinned: `nav`). It follows their account.
|
|
122
125
|
router.put('/api/me/views/:page', requireSession, express.json({ limit: '8kb' }), async (req, res) => {
|
|
123
126
|
try {
|
|
124
127
|
const view = await setPersonView(db, { id: req.person.id, page: req.params.page, view: req.body });
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { and, asc, eq, ilike, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { contacts, CONTACT_KINDS } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Flow's own contacts (docs/CONTACTS.md). A contact is a customer or a
|
|
6
|
+
// supplier the lab bills or buys from. The fields are what Xero needs to
|
|
7
|
+
// bill them and what Seazona keeps for the practice a job belongs to.
|
|
8
|
+
// Practitioners are people at that practice. Flow stores no patient.
|
|
9
|
+
|
|
10
|
+
const NAME_MAX = 255;
|
|
11
|
+
const TEXT_MAX = 255;
|
|
12
|
+
const SHORT_MAX = 50;
|
|
13
|
+
const PRACTITIONERS_MAX = 30;
|
|
14
|
+
const ADDRESS_KEYS = ['line1', 'line2', 'city', 'region', 'postalCode', 'country'];
|
|
15
|
+
|
|
16
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
17
|
+
|
|
18
|
+
function text(value, name, max) {
|
|
19
|
+
if (value == null || value === '') return null;
|
|
20
|
+
if (typeof value !== 'string') throw invalid(`${name} must be text`);
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (!trimmed) return null;
|
|
23
|
+
if (trimmed.length > max) throw invalid(`${name} is too long (${max} characters at most)`);
|
|
24
|
+
return trimmed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readAddress(raw) {
|
|
28
|
+
if (raw == null) return null;
|
|
29
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) throw invalid('address must be an object');
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const key of ADDRESS_KEYS) {
|
|
32
|
+
const value = text(raw[key], `address.${key}`, TEXT_MAX);
|
|
33
|
+
if (value) out[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(out).length ? out : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readPractitioners(raw) {
|
|
39
|
+
if (raw == null) return [];
|
|
40
|
+
if (!Array.isArray(raw)) throw invalid('practitioners must be a list');
|
|
41
|
+
if (raw.length > PRACTITIONERS_MAX) throw invalid(`a contact has ${PRACTITIONERS_MAX} practitioners at most`);
|
|
42
|
+
return raw.map((item, index) => {
|
|
43
|
+
const at = `practitioner ${index + 1}`;
|
|
44
|
+
const source = typeof item === 'string' ? { name: item } : item;
|
|
45
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) throw invalid(`${at} must be a name`);
|
|
46
|
+
const name = text(source.name, `${at} name`, NAME_MAX);
|
|
47
|
+
if (!name) throw invalid(`${at} needs a name`);
|
|
48
|
+
const email = text(source.email, `${at} email`, TEXT_MAX);
|
|
49
|
+
return email ? { name, email } : { name };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The fields a create or an update may set. `partial` is an update: only the
|
|
54
|
+
// keys the caller sent are returned.
|
|
55
|
+
export function readContact(input, { partial = false } = {}) {
|
|
56
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw invalid('contact must be an object');
|
|
57
|
+
const out = {};
|
|
58
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(input, key);
|
|
59
|
+
if (!partial || has('kind')) {
|
|
60
|
+
if (!CONTACT_KINDS.includes(input.kind)) throw invalid(`kind must be ${CONTACT_KINDS.join(' or ')}`);
|
|
61
|
+
out.kind = input.kind;
|
|
62
|
+
}
|
|
63
|
+
if (!partial || has('name')) {
|
|
64
|
+
const name = text(input.name, 'name', NAME_MAX);
|
|
65
|
+
if (!name) throw invalid('contact name is required');
|
|
66
|
+
out.name = name;
|
|
67
|
+
}
|
|
68
|
+
if (!partial || has('firstName')) out.firstName = text(input.firstName, 'firstName', NAME_MAX);
|
|
69
|
+
if (!partial || has('lastName')) out.lastName = text(input.lastName, 'lastName', NAME_MAX);
|
|
70
|
+
if (!partial || has('email')) out.email = text(input.email, 'email', TEXT_MAX);
|
|
71
|
+
if (!partial || has('phone')) out.phone = text(input.phone, 'phone', SHORT_MAX);
|
|
72
|
+
if (!partial || has('mobile')) out.mobile = text(input.mobile, 'mobile', SHORT_MAX);
|
|
73
|
+
if (!partial || has('accountNumber')) out.accountNumber = text(input.accountNumber, 'accountNumber', SHORT_MAX);
|
|
74
|
+
if (!partial || has('taxNumber')) out.taxNumber = text(input.taxNumber, 'taxNumber', SHORT_MAX);
|
|
75
|
+
if (!partial || has('taxRate')) out.taxRate = text(input.taxRate, 'taxRate', SHORT_MAX);
|
|
76
|
+
if (!partial || has('address')) out.address = readAddress(input.address);
|
|
77
|
+
if (!partial || has('practitioners')) out.practitioners = readPractitioners(input.practitioners);
|
|
78
|
+
if (!partial || has('active')) {
|
|
79
|
+
if (input.active != null && typeof input.active !== 'boolean') throw invalid('active must be true or false');
|
|
80
|
+
out.active = input.active !== false;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function duplicate(error, kind) {
|
|
86
|
+
const code = error?.code ?? error?.cause?.code;
|
|
87
|
+
if (code === '23505') throw new FlowError('conflict', `A ${kind ?? 'contact'} with this name already exists.`);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function presentContact(row) {
|
|
92
|
+
return {
|
|
93
|
+
id: row.id,
|
|
94
|
+
kind: row.kind,
|
|
95
|
+
name: row.name,
|
|
96
|
+
firstName: row.firstName ?? null,
|
|
97
|
+
lastName: row.lastName ?? null,
|
|
98
|
+
email: row.email ?? null,
|
|
99
|
+
phone: row.phone ?? null,
|
|
100
|
+
mobile: row.mobile ?? null,
|
|
101
|
+
accountNumber: row.accountNumber ?? null,
|
|
102
|
+
taxNumber: row.taxNumber ?? null,
|
|
103
|
+
taxRate: row.taxRate ?? null,
|
|
104
|
+
address: row.address ?? null,
|
|
105
|
+
practitioners: row.practitioners ?? [],
|
|
106
|
+
active: row.active !== false,
|
|
107
|
+
createdAt: row.createdAt,
|
|
108
|
+
updatedAt: row.updatedAt,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function createContact(db, organisationId, input) {
|
|
113
|
+
const values = readContact(input);
|
|
114
|
+
try {
|
|
115
|
+
const [row] = await db.insert(contacts).values({ organisationId, ...values }).returning();
|
|
116
|
+
return row;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
duplicate(error, values.kind);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function getContact(db, organisationId, id) {
|
|
123
|
+
const [row] = await db.select().from(contacts)
|
|
124
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, id)));
|
|
125
|
+
if (!row) throw notFound('contact');
|
|
126
|
+
return row;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function updateContact(db, organisationId, id, input) {
|
|
130
|
+
const current = await getContact(db, organisationId, id);
|
|
131
|
+
const patch = readContact(input, { partial: true });
|
|
132
|
+
if (!Object.keys(patch).length) return current;
|
|
133
|
+
try {
|
|
134
|
+
const [row] = await db.update(contacts)
|
|
135
|
+
.set({ ...patch, updatedAt: sql`now()` })
|
|
136
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, id)))
|
|
137
|
+
.returning();
|
|
138
|
+
return row;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
duplicate(error, patch.kind ?? current.kind);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `kind` null lists customers and suppliers. `active` null lists both.
|
|
145
|
+
export async function listContacts(db, organisationId, { q = '', kind = 'customer', active = true, limit = 200, offset = 0 } = {}) {
|
|
146
|
+
const where = [eq(contacts.organisationId, organisationId)];
|
|
147
|
+
if (kind) where.push(eq(contacts.kind, kind));
|
|
148
|
+
if (active === true) where.push(eq(contacts.active, true));
|
|
149
|
+
if (active === false) where.push(eq(contacts.active, false));
|
|
150
|
+
const needle = String(q ?? '').trim().replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
151
|
+
if (needle) {
|
|
152
|
+
const like = `%${needle}%`;
|
|
153
|
+
where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like)));
|
|
154
|
+
}
|
|
155
|
+
const rows = await db.select().from(contacts).where(and(...where))
|
|
156
|
+
.orderBy(asc(contacts.name), asc(contacts.id))
|
|
157
|
+
.limit(limit)
|
|
158
|
+
.offset(offset);
|
|
159
|
+
return rows.map(presentContact);
|
|
160
|
+
}
|
package/server/core/directory.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { and, eq, isNull } from 'drizzle-orm';
|
|
2
|
-
import {
|
|
2
|
+
import { organisations, priceItems, TAX_CODES } from '../db/schema.js';
|
|
3
|
+
import { createContact as insertContact } from './contacts.js';
|
|
3
4
|
import { FlowError } from './errors.js';
|
|
4
5
|
|
|
5
6
|
// Organisations, contacts and the price list: the records invoices and bills
|
|
@@ -13,14 +14,8 @@ export async function createOrganisation(db, { name, invoicePrefix }) {
|
|
|
13
14
|
return row;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!name?.trim()) throw new FlowError('invalid', 'contact name is required');
|
|
19
|
-
const [row] = await db.insert(contacts)
|
|
20
|
-
.values({ organisationId, kind, name: name.trim(), email })
|
|
21
|
-
.returning();
|
|
22
|
-
return row;
|
|
23
|
-
}
|
|
17
|
+
// Contacts live in contacts.js. This name stays so existing callers keep working.
|
|
18
|
+
export const createContact = insertContact;
|
|
24
19
|
|
|
25
20
|
export async function createPriceItem(db, organisationId, { code, name, unitAmountCents, taxCode = 'GST' }) {
|
|
26
21
|
if (!code?.trim() || !name?.trim()) throw new FlowError('invalid', 'price item code and name are required');
|
|
@@ -47,7 +47,14 @@ export const FIELDS = Object.freeze({
|
|
|
47
47
|
practitioner: { label: 'Practitioner', kind: 'text', options: 'practitioners', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
48
48
|
// Only a draft has a Reference, so only a pending rule may read it.
|
|
49
49
|
reference: { label: 'Reference', kind: 'text', draftOnly: true, read: (j) => j.reference || null },
|
|
50
|
-
|
|
50
|
+
// Menu tests read the contact id. Contains and does not contain read the
|
|
51
|
+
// customer's name (matches): a lab can exclude every name that contains
|
|
52
|
+
// one marker, so a new branch needs no new rule.
|
|
53
|
+
customer: {
|
|
54
|
+
label: 'Customer', kind: 'choice', options: 'customers',
|
|
55
|
+
tests: ['is', 'is_not', 'is_one_of', 'contains', 'does_not_contain', 'is_empty'],
|
|
56
|
+
read: (j) => j.contactId,
|
|
57
|
+
},
|
|
51
58
|
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
52
59
|
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
|
|
53
60
|
line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
|
|
@@ -71,7 +78,8 @@ function readCondition(raw, index) {
|
|
|
71
78
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
72
79
|
const field = FIELDS[raw.field];
|
|
73
80
|
if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
|
|
74
|
-
|
|
81
|
+
const allowed = field.tests ?? TESTS[field.kind];
|
|
82
|
+
if (!allowed.includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${allowed.join(', ')}`);
|
|
75
83
|
const out = { field: raw.field, test: raw.test };
|
|
76
84
|
if (NO_VALUE.has(raw.test)) return out;
|
|
77
85
|
const { value } = raw;
|
|
@@ -125,9 +133,13 @@ function matches(condition, job, context) {
|
|
|
125
133
|
const { test, value } = condition;
|
|
126
134
|
if (test === 'is_empty') return have === null || have === '' || have === undefined;
|
|
127
135
|
if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
|
|
128
|
-
|
|
136
|
+
// Customer contains reads the contact's name, in any case and with the
|
|
137
|
+
// spaces trimmed, the same way a practitioner's name is read. is, is not
|
|
138
|
+
// and is one of still compare the contact id. `customer.name` is that name.
|
|
139
|
+
const named = condition.field === 'customer' && (test === 'contains' || test === 'does_not_contain');
|
|
140
|
+
if ((field.kind === 'text' || named) && !['is', 'is_not', 'is_one_of'].includes(test)) {
|
|
129
141
|
// An empty text matches no test that reads its words; is_empty finds it.
|
|
130
|
-
const left = norm(have);
|
|
142
|
+
const left = norm(named ? (context.customer?.name ?? job.customerName ?? null) : have);
|
|
131
143
|
if (!left) return false;
|
|
132
144
|
if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
|
|
133
145
|
if (test === 'has_number') return /\d/.test(left);
|
|
@@ -195,6 +207,24 @@ export function rulePending(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
195
207
|
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))] }));
|
|
196
208
|
}
|
|
197
209
|
|
|
210
|
+
// Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
|
|
211
|
+
// A person drafted the job by hand, or the rule was made after the draft.
|
|
212
|
+
// The list shows the rule's name. The note says to void the draft or change
|
|
213
|
+
// the rule. A wait rule does not hold a draft: a person may still send one.
|
|
214
|
+
export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
215
|
+
const context = { customer, now };
|
|
216
|
+
return rules
|
|
217
|
+
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'skip')
|
|
218
|
+
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
219
|
+
.map((r) => ({
|
|
220
|
+
code: 'rule',
|
|
221
|
+
message: `Rule "${r.name}": not invoiced. Void this draft or change the rule.`,
|
|
222
|
+
short: r.name.slice(0, 40),
|
|
223
|
+
rule: r.id ?? null,
|
|
224
|
+
fields: [...new Set(r.conditions.map((c) => c.field))],
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
198
228
|
// The starter rules made from the earlier fixed settings.
|
|
199
229
|
export function starterRules(settings) {
|
|
200
230
|
const rules = [
|