@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.
Files changed (43) hide show
  1. package/README.md +7 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +115 -0
  5. package/server/api/invoicing.js +43 -12
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +12 -0
  8. package/server/auth/people.js +35 -0
  9. package/server/auth/routes.js +14 -0
  10. package/server/core/custom-fields.js +308 -0
  11. package/server/core/job-board.js +4 -0
  12. package/server/core/ledger.js +82 -2
  13. package/server/core/tracing.js +64 -0
  14. package/server/db/migrations/0018_custom_fields.sql +78 -0
  15. package/server/db/migrations/0019_people_views.sql +1 -0
  16. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  17. package/server/db/migrations/meta/0019_snapshot.json +4295 -0
  18. package/server/db/migrations/meta/_journal.json +14 -0
  19. package/server/db/schema.js +64 -1
  20. package/server/index.js +6 -1
  21. package/server/integrations/port.js +8 -1
  22. package/server/integrations/seazona/index.js +60 -2
  23. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  24. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  25. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  26. package/server/integrations/seazona/schema.js +26 -0
  27. package/server/integrations/seazona/settings.js +35 -0
  28. package/server/integrations/seazona/stage-moves.js +133 -0
  29. package/server/integrations/seazona/tables.js +2 -0
  30. package/server/integrations/sentry/index.js +61 -10
  31. package/server/release-channel.js +8 -6
  32. package/server/ui/app-page.js +1 -1
  33. package/server/ui/app.js +40 -2
  34. package/server/ui/custom-fields.js +190 -0
  35. package/server/ui/flow.css +128 -10
  36. package/server/ui/invoice-notes.js +1 -1
  37. package/server/ui/invoicing.js +185 -48
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +78 -0
  40. package/server/ui/job-view.js +119 -0
  41. package/server/ui/jobs.js +482 -49
  42. package/server/ui/monitoring.js +44 -2
  43. package/server/ui/routes.js +1 -1
