@arcforgelabs/flow 2026.10.51 → 2026.10.53-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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +26 -4
- package/server/api/invoicing.js +1 -1
- package/server/app.js +2 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai.js +2 -1
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +141 -7
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +1 -10
- package/server/core/sending.js +7 -3
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +3 -0
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/lanes.js +44 -27
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +61 -20
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +289 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/custom-fields.js +3 -9
- package/server/ui/flow.css +94 -18
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.53-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.53-1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/captures.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import {
|
|
3
|
-
MAX_PHOTO_BYTES, captureView, decideCapture, readCapture, readCapturePhoto, typeProposal, uploadCapture,
|
|
3
|
+
MAX_PHOTO_BYTES, captureView, decideCapture, listCaptures, matchCapture, readCapture, readCapturePhoto, typeProposal, uploadCapture,
|
|
4
4
|
} from '../core/captures.js';
|
|
5
5
|
import { FlowError } from '../core/errors.js';
|
|
6
6
|
import { resolveOrganisation } from './invoicing.js';
|
|
7
7
|
|
|
8
8
|
// Photo capture (server/core/captures.js). A signed-in person uploads one
|
|
9
9
|
// photo. The reader, when it is on, reads it after the response. When it is
|
|
10
|
-
// off, the capture is a problem a person can type.
|
|
10
|
+
// off, the capture is a problem a person can type. A read capture is matched
|
|
11
|
+
// to a job on the Jobs board the first time it is opened, and again when a
|
|
12
|
+
// person types the fields. Logs from this router are
|
|
11
13
|
// a capture id and an error code, never the photo or the fields.
|
|
12
14
|
|
|
13
15
|
const ERROR_STATUS = {
|
|
@@ -25,7 +27,10 @@ function respond(res, error) {
|
|
|
25
27
|
return res.status(ERROR_STATUS[error.code] ?? 400).json({ ok: false, code: error.code, error: error.message });
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
// `jobsFor(organisationId)` answers the Jobs board's jobs for the matcher.
|
|
31
|
+
export function createCapturesRouter({
|
|
32
|
+
db, storage, personAuth, log = console, publicOrigin = null, reader = null, jobsFor = async () => [],
|
|
33
|
+
}) {
|
|
29
34
|
const router = express.Router();
|
|
30
35
|
const signedIn = [personAuth.requireSession];
|
|
31
36
|
|
|
@@ -79,8 +84,25 @@ export function createCapturesRouter({ db, storage, personAuth, log = console, p
|
|
|
79
84
|
|
|
80
85
|
router.use(express.json({ limit: '32kb' }));
|
|
81
86
|
|
|
87
|
+
router.get('/', ...signedIn, handle(async (req, res) => {
|
|
88
|
+
const rows = await listCaptures(db, await organisationOf(req));
|
|
89
|
+
res.json({ ok: true, captures: rows.map((row) => captureView(row)) });
|
|
90
|
+
}));
|
|
91
|
+
|
|
82
92
|
router.get('/:id', ...signedIn, handle(async (req, res) => {
|
|
83
|
-
const
|
|
93
|
+
const organisationId = await organisationOf(req);
|
|
94
|
+
let result = await readCapture(db, storage, organisationId, req.params.id, origin(req));
|
|
95
|
+
if (result.capture.state === 'ready' && result.capture.read && !result.capture.match) {
|
|
96
|
+
result = await matchCapture(db, storage, organisationId, req.params.id, { jobs: await jobsFor(organisationId), baseUrl: origin(req) });
|
|
97
|
+
}
|
|
98
|
+
res.json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
router.post('/:id/match', ...signedIn, handle(async (req, res) => {
|
|
102
|
+
const organisationId = await organisationOf(req);
|
|
103
|
+
const result = await matchCapture(db, storage, organisationId, req.params.id, {
|
|
104
|
+
jobs: await jobsFor(organisationId), fields: req.body?.fields ?? null, baseUrl: origin(req),
|
|
105
|
+
});
|
|
84
106
|
res.json({ ok: true, capture: captureView(result.capture, result.link) });
|
|
85
107
|
}));
|
|
86
108
|
|
package/server/api/invoicing.js
CHANGED
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
// PUT /settings owner or admin: change some of them
|
|
75
75
|
// GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
|
|
76
76
|
// GET /rule-fields what a rule can name, and the menus of values
|
|
77
|
-
// POST /rules owner or admin: a new rule { name, enabled, conditions, action }
|
|
77
|
+
// POST /rules owner or admin: a new rule { name, icon, enabled, conditions, action }
|
|
78
78
|
// PUT /rules/:id owner or admin: change some of a rule
|
|
79
79
|
// DELETE /rules/:id owner or admin
|
|
80
80
|
// GET /line-mappings the lab's line mappings and the mapping steps (server/core/line-mapping.js)
|
package/server/app.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createConnectorApi } from './api/connector.js';
|
|
|
7
7
|
import { createInvoicingRouter } from './api/invoicing.js';
|
|
8
8
|
import { createSignInRouter } from './auth/routes.js';
|
|
9
9
|
import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
|
|
10
|
+
import { boardJobs } from './core/job-board.js';
|
|
10
11
|
import { reconcileHealth } from './core/reconcile.js';
|
|
11
12
|
import { appliedMigrationCounts } from './db/doctor.js';
|
|
12
13
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
@@ -158,6 +159,7 @@ export function createApp({
|
|
|
158
159
|
if (storage) {
|
|
159
160
|
app.use('/api/captures', createCapturesRouter({
|
|
160
161
|
db: database.db, storage, personAuth: auth.personAuth, log, publicOrigin, reader,
|
|
162
|
+
jobsFor: async (organisationId) => (await boardJobs(database.db, organisationId, { work: work(), log })).jobs,
|
|
161
163
|
}));
|
|
162
164
|
}
|
|
163
165
|
// Custom fields for jobs (server/core/custom-fields.js): kept in Flow
|
package/server/auth/people.js
CHANGED
|
@@ -2,8 +2,8 @@ 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 { invoiceColumnsView, normaliseJobsView, normaliseListView,
|
|
6
|
-
import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
5
|
+
import { invoiceColumnsView, normaliseJobsView, normaliseListView, RECONCILE_COLUMNS } from '../ui/job-view.js';
|
|
6
|
+
import { normalisePriceView, normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
7
7
|
import { normaliseNavPins } from '../ui/nav-pins.js';
|
|
8
8
|
import { isPlainEmail } from './settings.js';
|
|
9
9
|
|
|
@@ -61,7 +61,7 @@ function viewCheck(page) {
|
|
|
61
61
|
case 'jobs': return normaliseJobsView;
|
|
62
62
|
case 'invoices': return invoiceColumnsView;
|
|
63
63
|
case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
|
|
64
|
-
case 'price-list': return
|
|
64
|
+
case 'price-list': return normalisePriceView;
|
|
65
65
|
case 'nav': return normaliseNavPins;
|
|
66
66
|
default: return null;
|
|
67
67
|
}
|
package/server/core/ai.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FlowError } from './errors.js';
|
|
2
|
+
import { SURE_LINE } from '../ui/capture-fields.js';
|
|
2
3
|
|
|
3
4
|
// The AI port. One call reads one image and returns text. The vision reader
|
|
4
5
|
// (server/core/reader.js) is the caller. A client implements readImage; this
|
|
@@ -98,7 +99,7 @@ function priceFrom(env, model, prefix) {
|
|
|
98
99
|
|
|
99
100
|
function sureLineOf(env) {
|
|
100
101
|
const raw = String(env.FLOW_READER_SURE_LINE ?? '').trim();
|
|
101
|
-
if (!raw) return
|
|
102
|
+
if (!raw) return SURE_LINE;
|
|
102
103
|
const value = Number(raw);
|
|
103
104
|
if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error('FLOW_READER_SURE_LINE must be a number from 0 to 1');
|
|
104
105
|
return value;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The capture matcher (docs/DATA_MODEL.md, "Storage"). Flow code, not the
|
|
2
|
+
// model, picks the job a form is about. It scores each job on the Jobs board
|
|
3
|
+
// against the read or typed fields, in the style of the accounting link
|
|
4
|
+
// suggestions: a score, a rank and the signals behind it. A person always
|
|
5
|
+
// confirms the job before anything is applied.
|
|
6
|
+
//
|
|
7
|
+
// Points: case number 60 exact, 50 for a common misread (O for 0, l for 1),
|
|
8
|
+
// practice and dentist 20, due date within 3 days 10, work or teeth found in
|
|
9
|
+
// the job's lines 10.
|
|
10
|
+
|
|
11
|
+
export const MATCH_POINTS = Object.freeze({ caseNumber: 60, caseMisread: 50, practice: 12, dentist: 8, date: 10, work: 10 });
|
|
12
|
+
// A job at or above this, with no other job within SURE_GAP, is the one sure job.
|
|
13
|
+
export const SURE_SCORE = 60;
|
|
14
|
+
export const SURE_GAP = 20;
|
|
15
|
+
// A job below this is not shown.
|
|
16
|
+
export const SHOWN_SCORE = 20;
|
|
17
|
+
const SHOWN_MAX = 5;
|
|
18
|
+
const DATE_DAYS = 3;
|
|
19
|
+
|
|
20
|
+
// Fields that find the job (the work is its lines). They are not proposed as
|
|
21
|
+
// changes to a job that exists.
|
|
22
|
+
const IDENTITY = new Set(['case_number', 'practice', 'work']);
|
|
23
|
+
const MISREAD = { O: '0', Q: '0', D: '0', I: '1', L: '1', S: '5', B: '8', Z: '2', G: '6' };
|
|
24
|
+
const NAME_NOISE = new Set(['dr', 'dental', 'dentist', 'dentistry', 'clinic', 'surgery', 'practice', 'centre', 'center', 'pty', 'ltd', 'the', 'and', 'of']);
|
|
25
|
+
|
|
26
|
+
export const jobKey = (job) => `${job.sourceSystem}:${job.sourceJobRef}`;
|
|
27
|
+
|
|
28
|
+
const plain = (value) => String(value ?? '').trim();
|
|
29
|
+
const caseText = (value) => plain(value).toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
30
|
+
const misread = (value) => caseText(value).replace(/[OQDILSBZG]/g, (ch) => MISREAD[ch]);
|
|
31
|
+
|
|
32
|
+
function words(value) {
|
|
33
|
+
return plain(value).toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/)
|
|
34
|
+
.filter((word) => word.length > 1 && !NAME_NOISE.has(word));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Edit distance, for close spelling of a name.
|
|
38
|
+
function distance(a, b) {
|
|
39
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
40
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
41
|
+
let previous = row[0];
|
|
42
|
+
row[0] = i;
|
|
43
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
44
|
+
const kept = row[j];
|
|
45
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
46
|
+
previous = kept;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return row[b.length];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const closeWord = (a, b) => a === b || (Math.min(a.length, b.length) >= 4 && distance(a, b) <= Math.floor(Math.max(a.length, b.length) / 4));
|
|
53
|
+
|
|
54
|
+
// Half or more of the shorter name's words appear, by close spelling, in the other.
|
|
55
|
+
export function sameName(read, known) {
|
|
56
|
+
const a = words(read);
|
|
57
|
+
const b = words(known);
|
|
58
|
+
if (!a.length || !b.length) return false;
|
|
59
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
60
|
+
const found = short.filter((word) => long.some((other) => closeWord(word, other))).length;
|
|
61
|
+
return found * 2 >= short.length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A date as a lab writes it: 2026-10-31, 31/10/2026, 31/10/26 or 31/10 (day
|
|
65
|
+
// first, as in Australia). A date with no year takes the year nearest `now`.
|
|
66
|
+
export function readDate(value, now = new Date()) {
|
|
67
|
+
const text = plain(value);
|
|
68
|
+
let match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text);
|
|
69
|
+
if (match) return utc(+match[1], +match[2], +match[3]);
|
|
70
|
+
match = /^(\d{1,2})[/.-](\d{1,2})(?:[/.-](\d{2}|\d{4}))?$/.exec(text);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
const [, day, month, year] = match;
|
|
73
|
+
if (year) return utc(year.length === 2 ? 2000 + +year : +year, +month, +day);
|
|
74
|
+
const near = [-1, 0, 1].map((step) => utc(now.getUTCFullYear() + step, +month, +day)).filter(Boolean);
|
|
75
|
+
return near.sort((a, b) => Math.abs(a - now) - Math.abs(b - now))[0] ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function utc(year, month, day) {
|
|
79
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
80
|
+
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function teethOf(value) {
|
|
84
|
+
return plain(value).match(/\b[1-8][1-8]\b/g) ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function workFound(fields, job) {
|
|
88
|
+
const lines = (job.lines ?? []).map((line) => plain(line.description).toLowerCase()).join(' ');
|
|
89
|
+
if (!lines) return false;
|
|
90
|
+
const work = words(fields.work).filter((word) => word.length >= 4);
|
|
91
|
+
if (work.some((word) => lines.includes(word))) return true;
|
|
92
|
+
return teethOf(fields.teeth).some((tooth) => new RegExp(`\\b${tooth}\\b`).test(lines));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function score(fields, job, now) {
|
|
96
|
+
const signals = [];
|
|
97
|
+
const readCase = caseText(fields.case_number);
|
|
98
|
+
const jobCase = caseText(job.caseRef);
|
|
99
|
+
if (readCase && jobCase) {
|
|
100
|
+
if (readCase === jobCase) signals.push({ signal: 'case_number', points: MATCH_POINTS.caseNumber });
|
|
101
|
+
else if (misread(readCase) === misread(jobCase)) signals.push({ signal: 'case_number_misread', points: MATCH_POINTS.caseMisread });
|
|
102
|
+
}
|
|
103
|
+
if (sameName(fields.practice, job.clientName) || sameName(fields.practice, job.workClientName)) {
|
|
104
|
+
signals.push({ signal: 'practice', points: MATCH_POINTS.practice });
|
|
105
|
+
}
|
|
106
|
+
if (sameName(fields.dentist, job.practitioner)) signals.push({ signal: 'dentist', points: MATCH_POINTS.dentist });
|
|
107
|
+
const due = readDate(fields.due_date, now);
|
|
108
|
+
const jobDue = readDate(job.dueOn ?? job.jobDate, now);
|
|
109
|
+
if (due && jobDue && Math.abs(due - jobDue) <= DATE_DAYS * 86_400_000) signals.push({ signal: 'date', points: MATCH_POINTS.date });
|
|
110
|
+
if (workFound(fields, job)) signals.push({ signal: 'work', points: MATCH_POINTS.work });
|
|
111
|
+
return { score: signals.reduce((sum, s) => sum + s.points, 0), signals };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `fields` is { key: value }. `jobs` is the Jobs board's jobs
|
|
115
|
+
// (server/core/job-board.js). Answers { outcome, jobs }: outcome `sure` (one
|
|
116
|
+
// job), `close` (a short pick list) or `none` (offer a new job).
|
|
117
|
+
export function matchJobs(fields, jobs, { now = new Date() } = {}) {
|
|
118
|
+
const ranked = (jobs ?? [])
|
|
119
|
+
.map((job) => ({ job, ...score(fields, job, now) }))
|
|
120
|
+
.filter((entry) => entry.score >= SHOWN_SCORE)
|
|
121
|
+
.sort((a, b) => b.score - a.score || plain(a.job.sourceJobRef).localeCompare(plain(b.job.sourceJobRef)))
|
|
122
|
+
.slice(0, SHOWN_MAX);
|
|
123
|
+
const [top, next] = ranked;
|
|
124
|
+
const outcome = !top ? 'none'
|
|
125
|
+
: top.score >= SURE_SCORE && (!next || top.score - next.score >= SURE_GAP) ? 'sure' : 'close';
|
|
126
|
+
return {
|
|
127
|
+
outcome,
|
|
128
|
+
jobs: ranked.map(({ job, score: points, signals }) => ({
|
|
129
|
+
jobId: jobKey(job), caseRef: job.caseRef ?? null, clientName: job.clientName ?? null, score: points, signals,
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// What the job holds now for a read field, where the board shows it.
|
|
135
|
+
function current(job, key) {
|
|
136
|
+
if (!job) return '';
|
|
137
|
+
if (key === 'dentist') return plain(job.practitioner);
|
|
138
|
+
if (key === 'due_date') return plain(job.dueOn);
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The proposed change from the fields and the match. One sure job: update it
|
|
143
|
+
// with each field that differs. Close jobs: update, and the person picks the
|
|
144
|
+
// job. No job: create one from the fields. A field the reader was not sure
|
|
145
|
+
// of keeps its sure score, so the screen can mark it.
|
|
146
|
+
export function proposeChange(read, match, jobs) {
|
|
147
|
+
const sure = match.outcome === 'sure' ? match.jobs[0] : null;
|
|
148
|
+
const job = sure ? (jobs ?? []).find((entry) => jobKey(entry) === sure.jobId) ?? null : null;
|
|
149
|
+
const changes = read
|
|
150
|
+
.filter((field) => plain(field.value) && (match.outcome === 'none' || !IDENTITY.has(field.key)))
|
|
151
|
+
.map((field) => ({ field: field.key, from: current(job, field.key), to: plain(field.value), sure: field.sure }))
|
|
152
|
+
.filter((change) => change.from !== change.to);
|
|
153
|
+
return {
|
|
154
|
+
action: match.outcome === 'none' ? 'create' : 'update',
|
|
155
|
+
jobId: sure?.jobId ?? null,
|
|
156
|
+
changes,
|
|
157
|
+
};
|
|
158
|
+
}
|
package/server/core/captures.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
|
|
2
|
+
import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
|
|
3
3
|
import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
|
|
4
4
|
import { FlowError, notFound } from './errors.js';
|
|
5
|
+
import { matchJobs, proposeChange, readDate } from './capture-match.js';
|
|
6
|
+
import { listFields, listJobFieldValues, setJobFieldValue } from './custom-fields.js';
|
|
7
|
+
import { JOB_FIELDS } from '../ui/capture-fields.js';
|
|
5
8
|
|
|
6
9
|
// Shown when the module is off or the keys are missing. Type it still works.
|
|
7
10
|
const READER_OFF = 'reader turned off';
|
|
@@ -10,8 +13,9 @@ const READER_OFF = 'reader turned off';
|
|
|
10
13
|
// "Storage"). This does not change a job. The reader, when it is on, fills
|
|
11
14
|
// `read` in the background (server/core/reader.js). When it is off, or the
|
|
12
15
|
// keys are missing, the capture is `problem` ("reader turned off") and a
|
|
13
|
-
// person can type the proposal.
|
|
14
|
-
//
|
|
16
|
+
// person can type the proposal. A ready capture is matched to a job on the
|
|
17
|
+
// Jobs board (server/core/capture-match.js), and the match proposes the
|
|
18
|
+
// change. Apply or Discard records that decision and deletes the photo. A capture nobody decides is deleted after 7 days,
|
|
15
19
|
// photo included.
|
|
16
20
|
//
|
|
17
21
|
// Nothing here writes the photo, the typed fields or a patient name to a log.
|
|
@@ -24,6 +28,8 @@ export const EMPTY_PROPOSAL = Object.freeze({ action: null, jobId: null, changes
|
|
|
24
28
|
const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
|
|
25
29
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
30
|
const ACTIONS = new Set(['update', 'create', 'note']);
|
|
31
|
+
// A job on the Jobs board: its work system and its id there (capture-match.js jobKey).
|
|
32
|
+
const JOB_ID = /^[a-z][a-z0-9_-]{0,39}:[^\u0000-\u001f]{1,200}$/;
|
|
27
33
|
const FIELD_MAX = 60;
|
|
28
34
|
const VALUE_MAX = 2000;
|
|
29
35
|
const CHANGES_MAX = 40;
|
|
@@ -78,13 +84,14 @@ export function readProposal(input) {
|
|
|
78
84
|
const action = input.action ?? null;
|
|
79
85
|
if (action !== null && !ACTIONS.has(action)) throw invalid('action must be update, create or note');
|
|
80
86
|
const jobId = input.jobId ?? null;
|
|
81
|
-
if (jobId !== null && !
|
|
87
|
+
if (jobId !== null && !JOB_ID.test(String(jobId))) throw invalid('jobId must be a job id');
|
|
82
88
|
if (!Array.isArray(input.changes)) throw invalid('changes must be a list');
|
|
83
89
|
if (input.changes.length > CHANGES_MAX) throw invalid('too many changes');
|
|
84
90
|
const changes = input.changes.map((change) => ({
|
|
85
91
|
field: readText(change?.field, FIELD_MAX),
|
|
86
92
|
from: readText(change?.from ?? '', VALUE_MAX),
|
|
87
93
|
to: readText(change?.to ?? '', VALUE_MAX),
|
|
94
|
+
...(Number.isFinite(change?.sure) ? { sure: Math.min(1, Math.max(0, change.sure)) } : {}),
|
|
88
95
|
}));
|
|
89
96
|
if (changes.some((change) => !change.field)) throw invalid('a change needs a field');
|
|
90
97
|
if (changes.length > 0 && !action) throw invalid('action is required');
|
|
@@ -102,6 +109,7 @@ export function captureView(row, link = null) {
|
|
|
102
109
|
fileSha256: row.fileSha256,
|
|
103
110
|
proposal: row.proposal ?? { ...EMPTY_PROPOSAL, changes: [] },
|
|
104
111
|
read: row.read ?? null,
|
|
112
|
+
match: row.match ?? null,
|
|
105
113
|
problem: row.problem,
|
|
106
114
|
decision: row.decision,
|
|
107
115
|
decidedAt: row.decidedAt,
|
|
@@ -221,6 +229,130 @@ export async function typeProposal(db, storage, organisationId, id, input, { bas
|
|
|
221
229
|
return { capture: row, link: linkFor(storage, row, baseUrl) };
|
|
222
230
|
}
|
|
223
231
|
|
|
232
|
+
// What a person typed instead of a read: { key: value }, keys as the reader's.
|
|
233
|
+
function readTypedFields(input) {
|
|
234
|
+
if (input == null || typeof input !== 'object' || Array.isArray(input)) throw invalid('fields must be an object');
|
|
235
|
+
const entries = Object.entries(input);
|
|
236
|
+
if (entries.length > CHANGES_MAX) throw invalid('too many fields');
|
|
237
|
+
return entries.map(([key, value]) => ({ key: readText(key, FIELD_MAX), value: readText(value ?? '', VALUE_MAX), seen: '', sure: 1 }));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const isEmpty = (proposal) => !proposal?.action && !(proposal?.changes?.length);
|
|
241
|
+
|
|
242
|
+
// Scores the Jobs board against the read (or typed) fields, stores the match
|
|
243
|
+
// and, when nobody has typed a change yet, the proposed change. Typed fields
|
|
244
|
+
// always replace the proposal: the person asked for a new match. `jobs` is
|
|
245
|
+
// the board's jobs (server/core/job-board.js boardJobs).
|
|
246
|
+
export async function matchCapture(db, storage, organisationId, id, { jobs, fields = null, baseUrl, now } = {}) {
|
|
247
|
+
const current = await byId(db, organisationId, id);
|
|
248
|
+
if (current.state !== 'ready' && !(fields && current.state === 'problem')) throw new FlowError('conflict', 'this capture is not ready');
|
|
249
|
+
const read = fields ? readTypedFields(fields) : (current.read?.fields ?? []);
|
|
250
|
+
const values = Object.fromEntries(read.map((field) => [field.key, field.value]));
|
|
251
|
+
const match = matchJobs(values, jobs, { now });
|
|
252
|
+
const replace = Boolean(fields) || isEmpty(current.proposal);
|
|
253
|
+
const [row] = await db.update(captures).set({
|
|
254
|
+
state: 'ready',
|
|
255
|
+
problem: null,
|
|
256
|
+
match,
|
|
257
|
+
...(replace ? { proposal: await toCustomFields(db, organisationId, proposeChange(read, match, jobs)) } : {}),
|
|
258
|
+
updatedAt: sql`now()`,
|
|
259
|
+
}).where(and(
|
|
260
|
+
eq(captures.organisationId, organisationId),
|
|
261
|
+
eq(captures.id, id),
|
|
262
|
+
eq(captures.state, current.state),
|
|
263
|
+
)).returning();
|
|
264
|
+
if (!row) throw new FlowError('conflict', 'this capture changed; open it again');
|
|
265
|
+
return { capture: row, link: linkFor(storage, row, baseUrl) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The capture tray: every capture still open, and the ones decided in the
|
|
269
|
+
// last day, newest first. No signed link: the result sheet asks for one.
|
|
270
|
+
export async function listCaptures(db, organisationId, { now = new Date(), limit = 100 } = {}) {
|
|
271
|
+
const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
272
|
+
return db.select().from(captures).where(and(
|
|
273
|
+
eq(captures.organisationId, organisationId),
|
|
274
|
+
or(isNull(captures.decision), gt(captures.decidedAt, since)),
|
|
275
|
+
)).orderBy(desc(captures.createdAt)).limit(limit);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// A custom field's value from the text on the form. Undefined: Flow cannot
|
|
279
|
+
// set this field from text (a person field), so it is left as it is.
|
|
280
|
+
function fieldValue(field, text) {
|
|
281
|
+
const bad = () => invalid(`${field.name}: "${text}" does not fit this field`);
|
|
282
|
+
switch (field.type) {
|
|
283
|
+
case 'number': {
|
|
284
|
+
const number = Number(text.replace(/,/g, ''));
|
|
285
|
+
if (!Number.isFinite(number)) throw bad();
|
|
286
|
+
return number;
|
|
287
|
+
}
|
|
288
|
+
case 'date': {
|
|
289
|
+
const date = readDate(text);
|
|
290
|
+
if (!date) throw bad();
|
|
291
|
+
return date.toISOString().slice(0, 10);
|
|
292
|
+
}
|
|
293
|
+
case 'dropdown': {
|
|
294
|
+
const option = field.options.find((o) => !o.archived && o.name.toLowerCase() === text.toLowerCase());
|
|
295
|
+
if (!option) throw bad();
|
|
296
|
+
return option.id;
|
|
297
|
+
}
|
|
298
|
+
case 'checkbox':
|
|
299
|
+
if (/^(yes|y|true|x|✓|1)$/i.test(text)) return true;
|
|
300
|
+
if (/^(no|n|false|0)$/i.test(text)) return false;
|
|
301
|
+
throw bad();
|
|
302
|
+
case 'person':
|
|
303
|
+
return undefined;
|
|
304
|
+
default:
|
|
305
|
+
return text;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const jobOf = (jobId) => {
|
|
310
|
+
const at = jobId.indexOf(':');
|
|
311
|
+
return { sourceSystem: jobId.slice(0, at), sourceJobRef: jobId.slice(at + 1) };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// A custom field's stored value as the sheet shows it ("from").
|
|
315
|
+
function shownValue(field, value) {
|
|
316
|
+
if (value == null) return '';
|
|
317
|
+
if (field.type === 'dropdown') return field.options?.find((option) => option.id === value)?.name ?? '';
|
|
318
|
+
if (field.type === 'checkbox') return value ? 'Yes' : 'No';
|
|
319
|
+
return String(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A read field goes to the lab's custom field of the same name ("Shade")
|
|
323
|
+
// when there is one: Flow owns it, so Apply writes it. Any other field stays
|
|
324
|
+
// the work system's; the sheet marks it to change there (maintainer, #357).
|
|
325
|
+
async function toCustomFields(db, organisationId, proposal) {
|
|
326
|
+
if (proposal.action !== 'update') return proposal;
|
|
327
|
+
const byName = new Map((await listFields(db, organisationId)).map((field) => [field.name.trim().toLowerCase(), field]));
|
|
328
|
+
const labels = new Map(JOB_FIELDS.map((field) => [field.key, field.label.toLowerCase()]));
|
|
329
|
+
const values = proposal.jobId
|
|
330
|
+
? new Map((await listJobFieldValues(db, organisationId, { jobs: [jobOf(proposal.jobId)] })).map((row) => [row.fieldId, row.value]))
|
|
331
|
+
: new Map();
|
|
332
|
+
const changes = proposal.changes.map((change) => {
|
|
333
|
+
const field = byName.get(labels.get(change.field));
|
|
334
|
+
return field ? { ...change, field: `custom_${field.id}`, from: shownValue(field, values.get(field.id)) } : change;
|
|
335
|
+
}).filter((change) => change.from !== change.to);
|
|
336
|
+
return { ...proposal, changes };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Apply writes what Flow owns: the job's custom fields, each with its own
|
|
340
|
+
// event (who, when, from and to). A field the work system owns (due date,
|
|
341
|
+
// shade) stays there; the result sheet links to the job in that system.
|
|
342
|
+
// Every value is checked before the first write, so a bad one writes none.
|
|
343
|
+
async function applyToJob(db, organisationId, proposal, personId) {
|
|
344
|
+
if (proposal?.action !== 'update' || !proposal.jobId) return;
|
|
345
|
+
const job = jobOf(proposal.jobId);
|
|
346
|
+
const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
|
|
347
|
+
const writes = proposal.changes
|
|
348
|
+
.filter((change) => fields.has(change.field))
|
|
349
|
+
.map((change) => ({ field: fields.get(change.field), value: fieldValue(fields.get(change.field), change.to) }))
|
|
350
|
+
.filter((write) => write.value !== undefined);
|
|
351
|
+
for (const { field, value } of writes) {
|
|
352
|
+
await setJobFieldValue(db, organisationId, { ...job, fieldId: field.id, value }, { personId });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
224
356
|
async function deletePhoto(db, storage, row) {
|
|
225
357
|
if (row.photoDeletedAt) return row;
|
|
226
358
|
try {
|
|
@@ -242,9 +374,10 @@ async function deletePhoto(db, storage, row) {
|
|
|
242
374
|
return next ?? row;
|
|
243
375
|
}
|
|
244
376
|
|
|
245
|
-
// Records the person's decision and deletes the photo.
|
|
246
|
-
// job
|
|
247
|
-
//
|
|
377
|
+
// Records the person's decision and deletes the photo. Apply first writes
|
|
378
|
+
// the job's custom fields (applyToJob). The same decision again writes the
|
|
379
|
+
// same values, which changes nothing, and deletes the photo if that was
|
|
380
|
+
// still waiting.
|
|
248
381
|
export async function decideCapture(db, storage, organisationId, id, decision, { personId, baseUrl } = {}) {
|
|
249
382
|
if (!CAPTURE_DECISIONS.includes(decision)) throw invalid('decision must be applied or discarded');
|
|
250
383
|
const current = await byId(db, organisationId, id);
|
|
@@ -254,6 +387,7 @@ export async function decideCapture(db, storage, organisationId, id, decision, {
|
|
|
254
387
|
return { capture: row, link: null };
|
|
255
388
|
}
|
|
256
389
|
if (current.state !== 'ready') throw new FlowError('conflict', 'this capture is not ready');
|
|
390
|
+
if (decision === 'applied') await applyToJob(db, organisationId, current.proposal, personId ?? null);
|
|
257
391
|
const [row] = await db.update(captures).set({
|
|
258
392
|
state: 'done',
|
|
259
393
|
decision,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
1
4
|
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
5
|
import { invoiceRules, invoicingSettings } from '../db/schema.js';
|
|
3
6
|
import { FlowError, notFound } from './errors.js';
|
|
@@ -26,6 +29,10 @@ const MAX_CONDITIONS = 10;
|
|
|
26
29
|
const invalid = (message) => new FlowError('invalid', message);
|
|
27
30
|
|
|
28
31
|
const MAX_NOTE = 200;
|
|
32
|
+
// A rule's own icon: any Lucide name (the icon picker, server/ui/kit.js).
|
|
33
|
+
// Null shows the mark of what the rule does.
|
|
34
|
+
const ICON_DIR = join(dirname(createRequire(import.meta.url).resolve('lucide-static/package.json')), 'icons');
|
|
35
|
+
const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
29
36
|
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
30
37
|
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
31
38
|
// A line's key: its price item code, else the label line mapping gave it (a
|
|
@@ -107,10 +114,14 @@ function readCondition(raw, index) {
|
|
|
107
114
|
|
|
108
115
|
export function readRule(raw) {
|
|
109
116
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the rule must be an object');
|
|
110
|
-
const unknown = Object.keys(raw).filter((k) => !['name', 'enabled', 'conditions', 'action'].includes(k));
|
|
117
|
+
const unknown = Object.keys(raw).filter((k) => !['name', 'icon', 'enabled', 'conditions', 'action'].includes(k));
|
|
111
118
|
if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
|
|
112
119
|
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
113
120
|
if (!name || name.length > 120) throw invalid('the rule needs a name of 120 characters at most');
|
|
121
|
+
const icon = raw.icon ?? null;
|
|
122
|
+
if (icon !== null && (typeof icon !== 'string' || icon.length > 64 || !ICON_NAME.test(icon) || !existsSync(join(ICON_DIR, `${icon}.svg`)))) {
|
|
123
|
+
throw invalid('the icon must be a Lucide icon name');
|
|
124
|
+
}
|
|
114
125
|
const enabled = raw.enabled ?? true;
|
|
115
126
|
if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
|
|
116
127
|
if (!Array.isArray(raw.conditions) || !raw.conditions.length || raw.conditions.length > MAX_CONDITIONS) {
|
|
@@ -127,7 +138,7 @@ export function readRule(raw) {
|
|
|
127
138
|
const early = conditions.find((c) => FIELDS[c.field].draftOnly);
|
|
128
139
|
if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
|
|
129
140
|
}
|
|
130
|
-
return { name, enabled, conditions, action };
|
|
141
|
+
return { name, icon, enabled, conditions, action };
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
function matches(condition, job, context) {
|
|
@@ -270,7 +281,7 @@ async function seed(db, organisationId) {
|
|
|
270
281
|
}
|
|
271
282
|
|
|
272
283
|
const shape = (r) => ({
|
|
273
|
-
id: r.id, name: r.name, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
284
|
+
id: r.id, name: r.name, icon: r.icon, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
274
285
|
conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
|
|
275
286
|
});
|
|
276
287
|
|
|
@@ -299,12 +310,12 @@ async function loadRule(db, organisationId, ruleId) {
|
|
|
299
310
|
return row;
|
|
300
311
|
}
|
|
301
312
|
|
|
302
|
-
// Changes a rule. `changes` holds any of name, enabled, conditions, action;
|
|
313
|
+
// Changes a rule. `changes` holds any of name, icon, enabled, conditions, action;
|
|
303
314
|
// the result must be a whole valid rule.
|
|
304
315
|
export async function updateRule(db, organisationId, ruleId, changes, { personId = null } = {}) {
|
|
305
316
|
const row = await loadRule(db, organisationId, ruleId);
|
|
306
317
|
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
307
|
-
const rule = readRule({ name: row.name, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
318
|
+
const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
308
319
|
const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
|
|
309
320
|
.where(eq(invoiceRules.id, row.id)).returning();
|
|
310
321
|
return shape(saved);
|
package/server/core/reader.js
CHANGED
|
@@ -4,6 +4,7 @@ import { callCostMicros } from './ai.js';
|
|
|
4
4
|
import { captures, jobFields } from '../db/schema.js';
|
|
5
5
|
import { FlowError } from './errors.js';
|
|
6
6
|
import { enqueueJob, runNextJob } from './queue.js';
|
|
7
|
+
import { JOB_FIELDS } from '../ui/capture-fields.js';
|
|
7
8
|
|
|
8
9
|
// The vision reader. A small model reads every capture. A larger model reads
|
|
9
10
|
// again only when case number, practice or work is below the sure line.
|
|
@@ -20,16 +21,6 @@ export const CAPTURE_READ_KIND = 'capture.read';
|
|
|
20
21
|
const CALLS = 3;
|
|
21
22
|
const VALUE_MAX = 2000;
|
|
22
23
|
|
|
23
|
-
export const JOB_FIELDS = Object.freeze([
|
|
24
|
-
Object.freeze({ key: 'case_number', label: 'Case number', needed: true }),
|
|
25
|
-
Object.freeze({ key: 'practice', label: 'Practice', needed: true }),
|
|
26
|
-
Object.freeze({ key: 'dentist', label: 'Dentist', needed: false }),
|
|
27
|
-
Object.freeze({ key: 'work', label: 'Work', needed: true }),
|
|
28
|
-
Object.freeze({ key: 'teeth', label: 'Teeth', needed: false }),
|
|
29
|
-
Object.freeze({ key: 'shade', label: 'Shade', needed: false }),
|
|
30
|
-
Object.freeze({ key: 'due_date', label: 'Due date', needed: false }),
|
|
31
|
-
Object.freeze({ key: 'notes', label: 'Notes', needed: false }),
|
|
32
|
-
]);
|
|
33
24
|
|
|
34
25
|
const NEEDED = new Set(JOB_FIELDS.filter((field) => field.needed).map((field) => field.key));
|
|
35
26
|
|
package/server/core/sending.js
CHANGED
|
@@ -53,6 +53,10 @@ const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
|
53
53
|
// it. `extra` adds `fields` (the invoice fields to fix) and `line`.
|
|
54
54
|
const problem = (code, message, short = null, extra = {}) => ({ code, message, ...(short ? { short } : {}), ...extra });
|
|
55
55
|
|
|
56
|
+
// An issued invoice the accounting system refused. A refusal stored without
|
|
57
|
+
// its few words (before 2026-10-28) shows "Not sent"; its message stays.
|
|
58
|
+
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : problem('send_failed', 'Sending failed.', 'Not sent'));
|
|
59
|
+
|
|
56
60
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
57
61
|
export function draftProblems(invoice, lines) {
|
|
58
62
|
const problems = [];
|
|
@@ -155,7 +159,7 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
|
|
|
155
159
|
.orderBy(desc(invoices.sendUpdatedAt));
|
|
156
160
|
for (const row of issued) {
|
|
157
161
|
if (row.sendState === 'queued') sending.add(row.id);
|
|
158
|
-
else problems.set(row.id, [row.sendError
|
|
162
|
+
else problems.set(row.id, [refusal(row.sendError)]);
|
|
159
163
|
}
|
|
160
164
|
// A draft whose checks pass is ready (Samuel, 2026-10-20): no person
|
|
161
165
|
// needs to mark it. toCheck stays for callers that still read it.
|
|
@@ -174,7 +178,7 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
|
|
|
174
178
|
if (invoice.status === 'void') return { view: null, problems: [] };
|
|
175
179
|
if (invoice.status === 'issued') {
|
|
176
180
|
if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
|
|
177
|
-
if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError
|
|
181
|
+
if (invoice.sendState === 'failed') return { view: 'problem', problems: [refusal(invoice.sendError)] };
|
|
178
182
|
return { view: null, problems: [] };
|
|
179
183
|
}
|
|
180
184
|
const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
|
|
@@ -435,7 +439,7 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
435
439
|
summary.waiting += 1;
|
|
436
440
|
} else {
|
|
437
441
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
438
|
-
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
442
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}) } });
|
|
439
443
|
summary.failed += 1;
|
|
440
444
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
441
445
|
const fact = refusalFact(known.get(result.invoiceId), error);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ALTER TABLE "invoice_rules" ADD COLUMN IF NOT EXISTS "icon" text;--> statement-breakpoint
|
|
2
|
+
DO $flow_guard$ BEGIN
|
|
3
|
+
ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_icon_valid" CHECK ("invoice_rules"."icon" is null or (length("invoice_rules"."icon") between 1 and 64 and "invoice_rules"."icon" !~ '[^a-z0-9-]'));
|
|
4
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
5
|
+
END $flow_guard$;
|