@@ -0,0 +1,308 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
3
+ import { CUSTOM_FIELD_TYPES, jobFieldEvents, jobFields, jobFieldValues, people } from '../db/schema.js';
4
+ import { FlowError, notFound } from './errors.js';
5
+
6
+ // Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
7
+ // Flow, like ClickUp's custom fields. An owner or admin makes them in
8
+ // Settings, Custom fields; everyone can set a job's value. A job is the work
9
+ // system's job (sourceSystem, sourceJobRef). A value lives only in Flow: a
10
+ // change never writes to the work system or the accounting system.
11
+
12
+ export { CUSTOM_FIELD_TYPES };
13
+ // The types that make lanes on the Jobs board: one lane per option, per
14
+ // person, or yes and no.
15
+ export const GROUPABLE_TYPES = ['dropdown', 'person', 'checkbox'];
16
+ export const OPTION_COLORS = ['gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'];
17
+
18
+ const MAX_FIELDS = 50;
19
+ const MAX_OPTIONS = 50;
20
+ const MAX_NAME = 60;
21
+ const MAX_OPTION_NAME = 40;
22
+ const MAX_TEXT = 500;
23
+ const MAX_NUMBER = 1e12;
24
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
25
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+ const SYSTEM = /^[a-z][a-z0-9-]{1,31}$/;
27
+
28
+ const invalid = (message) => new FlowError('invalid', message);
29
+
30
+ function readName(raw, what, max) {
31
+ if (typeof raw !== 'string' || !raw.trim()) throw invalid(`${what} is required`);
32
+ const name = raw.trim().replace(/\s+/g, ' ');
33
+ if (name.length > max) throw invalid(`${what} is too long (${max} characters at most)`);
34
+ return name;
35
+ }
36
+
37
+ // A dropdown's options as a person sends them: [{ id?, name, color? }], in
38
+ // lane order. An option with no id is new. An earlier option that is not in
39
+ // the list is kept as archived, so its old values still read.
40
+ export function readOptions(raw, previous = []) {
41
+ if (!Array.isArray(raw)) throw invalid('options must be a list');
42
+ if (!raw.length) throw invalid('a dropdown needs at least one option');
43
+ if (raw.length > MAX_OPTIONS) throw invalid(`a dropdown has ${MAX_OPTIONS} options at most`);
44
+ const known = new Map(previous.map((o) => [o.id, o]));
45
+ const names = new Set();
46
+ const active = raw.map((option, i) => {
47
+ if (!option || typeof option !== 'object') throw invalid(`option ${i + 1} must be an object`);
48
+ const name = readName(option.name, `option ${i + 1} name`, MAX_OPTION_NAME);
49
+ const key = name.toLowerCase();
50
+ if (names.has(key)) throw invalid(`two options are named ${name}`);
51
+ names.add(key);
52
+ const color = option.color === undefined || option.color === null ? 'gray' : option.color;
53
+ if (!OPTION_COLORS.includes(color)) throw invalid(`option ${name}: color must be one of ${OPTION_COLORS.join(', ')}`);
54
+ if (option.id !== undefined && option.id !== null) {
55
+ if (!known.has(option.id)) throw invalid(`option ${name}: unknown id`);
56
+ return { id: option.id, name, color };
57
+ }
58
+ return { id: randomUUID(), name, color };
59
+ });
60
+ const kept = new Set(active.map((o) => o.id));
61
+ const archived = previous.filter((o) => !kept.has(o.id)).map((o) => ({ ...o, archived: true }));
62
+ return [...active, ...archived];
63
+ }
64
+
65
+ // A new field, or the part of one a person changes. The type is fixed once
66
+ // the field exists.
67
+ export function readField(raw, { partial = false, previous = null } = {}) {
68
+ const input = raw && typeof raw === 'object' ? raw : {};
69
+ const out = {};
70
+ if (!partial || input.name !== undefined) out.name = readName(input.name, 'name', MAX_NAME);
71
+ if (!partial) {
72
+ if (!CUSTOM_FIELD_TYPES.includes(input.type)) throw invalid(`type must be one of ${CUSTOM_FIELD_TYPES.join(', ')}`);
73
+ out.type = input.type;
74
+ } else if (input.type !== undefined && input.type !== previous?.type) {
75
+ throw invalid('the type of a field cannot change; make a new field instead');
76
+ }
77
+ const type = out.type ?? previous?.type;
78
+ if (type === 'dropdown' && (!partial || input.options !== undefined)) out.options = readOptions(input.options, previous?.options ?? []);
79
+ else if (type !== 'dropdown' && input.options !== undefined && input.options !== null && !(Array.isArray(input.options) && !input.options.length)) {
80
+ throw invalid('only a dropdown has options');
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export const publicField = (row) => ({
86
+ id: row.id,
87
+ name: row.name,
88
+ type: row.type,
89
+ options: row.options ?? [],
90
+ position: row.position,
91
+ groupable: GROUPABLE_TYPES.includes(row.type),
92
+ archived: Boolean(row.archivedAt),
93
+ createdBy: row.createdBy ?? null,
94
+ createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
95
+ updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
96
+ });
97
+
98
+ export async function listFields(db, organisationId, { archived = false } = {}) {
99
+ const where = [eq(jobFields.organisationId, organisationId)];
100
+ if (!archived) where.push(isNull(jobFields.archivedAt));
101
+ const rows = await db.select().from(jobFields).where(and(...where)).orderBy(asc(jobFields.position), asc(jobFields.createdAt));
102
+ return rows.map(publicField);
103
+ }
104
+
105
+ async function readRow(db, organisationId, id) {
106
+ if (!UUID.test(id ?? '')) throw notFound('field');
107
+ const [row] = await db.select().from(jobFields).where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id)));
108
+ if (!row) throw notFound('field');
109
+ return row;
110
+ }
111
+
112
+ async function checkNameFree(db, organisationId, name, exceptId = null) {
113
+ const rows = await db.select({ id: jobFields.id }).from(jobFields).where(and(
114
+ eq(jobFields.organisationId, organisationId), isNull(jobFields.archivedAt),
115
+ sql`lower(${jobFields.name}) = ${name.toLowerCase()}`,
116
+ ));
117
+ if (rows.some((r) => r.id !== exceptId)) throw new FlowError('conflict', `a field named ${name} already exists`);
118
+ }
119
+
120
+ export async function createField(db, organisationId, raw, { personId = null } = {}) {
121
+ const field = readField(raw);
122
+ return db.transaction(async (tx) => {
123
+ const [{ count, last }] = await tx.select({
124
+ count: sql`count(*) filter (where ${jobFields.archivedAt} is null)`.mapWith(Number),
125
+ last: sql`coalesce(max(${jobFields.position}), -1)`.mapWith(Number),
126
+ }).from(jobFields).where(eq(jobFields.organisationId, organisationId));
127
+ if (count >= MAX_FIELDS) throw invalid(`an organisation has ${MAX_FIELDS} fields at most`);
128
+ await checkNameFree(tx, organisationId, field.name);
129
+ const [row] = await tx.insert(jobFields).values({
130
+ organisationId, ...field, options: field.options ?? [], position: last + 1, createdBy: personId, updatedBy: personId,
131
+ }).returning();
132
+ return publicField(row);
133
+ });
134
+ }
135
+
136
+ export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
137
+ const previous = await readRow(db, organisationId, id);
138
+ if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
139
+ const change = readField(raw, { partial: true, previous });
140
+ if (change.name) await checkNameFree(db, organisationId, change.name, id);
141
+ const [row] = await db.update(jobFields).set({ ...change, updatedBy: personId, updatedAt: new Date() })
142
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id))).returning();
143
+ return publicField(row);
144
+ }
145
+
146
+ // Archive a field, or bring it back. Its values stay, so bringing it back
147
+ // restores them.
148
+ export async function archiveField(db, organisationId, id, { archived = true, personId = null } = {}) {
149
+ const previous = await readRow(db, organisationId, id);
150
+ if (!archived) await checkNameFree(db, organisationId, previous.name, id);
151
+ const [row] = await db.update(jobFields).set({ archivedAt: archived ? new Date() : null, updatedBy: personId, updatedAt: new Date() })
152
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id))).returning();
153
+ return publicField(row);
154
+ }
155
+
156
+ // Put the fields in this order. `ids` lists every field that is not archived.
157
+ export async function orderFields(db, organisationId, ids, { personId = null } = {}) {
158
+ if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw invalid('ids must be a list of field ids');
159
+ const current = await listFields(db, organisationId);
160
+ const want = new Set(ids);
161
+ if (want.size !== ids.length || current.length !== ids.length || current.some((f) => !want.has(f.id))) {
162
+ throw invalid('ids must list every field that is not archived, once each');
163
+ }
164
+ await db.transaction(async (tx) => {
165
+ for (const [position, id] of ids.entries()) {
166
+ await tx.update(jobFields).set({ position, updatedBy: personId, updatedAt: new Date() })
167
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id)));
168
+ }
169
+ });
170
+ return listFields(db, organisationId);
171
+ }
172
+
173
+ function readJob(raw) {
174
+ const sourceSystem = typeof raw?.sourceSystem === 'string' ? raw.sourceSystem.trim() : '';
175
+ const sourceJobRef = typeof raw?.sourceJobRef === 'string' ? raw.sourceJobRef.trim() : '';
176
+ if (!SYSTEM.test(sourceSystem)) throw invalid('sourceSystem must be an integration id');
177
+ if (!sourceJobRef || sourceJobRef.length > 200) throw invalid('sourceJobRef is required (200 characters at most)');
178
+ return { sourceSystem, sourceJobRef };
179
+ }
180
+
181
+ function isRealDate(value) {
182
+ if (typeof value !== 'string' || !DATE.test(value)) return false;
183
+ const d = new Date(`${value}T00:00:00Z`);
184
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
185
+ }
186
+
187
+ // A value checked against its field. null clears the value.
188
+ export async function readValue(db, field, raw) {
189
+ if (raw === null || raw === undefined || raw === '') return null;
190
+ switch (field.type) {
191
+ case 'dropdown': {
192
+ const option = (field.options ?? []).find((o) => o.id === raw && !o.archived);
193
+ if (!option) throw invalid(`${field.name}: choose one of its options`);
194
+ return option.id;
195
+ }
196
+ case 'person': {
197
+ if (typeof raw !== 'string' || !UUID.test(raw)) throw invalid(`${field.name}: choose a person`);
198
+ const [row] = await db.select({ id: people.id }).from(people).where(and(eq(people.id, raw), eq(people.status, 'active')));
199
+ if (!row) throw invalid(`${field.name}: that person cannot be chosen`);
200
+ return row.id;
201
+ }
202
+ case 'checkbox':
203
+ if (typeof raw !== 'boolean') throw invalid(`${field.name} must be true or false`);
204
+ return raw;
205
+ case 'date':
206
+ if (!isRealDate(raw)) throw invalid(`${field.name} must be a date, YYYY-MM-DD`);
207
+ return raw;
208
+ case 'number':
209
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || Math.abs(raw) >= MAX_NUMBER) throw invalid(`${field.name} must be a number`);
210
+ return raw;
211
+ default:
212
+ if (typeof raw !== 'string') throw invalid(`${field.name} must be text`);
213
+ if (raw.length > MAX_TEXT) throw invalid(`${field.name} is too long (${MAX_TEXT} characters at most)`);
214
+ return raw.trim() || null;
215
+ }
216
+ }
217
+
218
+ const publicValue = (row) => ({
219
+ fieldId: row.fieldId,
220
+ sourceSystem: row.sourceSystem,
221
+ sourceJobRef: row.sourceJobRef,
222
+ value: row.value,
223
+ updatedBy: row.updatedBy ?? null,
224
+ updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
225
+ });
226
+
227
+ // Set one job's value for one field, as a drag between lanes does.
228
+ // `expectedUpdatedAt` is the updatedAt of the value the person last saw
229
+ // (null when they saw none). When someone changed it since, nothing changes
230
+ // and the error carries the current value, so the board can show it.
231
+ export async function setJobFieldValue(db, organisationId, raw, { personId = null } = {}) {
232
+ const job = readJob(raw);
233
+ const field = await readRow(db, organisationId, raw?.fieldId);
234
+ if (field.archivedAt) throw new FlowError('conflict', 'the field is archived');
235
+ const value = await readValue(db, field, raw?.value);
236
+ const checkVersion = raw && Object.hasOwn(raw, 'expectedUpdatedAt');
237
+ const expected = checkVersion && raw.expectedUpdatedAt ? new Date(raw.expectedUpdatedAt) : null;
238
+ if (expected && Number.isNaN(expected.getTime())) throw invalid('expectedUpdatedAt must be a time');
239
+ const key = and(
240
+ eq(jobFieldValues.organisationId, organisationId), eq(jobFieldValues.fieldId, field.id),
241
+ eq(jobFieldValues.sourceSystem, job.sourceSystem), eq(jobFieldValues.sourceJobRef, job.sourceJobRef),
242
+ );
243
+ return db.transaction(async (tx) => {
244
+ // One change at a time per job and field, also while no row exists yet.
245
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${organisationId}:${field.id}:${job.sourceSystem}:${job.sourceJobRef}`}))`);
246
+ const [current] = await tx.select().from(jobFieldValues).where(key);
247
+ if (checkVersion) {
248
+ const seen = current?.updatedAt?.getTime() ?? null;
249
+ if (seen !== (expected?.getTime() ?? null)) {
250
+ throw new FlowError('conflict', 'someone changed this value since you saw it', { current: current ? publicValue(current) : null });
251
+ }
252
+ }
253
+ const from = current?.value ?? null;
254
+ if (JSON.stringify(from) === JSON.stringify(value)) return current ? publicValue(current) : { fieldId: field.id, ...job, value: null, updatedBy: null, updatedAt: null };
255
+ let saved = null;
256
+ if (value === null) {
257
+ await tx.delete(jobFieldValues).where(key);
258
+ } else {
259
+ [saved] = await tx.insert(jobFieldValues).values({ organisationId, fieldId: field.id, ...job, value, updatedBy: personId, updatedAt: new Date() })
260
+ .onConflictDoUpdate({
261
+ target: [jobFieldValues.organisationId, jobFieldValues.fieldId, jobFieldValues.sourceSystem, jobFieldValues.sourceJobRef],
262
+ set: { value, updatedBy: personId, updatedAt: new Date() },
263
+ }).returning();
264
+ }
265
+ await tx.insert(jobFieldEvents).values({ organisationId, fieldId: field.id, ...job, fromValue: from, toValue: value, actor: personId });
266
+ return saved ? publicValue(saved) : { fieldId: field.id, ...job, value: null, updatedBy: personId, updatedAt: null };
267
+ });
268
+ }
269
+
270
+ // Every value of the fields that are not archived, optionally for some jobs
271
+ // only (`jobs`: [{ sourceSystem, sourceJobRef }]).
272
+ export async function listJobFieldValues(db, organisationId, { jobs = null } = {}) {
273
+ const where = [eq(jobFieldValues.organisationId, organisationId), isNull(jobFields.archivedAt)];
274
+ if (jobs) {
275
+ if (!jobs.length) return [];
276
+ const refs = [...new Set(jobs.map((j) => j.sourceJobRef))];
277
+ where.push(inArray(jobFieldValues.sourceJobRef, refs));
278
+ }
279
+ const rows = await db.select({
280
+ fieldId: jobFieldValues.fieldId, sourceSystem: jobFieldValues.sourceSystem, sourceJobRef: jobFieldValues.sourceJobRef,
281
+ value: jobFieldValues.value, updatedBy: jobFieldValues.updatedBy, updatedAt: jobFieldValues.updatedAt,
282
+ }).from(jobFieldValues)
283
+ .innerJoin(jobFields, and(eq(jobFields.organisationId, jobFieldValues.organisationId), eq(jobFields.id, jobFieldValues.fieldId)))
284
+ .where(and(...where));
285
+ const wanted = jobs ? new Set(jobs.map((j) => `${j.sourceSystem}\u0000${j.sourceJobRef}`)) : null;
286
+ return rows.filter((r) => !wanted || wanted.has(`${r.sourceSystem}\u0000${r.sourceJobRef}`)).map(publicValue);
287
+ }
288
+
289
+ // The people a person field can name: every active person, by id and name
290
+ // (the part of the email before @ when they have no name), A to Z.
291
+ export async function choosablePeople(db) {
292
+ const rows = await db.select({ id: people.id, name: people.name, email: people.email }).from(people).where(eq(people.status, 'active'));
293
+ return rows.map((r) => ({ id: r.id, name: r.name?.trim() || r.email.replace(/@.*/, '') }))
294
+ .sort((a, b) => a.name.localeCompare(b.name));
295
+ }
296
+
297
+ // What happened to one job's custom field values, newest first.
298
+ export async function jobFieldHistory(db, organisationId, raw, { limit = 50 } = {}) {
299
+ const job = readJob(raw);
300
+ const rows = await db.select().from(jobFieldEvents).where(and(
301
+ eq(jobFieldEvents.organisationId, organisationId),
302
+ eq(jobFieldEvents.sourceSystem, job.sourceSystem), eq(jobFieldEvents.sourceJobRef, job.sourceJobRef),
303
+ )).orderBy(sql`${jobFieldEvents.createdAt} desc`).limit(limit);
304
+ return rows.map((r) => ({
305
+ fieldId: r.fieldId, from: r.fromValue, to: r.toValue, actor: r.actor ?? null,
306
+ at: r.createdAt?.toISOString?.() ?? r.createdAt,
307
+ }));
308
+ }
@@ -136,6 +136,10 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
136
136
  totalCents: o?.totalCents ?? item.totalCents ?? null,
137
137
  lines: o ? ledgerLines(o.lines)
138
138
  : (item.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null, mapped: Boolean(l.code && codes.has(l.code)) })),
139
+ // A stage move the work system has not made yet (moveWork).
140
+ pendingMove: item.pendingMove ?? null,
141
+ // The link to the job in the work system's own web app ({ url, exact }).
142
+ link: item.link ?? null,
139
143
  obligation: o,
140
144
  });
141
145
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import {
4
- billingObligations, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
4
+ billingObligations, invoices, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
7
  import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
@@ -325,9 +325,16 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
325
325
  ));
326
326
  } else changes[field] = obligation[field];
327
327
  }
328
- const invoice = Object.keys(changes).length
328
+ let invoice = Object.keys(changes).length
329
329
  ? await updateDraftInvoice(tx, organisationId, obligation.invoiceId, changes)
330
330
  : await getInvoice(tx, organisationId, obligation.invoiceId);
331
+ // The draft now matches a job that is ready to invoice, so it goes to
332
+ // Ready on its own; Flow's checks still hold it in Problem if one fails.
333
+ if (obligation.readyAt && !invoice.readyAt) {
334
+ await tx.update(invoices).set({ readyAt: sql`now()`, readyBy: null })
335
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoice.id), eq(invoices.status, 'draft')));
336
+ invoice = await getInvoice(tx, organisationId, invoice.id);
337
+ }
331
338
  const fields = { draftBasis: basisOf(obligation) };
332
339
  if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
333
340
  const row = await update(tx, obligation, fields);
@@ -337,6 +344,79 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
337
344
  });
338
345
  }
339
346
 
347
+ // What changed in a job after its invoice was made, for the invoice page:
348
+ // each line as it was (the draft basis) and as the work system has it now.
349
+ // Lines pair by price item, else item code, account or description; an
350
+ // unpaired old line and an unpaired new line at the same place pair as a
351
+ // replaced item. Answers [{ change, position, was, now, fields }], where
352
+ // `change` is changed, replaced, added or removed and `fields` names what
353
+ // differs on a changed or replaced line (item, description, quantity, price).
354
+ export function diffJobLines(wasLines = [], nowLines = []) {
355
+ const keyOf = (l) => l.priceItemId ?? l.itemCode ?? l.accountCode ?? String(l.description ?? '').trim().toLowerCase();
356
+ const show = (l) => ({
357
+ code: l.itemCode ?? l.accountCode ?? null, description: l.description ?? '', quantity: Number(l.quantity), unitAmountCents: l.unitAmountCents ?? null,
358
+ });
359
+ const differs = (a, b) => [
360
+ keyOf(a) !== keyOf(b) ? 'item' : null,
361
+ String(a.description ?? '').trim() !== String(b.description ?? '').trim() && keyOf(a) === keyOf(b) ? 'description' : null,
362
+ Number(a.quantity) !== Number(b.quantity) ? 'quantity' : null,
363
+ (a.unitAmountCents ?? null) !== (b.unitAmountCents ?? null) ? 'price' : null,
364
+ ].filter(Boolean);
365
+ const wasLeft = wasLines.map((line, index) => ({ line, index }));
366
+ const pairs = [];
367
+ const added = [];
368
+ nowLines.forEach((line, index) => {
369
+ const at = wasLeft.findIndex((w) => keyOf(w.line) === keyOf(line));
370
+ if (at === -1) { added.push({ line, index }); return; }
371
+ pairs.push({ was: wasLeft.splice(at, 1)[0], now: { line, index } });
372
+ });
373
+ // What is left over: an old line and a new line in the same place are one
374
+ // replaced item; the rest were added or removed.
375
+ for (const now of [...added]) {
376
+ const at = wasLeft.findIndex((w) => w.index === now.index);
377
+ if (at === -1) continue;
378
+ pairs.push({ was: wasLeft.splice(at, 1)[0], now });
379
+ added.splice(added.indexOf(now), 1);
380
+ }
381
+ const changes = [];
382
+ for (const { was, now } of pairs) {
383
+ const fields = differs(was.line, now.line);
384
+ if (!fields.length) continue;
385
+ changes.push({ change: fields.includes('item') ? 'replaced' : 'changed', position: now.index + 1, was: show(was.line), now: show(now.line), fields });
386
+ }
387
+ for (const { line, index } of added) changes.push({ change: 'added', position: index + 1, was: null, now: show(line), fields: [] });
388
+ for (const { line, index } of wasLeft) changes.push({ change: 'removed', position: index + 1, was: show(line), now: null, fields: [] });
389
+ return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
390
+ }
391
+
392
+ // The open "job changed" problem of an invoice, for the invoice page, or
393
+ // null: the job as it was when the invoice was made, as the work system has
394
+ // it now, and whether a person can adopt the change into Flow's own draft
395
+ // ("Adopt changes", useSourceLines). An invoice drafted before the basis was
396
+ // kept compares with the invoice's own lines.
397
+ export async function jobChangeFor(db, organisationId, invoiceId) {
398
+ const [obligation] = await db.select().from(billingObligations)
399
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId),
400
+ eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
401
+ if (!obligation) return null;
402
+ const compare = await compareWithDraft(db, organisationId, obligation, obligation);
403
+ const was = obligation.draftBasis
404
+ ?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
405
+ return {
406
+ jobId: obligation.id,
407
+ sourceSystem: obligation.sourceSystem,
408
+ sourceJobRef: obligation.sourceJobRef,
409
+ sourceInvoiceRef: obligation.sourceInvoiceRef,
410
+ seenAt: obligation.sourceSeenAt,
411
+ outcome: compare.outcome,
412
+ sent: compare.sent,
413
+ canAdopt: compare.outcome === 'offer',
414
+ lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
415
+ contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
416
+ total: { was: was.totalCents ?? null, now: obligation.totalCents },
417
+ };
418
+ }
419
+
340
420
  // Records that a job the ledger knows is no longer billable in the work
341
421
  // system: deleted there, or its status moved back (for example from Shipped
342
422
  // to In production). Flow writes nothing to another system:
@@ -0,0 +1,64 @@
1
+ import { routePattern } from '../ui/monitoring.js';
2
+
3
+ // Performance traces for the installation's own tracing service
4
+ // (docs/SENTRY.md, "Traces"). The first integration that is on and offers the
5
+ // `traceSpan` and `traceRequest` capabilities gets them. With none on, each
6
+ // function here runs the work as it is and adds nothing.
7
+ //
8
+ // `tracers()` answers the adapters to trace with; it is read at each call,
9
+ // so an integration turned on or off later is followed.
10
+ export function createTracing({ tracers = () => [] } = {}) {
11
+ const first = () => tracers()[0]?.capabilities ?? null;
12
+ return {
13
+ // Runs `work` inside a span named `name`, of kind `op`.
14
+ span(name, op, work, attributes = {}) {
15
+ const tracer = first();
16
+ return tracer ? tracer.traceSpan({ name, op, attributes }, work) : work();
17
+ },
18
+ // Express middleware: names each request's trace by its route pattern
19
+ // ("GET /api/invoicing/invoices/:id"), never by its URL, so the same
20
+ // click on another invoice is the same name.
21
+ requests() {
22
+ return (req, res, next) => {
23
+ const rename = first()?.traceRequest() ?? null;
24
+ if (rename) res.once('finish', () => rename(requestName(req)));
25
+ next();
26
+ };
27
+ },
28
+ };
29
+ }
30
+
31
+ // The route Express matched, else the path with its ids as ":id".
32
+ export function requestName(req) {
33
+ const route = typeof req.route?.path === 'string' ? `${req.baseUrl ?? ''}${req.route.path}` : null;
34
+ return `${req.method} ${route ?? routePattern(req.originalUrl ?? req.url)}`;
35
+ }
36
+
37
+ // A span for each database query the pool runs. The span's name is the SQL
38
+ // with its values left out: Flow passes values as parameters, and any quoted
39
+ // text or number in the SQL itself becomes "?".
40
+ export function traceQueries(pool, tracing) {
41
+ const wrap = (target) => {
42
+ const query = target.query.bind(target);
43
+ target.query = (...args) => {
44
+ // Callback calls are pg's own (the pool's query runs through a
45
+ // client's): the promise call around them already has its span.
46
+ if (typeof args.at(-1) === 'function') return query(...args);
47
+ return tracing.span(sqlName(args[0]), 'db', () => query(...args), { 'db.system': 'postgresql', 'db.system.name': 'postgresql' });
48
+ };
49
+ };
50
+ wrap(pool);
51
+ // A client a transaction holds (pool.connect()) queries directly.
52
+ pool.on('connect', wrap);
53
+ return pool;
54
+ }
55
+
56
+ export function sqlName(statement) {
57
+ const text = typeof statement === 'string' ? statement : String(statement?.text ?? '');
58
+ return text
59
+ .replace(/'(?:[^']|'')*'/g, '?')
60
+ .replace(/(?<![\w$])\d+(\.\d+)?\b/g, '?')
61
+ .replace(/\s+/g, ' ')
62
+ .trim()
63
+ .slice(0, 300) || 'query';
64
+ }
@@ -0,0 +1,78 @@
1
+ CREATE TABLE IF NOT EXISTS "job_field_events" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "field_id" uuid NOT NULL,
5
+ "source_system" text NOT NULL,
6
+ "source_job_ref" text NOT NULL,
7
+ "from_value" jsonb,
8
+ "to_value" jsonb,
9
+ "actor" uuid,
10
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE TABLE IF NOT EXISTS "job_field_values" (
14
+ "organisation_id" uuid NOT NULL,
15
+ "field_id" uuid NOT NULL,
16
+ "source_system" text NOT NULL,
17
+ "source_job_ref" text NOT NULL,
18
+ "value" jsonb NOT NULL,
19
+ "updated_by" uuid,
20
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
21
+ CONSTRAINT "job_field_values_organisation_id_field_id_source_system_source_job_ref_pk" PRIMARY KEY("organisation_id","field_id","source_system","source_job_ref")
22
+ );
23
+ --> statement-breakpoint
24
+ CREATE TABLE IF NOT EXISTS "job_fields" (
25
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
26
+ "organisation_id" uuid NOT NULL,
27
+ "name" text NOT NULL,
28
+ "type" text NOT NULL,
29
+ "options" jsonb DEFAULT '[]'::jsonb NOT NULL,
30
+ "position" integer NOT NULL,
31
+ "archived_at" timestamp with time zone,
32
+ "created_by" uuid,
33
+ "updated_by" uuid,
34
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
35
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
36
+ CONSTRAINT "job_fields_org_id" UNIQUE("organisation_id","id"),
37
+ CONSTRAINT "job_fields_type_valid" CHECK ("job_fields"."type" in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')),
38
+ CONSTRAINT "job_fields_name_valid" CHECK (length("job_fields"."name") between 1 and 60)
39
+ );
40
+ --> statement-breakpoint
41
+ DO $flow_guard$ BEGIN
42
+ ALTER TABLE "job_field_events" ADD CONSTRAINT "job_field_events_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
43
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
44
+ END $flow_guard$;--> statement-breakpoint
45
+ DO $flow_guard$ BEGIN
46
+ ALTER TABLE "job_field_events" ADD CONSTRAINT "job_field_events_actor_people_id_fk" FOREIGN KEY ("actor") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
47
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
48
+ END $flow_guard$;--> statement-breakpoint
49
+ DO $flow_guard$ BEGIN
50
+ ALTER TABLE "job_field_events" ADD CONSTRAINT "job_field_events_field_same_org" FOREIGN KEY ("organisation_id","field_id") REFERENCES "public"."job_fields"("organisation_id","id") ON DELETE cascade ON UPDATE no action;
51
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
52
+ END $flow_guard$;--> statement-breakpoint
53
+ DO $flow_guard$ BEGIN
54
+ ALTER TABLE "job_field_values" ADD CONSTRAINT "job_field_values_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
55
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
56
+ END $flow_guard$;--> statement-breakpoint
57
+ DO $flow_guard$ BEGIN
58
+ ALTER TABLE "job_field_values" ADD CONSTRAINT "job_field_values_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
59
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
60
+ END $flow_guard$;--> statement-breakpoint
61
+ DO $flow_guard$ BEGIN
62
+ ALTER TABLE "job_field_values" ADD CONSTRAINT "job_field_values_field_same_org" FOREIGN KEY ("organisation_id","field_id") REFERENCES "public"."job_fields"("organisation_id","id") ON DELETE cascade ON UPDATE no action;
63
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
64
+ END $flow_guard$;--> statement-breakpoint
65
+ DO $flow_guard$ BEGIN
66
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
67
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
68
+ END $flow_guard$;--> statement-breakpoint
69
+ DO $flow_guard$ BEGIN
70
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_created_by_people_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
71
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
72
+ END $flow_guard$;--> statement-breakpoint
73
+ DO $flow_guard$ BEGIN
74
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
75
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
76
+ END $flow_guard$;--> statement-breakpoint
77
+ CREATE INDEX IF NOT EXISTS "job_field_events_job" ON "job_field_events" USING btree ("organisation_id","source_system","source_job_ref","created_at");--> statement-breakpoint
78
+ CREATE INDEX IF NOT EXISTS "job_field_values_job" ON "job_field_values" USING btree ("organisation_id","source_system","source_job_ref");
@@ -0,0 +1 @@
1
+ ALTER TABLE "people" ADD COLUMN IF NOT EXISTS "views" jsonb;