@arcforgelabs/flow 2026.10.38 → 2026.10.39

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 (45) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +148 -0
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/ui/app.js +35 -3
  33. package/server/ui/custom-fields.js +252 -94
  34. package/server/ui/flow.css +185 -29
  35. package/server/ui/invoice-notes.js +2 -2
  36. package/server/ui/invoicing.js +92 -137
  37. package/server/ui/job-board.js +1 -1
  38. package/server/ui/job-change.js +23 -12
  39. package/server/ui/job-view.js +168 -33
  40. package/server/ui/jobs.js +316 -65
  41. package/server/ui/price-list.js +43 -86
  42. package/server/ui/price-match.js +93 -0
  43. package/server/ui/price-reconcile.js +357 -0
  44. package/server/ui/reconcile.js +1 -1
  45. package/server/ui/routes.js +1 -1
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
3
- import { CUSTOM_FIELD_TYPES, jobFieldEvents, jobFields, jobFieldValues, people } from '../db/schema.js';
3
+ import {
4
+ CUSTOM_FIELD_TYPES, integrationSettingEvents, integrationSettings, jobFieldEvents, jobFields, jobFieldSettings, jobFieldValues, people,
5
+ } from '../db/schema.js';
4
6
  import { FlowError, notFound } from './errors.js';
5
7
 
6
8
  // Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
@@ -14,6 +16,14 @@ export { CUSTOM_FIELD_TYPES };
14
16
  // person, or yes and no.
15
17
  export const GROUPABLE_TYPES = ['dropdown', 'person', 'checkbox'];
16
18
  export const OPTION_COLORS = ['gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'];
19
+ // Each type group's own colour, until the lab picks another.
20
+ export const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
21
+
22
+ function readColor(raw, what) {
23
+ if (raw === null) return null;
24
+ if (!OPTION_COLORS.includes(raw)) throw invalid(`${what} must be one of ${OPTION_COLORS.join(', ')}`);
25
+ return raw;
26
+ }
17
27
 
18
28
  const MAX_FIELDS = 50;
19
29
  const MAX_OPTIONS = 50;
@@ -74,6 +84,7 @@ export function readField(raw, { partial = false, previous = null } = {}) {
74
84
  } else if (input.type !== undefined && input.type !== previous?.type) {
75
85
  throw invalid('the type of a field cannot change; make a new field instead');
76
86
  }
87
+ if (input.color !== undefined) out.color = readColor(input.color, 'color');
77
88
  const type = out.type ?? previous?.type;
78
89
  if (type === 'dropdown' && (!partial || input.options !== undefined)) out.options = readOptions(input.options, previous?.options ?? []);
79
90
  else if (type !== 'dropdown' && input.options !== undefined && input.options !== null && !(Array.isArray(input.options) && !input.options.length)) {
@@ -87,6 +98,7 @@ export const publicField = (row) => ({
87
98
  name: row.name,
88
99
  type: row.type,
89
100
  options: row.options ?? [],
101
+ color: row.color ?? null,
90
102
  position: row.position,
91
103
  groupable: GROUPABLE_TYPES.includes(row.type),
92
104
  archived: Boolean(row.archivedAt),
@@ -95,11 +107,15 @@ export const publicField = (row) => ({
95
107
  updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
96
108
  });
97
109
 
110
+ // `valueCount` is how many jobs have a value, so Delete can say what goes.
98
111
  export async function listFields(db, organisationId, { archived = false } = {}) {
99
112
  const where = [eq(jobFields.organisationId, organisationId)];
100
113
  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);
114
+ const rows = await db.select({
115
+ field: jobFields,
116
+ valueCount: sql`(select count(*) from ${jobFieldValues} v where v.organisation_id = ${jobFields.organisationId} and v.field_id = ${jobFields.id})`.mapWith(Number),
117
+ }).from(jobFields).where(and(...where)).orderBy(asc(jobFields.position), asc(jobFields.createdAt));
118
+ return rows.map(({ field, valueCount }) => ({ ...publicField(field), valueCount }));
103
119
  }
104
120
 
105
121
  async function readRow(db, organisationId, id) {
@@ -135,11 +151,20 @@ export async function createField(db, organisationId, raw, { personId = null } =
135
151
 
136
152
  // Every lab gets an Assignee: a person field, made once, the first time
137
153
  // the lab's fields are read and it has no person field at all (live or
138
- // archived). The lab can then rename it or archive it like any field, and
139
- // Flow does not make it again. A field already named Assignee of another
140
- // type is left alone.
154
+ // archived). The lab can then rename, archive or delete it like any field,
155
+ // and Flow does not make it again: job_field_settings.assignee_made_at
156
+ // records that it was made (or that the lab already had a person field).
157
+ // A field already named Assignee of another type is left alone.
141
158
  export const ASSIGNEE = { name: 'Assignee', type: 'person' };
142
159
  export async function ensureAssignee(db, organisationId) {
160
+ const [settings] = await db.select({ at: jobFieldSettings.assigneeMadeAt }).from(jobFieldSettings)
161
+ .where(eq(jobFieldSettings.organisationId, organisationId));
162
+ if (settings?.at) return null;
163
+ // Only the request that marks it makes it.
164
+ const [marked] = await db.insert(jobFieldSettings).values({ organisationId, assigneeMadeAt: new Date() })
165
+ .onConflictDoUpdate({ target: jobFieldSettings.organisationId, set: { assigneeMadeAt: new Date() }, setWhere: isNull(jobFieldSettings.assigneeMadeAt) })
166
+ .returning({ organisationId: jobFieldSettings.organisationId });
167
+ if (!marked) return null;
143
168
  const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFields)
144
169
  .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.type, 'person')));
145
170
  if (count > 0) return null;
@@ -151,6 +176,39 @@ export async function ensureAssignee(db, organisationId) {
151
176
  }
152
177
  }
153
178
 
179
+ // Each type group's colour: TYPE_COLORS, with the lab's own choices on top.
180
+ export async function readGroupColors(db, organisationId) {
181
+ const [row] = await db.select({ colors: jobFieldSettings.groupColors }).from(jobFieldSettings)
182
+ .where(eq(jobFieldSettings.organisationId, organisationId));
183
+ const own = row?.colors ?? {};
184
+ return Object.fromEntries(CUSTOM_FIELD_TYPES.map((t) => [t, OPTION_COLORS.includes(own[t]) ? own[t] : TYPE_COLORS[t]]));
185
+ }
186
+
187
+ // Give one type group a colour. null puts back its own colour.
188
+ export async function setGroupColor(db, organisationId, type, raw, { personId = null } = {}) {
189
+ if (!CUSTOM_FIELD_TYPES.includes(type)) throw notFound('group');
190
+ const color = raw === undefined ? undefined : readColor(raw, 'color');
191
+ if (color === undefined) throw invalid('color is required (null puts back the group\'s own colour)');
192
+ const own = { ...(await db.select({ colors: jobFieldSettings.groupColors }).from(jobFieldSettings)
193
+ .where(eq(jobFieldSettings.organisationId, organisationId)))[0]?.colors };
194
+ if (color === null || color === TYPE_COLORS[type]) delete own[type]; else own[type] = color;
195
+ await db.insert(jobFieldSettings).values({ organisationId, groupColors: own, updatedBy: personId })
196
+ .onConflictDoUpdate({ target: jobFieldSettings.organisationId, set: { groupColors: own, updatedBy: personId, updatedAt: new Date() } });
197
+ return readGroupColors(db, organisationId);
198
+ }
199
+
200
+ // When an integration was connected: the first time its settings were saved
201
+ // or it was turned on in Flow, or, for one set up in the host's own
202
+ // settings, the first time Flow saw one of its records (`seenSince`).
203
+ export async function integrationConnectedAt(db, adapterId, seenSince = null) {
204
+ const [[saved], [event]] = await Promise.all([
205
+ db.select({ at: sql`min(${integrationSettings.createdAt})` }).from(integrationSettings).where(eq(integrationSettings.adapterId, adapterId)),
206
+ db.select({ at: sql`min(${integrationSettingEvents.createdAt})` }).from(integrationSettingEvents).where(eq(integrationSettingEvents.adapterId, adapterId)),
207
+ ]);
208
+ const times = [saved?.at, event?.at, seenSince].filter(Boolean).map((t) => new Date(t)).filter((t) => !Number.isNaN(t.getTime()));
209
+ return times.length ? new Date(Math.min(...times)).toISOString() : null;
210
+ }
211
+
154
212
  export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
155
213
  const previous = await readRow(db, organisationId, id);
156
214
  if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
@@ -171,6 +229,16 @@ export async function archiveField(db, organisationId, id, { archived = true, pe
171
229
  return publicField(row);
172
230
  }
173
231
 
232
+ // Delete a field for good, with every job's value and its history (the
233
+ // tables cascade). Archive is the step that keeps them.
234
+ export async function deleteField(db, organisationId, id) {
235
+ const field = await readRow(db, organisationId, id);
236
+ const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFieldValues)
237
+ .where(and(eq(jobFieldValues.organisationId, organisationId), eq(jobFieldValues.fieldId, field.id)));
238
+ await db.delete(jobFields).where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, field.id)));
239
+ return { id: field.id, name: field.name, valuesDeleted: count };
240
+ }
241
+
174
242
  // Put the fields in this order. `ids` lists every field that is not archived.
175
243
  export async function orderFields(db, organisationId, ids, { personId = null } = {}) {
176
244
  if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw invalid('ids must be a list of field ids');
@@ -305,20 +305,21 @@ function changedReview(obligation, compare) {
305
305
 
306
306
  // "Use new lines": a person takes the work system's new lines (and its
307
307
  // customer, when that changed) into Flow's own unsent draft. Flow writes only
308
- // its own draft, never another system. Refused when the draft changed too,
309
- // or has left Flow: those stay alerts for a person.
310
- export async function useSourceLines(db, organisationId, obligationId, { actor }) {
308
+ // its own draft, never another system. Refused when the invoice has left
309
+ // Flow. When the draft changed too, `accept` (a person's "Accept change")
310
+ // takes the work system's value over the draft's; without it, refused.
311
+ export async function useSourceLines(db, organisationId, obligationId, { actor, accept = false }) {
311
312
  requireActor(actor);
312
313
  return db.transaction(async (tx) => {
313
314
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
314
315
  if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
315
316
  const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
316
- if (compare.outcome === 'conflict') {
317
- throw new FlowError('conflict', compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
317
+ if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
318
+ throw new FlowError('conflict', compare.missing ? 'the invoice was not found' : compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
318
319
  }
319
320
  const changes = {};
320
321
  for (const { field, changedIn } of compare.fields) {
321
- if (changedIn !== 'work') continue;
322
+ if (changedIn !== 'work' && !(accept && changedIn === 'both')) continue;
322
323
  if (field === 'lines') {
323
324
  changes.lines = obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
324
325
  { priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
@@ -338,12 +339,30 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
338
339
  const fields = { draftBasis: basisOf(obligation) };
339
340
  if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
340
341
  const row = await update(tx, obligation, fields);
341
- await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents });
342
+ await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents, ...(accept ? { accepted: compare.outcome } : {}) });
342
343
  await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'used_new_lines', actor });
343
344
  return { obligation: row, invoice };
344
345
  });
345
346
  }
346
347
 
348
+ // After a person rechecks a job (POST /jobs/:id/recheck): a "job changed"
349
+ // hold whose job now matches the draft again (the work system was fixed, or
350
+ // the draft was) is over. Answers the obligation.
351
+ export async function settleJobChange(db, organisationId, obligationId, { actor }) {
352
+ requireActor(actor);
353
+ return db.transaction(async (tx) => {
354
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
355
+ if (!obligation.invoiceId || obligation.holdState !== 'review' || obligation.holdReason !== CHANGED_REASON) return obligation;
356
+ const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
357
+ const settled = compare.fields.every(({ changedIn }) => ['none', 'same', 'flow'].includes(changedIn)) && !compare.missing;
358
+ if (!settled) return obligation;
359
+ const row = await update(tx, obligation, { holdState: 'none', holdReason: null, draftBasis: basisOf(obligation) });
360
+ await addEvent(tx, row, 'review_settled', actor, { invoiceId: obligation.invoiceId });
361
+ await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'job_matches_invoice', actor });
362
+ return row;
363
+ });
364
+ }
365
+
347
366
  // What changed in a job after its invoice was made, for the invoice page:
348
367
  // each line as it was (the draft basis) and as the work system has it now.
349
368
  // Lines pair by price item, else item code, account or description; an
@@ -411,6 +430,9 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
411
430
  outcome: compare.outcome,
412
431
  sent: compare.sent,
413
432
  canAdopt: compare.outcome === 'offer',
433
+ // "Accept change": the draft takes the work system's values, even when
434
+ // the draft changed too. Not once the invoice has left Flow.
435
+ canAccept: !compare.sent && !compare.missing,
414
436
  lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
415
437
  contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
416
438
  total: { was: was.totalCents ?? null, now: obligation.totalCents },
@@ -174,13 +174,13 @@ export async function linkByCode(db, organisationId, catalogue, { items = null,
174
174
  // A person's link: `externalId` in `system`'s list goes to this price item.
175
175
  // For an accounting system it replaces the item's earlier link; a record
176
176
  // linked elsewhere moves here.
177
- export async function linkPriceItem(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId = null }) {
177
+ async function writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId }) {
178
178
  const item = await loadItem(db, organisationId, priceItemId);
179
179
  const catalogue = catalogues.get(system);
180
180
  if (!catalogue) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
181
181
  const record = catalogue.byId.get(String(externalId ?? ''));
182
182
  if (!record) throw notFound(`${catalogue.name} record`);
183
- const link = await db.transaction(async (tx) => {
183
+ return db.transaction(async (tx) => {
184
184
  await tx.delete(priceItemLinks).where(and(
185
185
  eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.externalId, String(record.id)),
186
186
  ));
@@ -194,10 +194,35 @@ export async function linkPriceItem(db, organisationId, priceItemId, { system, e
194
194
  }).returning();
195
195
  return row;
196
196
  });
197
+ }
198
+
199
+ export async function linkPriceItem(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId = null }) {
200
+ const link = await writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId });
197
201
  await remapJobs(db, organisationId);
198
202
  return link;
199
203
  }
200
204
 
205
+ // Many links at once (Reconcile prices, "Link sure matches"): each one as
206
+ // linkPriceItem makes it, then one remap. A link that fails stops the rest;
207
+ // the ones before it stay.
208
+ export const MAX_LINKS_AT_ONCE = 500;
209
+ export async function linkPriceItems(db, organisationId, links, { catalogues, personId = null }) {
210
+ if (!Array.isArray(links) || !links.length) throw invalid('links must be a list of { priceItemId, system, externalId }');
211
+ if (links.length > MAX_LINKS_AT_ONCE) throw invalid(`${MAX_LINKS_AT_ONCE} links at once at most`);
212
+ const made = [];
213
+ try {
214
+ for (const link of links) {
215
+ if (!link || typeof link !== 'object' || !link.priceItemId || !link.system || !link.externalId) {
216
+ throw invalid('each link needs priceItemId, system and externalId');
217
+ }
218
+ made.push(await writeLink(db, organisationId, link.priceItemId, link, { catalogues, personId }));
219
+ }
220
+ } finally {
221
+ if (made.length) await remapJobs(db, organisationId);
222
+ }
223
+ return made;
224
+ }
225
+
201
226
  export async function unlinkPriceItem(db, organisationId, linkId) {
202
227
  const rows = await db.delete(priceItemLinks)
203
228
  .where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.id, linkId))).returning();
@@ -30,11 +30,12 @@ const NO_LONGER_BILLABLE = 'the work system no longer reports the job as billabl
30
30
  // Answers Map(obligationId -> { outcome, obligation, reason? }), where
31
31
  // outcome is fresh (no read needed), read, review (sent to review) or
32
32
  // waiting (the read could not run).
33
- export async function readBeforeAct(db, organisationId, obligations, { work, now = new Date(), actor }) {
33
+ // `force` reads even a fresh report (a person's "Recheck").
34
+ export async function readBeforeAct(db, organisationId, obligations, { work, now = new Date(), actor, force = false }) {
34
35
  const outcomes = new Map();
35
36
  let toJob = null;
36
37
  for (const obligation of obligations) {
37
- if (!needsRead(obligation, { work, now })) {
38
+ if (!needsRead(obligation, { work, now: force ? new Date(8.64e15) : now })) {
38
39
  outcomes.set(obligation.id, { outcome: 'fresh', obligation });
39
40
  continue;
40
41
  }
@@ -3,19 +3,19 @@ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
5
  import { issueInvoice } from './invoices.js';
6
+ import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
6
7
  import { soleOrganisation } from './pipeline.js';
7
8
  import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
8
9
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
9
10
 
10
- // Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
11
+ // Pending, Ready and Send all ready (docs/INVOICE_SENDING.md).
11
12
  //
12
13
  // Flow checks every draft. It checks its own data first: the draft has lines,
13
14
  // a total above zero, and a price item (or, for a line such as a rush
14
15
  // charge, an account) on every line. When an accounting
15
16
  // system is connected, its own checks are added (for Xero: the customer
16
17
  // matches one Xero contact, every item is in Xero). A draft with a problem is
17
- // in Problem. A person marks a draft without one ready. A saved change keeps
18
- // it ready; only Move to Drafts (markNotReady) takes it back.
18
+ // Pending (the "problem" view). A draft without one is Ready.
19
19
  //
20
20
  // Send all ready issues every ready draft (each gets its number) and, when an
21
21
  // accounting system is connected, queues it to go there. The send worker
@@ -35,19 +35,35 @@ const LEASE_MS = 15 * 60 * 1000;
35
35
  // Outcomes that mean the accounting system holds the invoice.
36
36
  const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
37
37
 
38
- const problem = (code, message) => ({ code, message });
38
+ // `short` is the few words the Invoices list shows; `message` says how to fix it.
39
+ const problem = (code, message, short = null) => ({ code, message, ...(short ? { short } : {}) });
39
40
 
40
41
  // Flow's own checks of one draft. `lines` are its invoice lines.
41
42
  export function draftProblems(invoice, lines) {
42
43
  const problems = [];
43
- if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.'));
44
- else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.'));
44
+ if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
45
+ else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
45
46
  for (const line of lines) {
46
- if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`));
47
+ if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`));
47
48
  }
48
49
  return problems;
49
50
  }
50
51
 
52
+ // The few words for a job in review: what changed, for this job.
53
+ export function reviewShort(obligation) {
54
+ if (obligation.holdReason === STATUS_BACK_REASON) return 'Status moved back';
55
+ if (obligation.holdReason !== CHANGED_REASON) return /deleted/.test(obligation.holdReason ?? '') ? 'Job deleted' : 'Job needs a look';
56
+ const basis = obligation.draftBasis;
57
+ if (!basis) return 'Job changed';
58
+ const changes = diffJobLines(basis.lines ?? [], obligation.lines ?? []);
59
+ const words = changes.map((c) => (['added', 'removed'].includes(c.change)
60
+ ? `Line ${c.position} ${c.change}`
61
+ : `Line ${c.position} ${{ item: 'item', description: 'text', quantity: 'qty', price: 'price' }[c.fields[0]] ?? 'changed'}`));
62
+ if ((basis.contactId ?? null) !== (obligation.contactId ?? null)) words.unshift('Client changed');
63
+ if (!words.length) return 'Job changed';
64
+ return words.length > 1 ? `${words[0]} +${words.length - 1}` : words[0];
65
+ }
66
+
51
67
  // Flow's checks, and the accounting system's, for some drafts. Answers
52
68
  // Map(id -> problems) holding only the drafts that have one.
53
69
  async function checkDrafts(db, organisationId, drafts, accounting) {
@@ -63,11 +79,11 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
63
79
  }
64
80
  // A draft whose job the ledger sent to review (the job changed after the
65
81
  // draft was made, or the work system deleted it) waits for a person.
66
- const reviewed = await db.select({ invoiceId: billingObligations.invoiceId, reason: billingObligations.holdReason })
67
- .from(billingObligations)
82
+ const reviewed = await db.select().from(billingObligations)
68
83
  .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
69
- for (const { invoiceId, reason } of reviewed) {
70
- const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`);
84
+ for (const obligation of reviewed) {
85
+ const { invoiceId, holdReason: reason } = obligation;
86
+ const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation));
71
87
  problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
72
88
  }
73
89
  if (accounting) {
@@ -80,8 +96,8 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
80
96
  }
81
97
 
82
98
  // The views of the Invoices page. `accounting` is the accounting adapter, or
83
- // null. Every draft is in one view: Problem (a check failed), Ready (a person
84
- // marked it ready) or To check (the rest). An issued invoice is Sending while
99
+ // null. Every draft is in one view: Problem (a check failed; the Pending tab)
100
+ // or Ready (its checks pass). An issued invoice is Sending while
85
101
  // queued, and a Problem when the accounting system refused it. Answers
86
102
  // { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
87
103
  export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
@@ -96,12 +112,14 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
96
112
  .orderBy(desc(invoices.sendUpdatedAt));
97
113
  for (const row of issued) {
98
114
  if (row.sendState === 'queued') sending.add(row.id);
99
- else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.')]);
115
+ else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')]);
100
116
  }
117
+ // A draft whose checks pass is ready (Samuel, 2026-10-20): no person
118
+ // needs to mark it. toCheck stays for callers that still read it.
101
119
  const clean = drafts.filter((d) => !problems.has(d.id));
102
120
  return {
103
- toCheck: clean.filter((d) => !d.readyAt).map((d) => d.id),
104
- ready: clean.filter((d) => d.readyAt).map((d) => d.id),
121
+ toCheck: [],
122
+ ready: clean.map((d) => d.id),
105
123
  problems,
106
124
  sending,
107
125
  };
@@ -112,11 +130,11 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
112
130
  if (invoice.status === 'void') return { view: null, problems: [] };
113
131
  if (invoice.status === 'issued') {
114
132
  if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
115
- if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.')] };
133
+ if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')] };
116
134
  return { view: null, problems: [] };
117
135
  }
118
136
  const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
119
- return { view: found.length ? 'problem' : invoice.readyAt ? 'ready' : 'to_check', problems: found };
137
+ return { view: found.length ? 'problem' : 'ready', problems: found };
120
138
  }
121
139
 
122
140
  // Mark ready: a person checked these drafts. A draft with a problem stays
@@ -12,12 +12,10 @@ import { migrationsTable } from '../integrations/schema.js';
12
12
  // - the tables in public that neither declares (another app's, or an
13
13
  // earlier Flow's).
14
14
  //
15
- // On Replit it runs on the workspace's database after each update
16
- // (scripts/replit-deploy.sh), and compares that database with the live app's
17
- // GET /health, which reports the same migration counts. Replit's publish step
18
- // offers to make production's tables match the workspace's, so the
19
- // comparison says what that step will show (docs/REPLIT.md, "The workspace
20
- // updater"). Anyone who runs Flow can run it: `flow-server doctor`.
15
+ // On Replit, `flow-server doctor --live <address>` compares the workspace's
16
+ // database with the live app's GET /health, which reports the same migration
17
+ // counts, and says what Replit's publish step will show (docs/REPLIT.md, "The
18
+ // workspace database"). Anyone who runs Flow can run it: `flow-server doctor`.
21
19
 
22
20
  const CORE_MIGRATIONS = fileURLToPath(new URL('./migrations', import.meta.url));
23
21
  const CORE_TABLE = '__drizzle_migrations';
@@ -114,26 +112,27 @@ export async function diagnoseDatabase(pool, { adapters = [] } = {}) {
114
112
  };
115
113
  }
116
114
 
117
- // What Replit's publish step will offer, from the workspace's report and the
115
+ // What Replit's publish step will show, from the workspace's report and the
118
116
  // live app's migration counts (GET /health's schema.migrations, or null when
119
- // the live app could not say).
117
+ // the live app could not say). Flow migrates production itself when it
118
+ // starts, so the answer to "Development database changes detected" is always
119
+ // Cancel (docs/REPLIT.md, "The workspace database").
120
120
  //
121
- // broken the workspace database is not at this build: do not publish
122
- // behind production has migrations the workspace lacks: the list would
123
- // drop them, so Cancel and update the workspace first
124
- // adds the workspace is ahead: the list holds additions only; approve
125
121
  // same the two match: no list
126
- // unknown the live app did not say: approve additions, Cancel a DROP
122
+ // behind production has migrations the workspace lacks: follow production
123
+ // ahead the workspace has migrations production does not run
124
+ // unknown the live app did not say
125
+ export const CANCEL_RULE = 'If Replit shows "Development database changes detected", press Cancel.';
126
+
127
127
  export function publishVerdict(report, live) {
128
- if (!report.ok) return { verdict: 'broken', say: 'The workspace database is not at this build. Do not publish. Update the workspace again.' };
129
- if (!live) return { verdict: 'unknown', say: 'The live app did not report its migrations. Publish: approve a list of additions; Cancel any DROP.' };
128
+ if (!live) return { verdict: 'unknown', say: `The live app did not report its migrations. ${CANCEL_RULE}` };
130
129
  const behind = Object.entries(live).filter(([id, n]) => n > (report.migrations[id] ?? 0)).map(([id]) => id);
131
- if (behind.length) {
132
- return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Do not publish: Replit would offer to DROP them. Update the workspace first.` };
133
- }
134
130
  const ahead = Object.entries(report.migrations).filter(([id, n]) => n > (live[id] ?? 0)).map(([id]) => id);
135
131
  if (ahead.length) {
136
- return { verdict: 'adds', say: `The workspace is ahead of production (${ahead.join(', ')}). Publish: Replit offers to add tables or columns only. Approve.` };
132
+ return { verdict: 'ahead', say: `The workspace has ${ahead.join(', ')} migrations production does not run. ${CANCEL_RULE} Production applies its own migrations when it starts.` };
133
+ }
134
+ if (behind.length) {
135
+ return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Run npm run db:follow-production (or press Run). ${CANCEL_RULE}` };
137
136
  }
138
137
  return { verdict: 'same', say: 'The workspace and production are at the same migrations. Publish: Replit shows no database changes.' };
139
138
  }
@@ -6,6 +6,13 @@ import * as schema from './schema.js';
6
6
 
7
7
  const migrationsFolder = fileURLToPath(new URL('./migrations', import.meta.url));
8
8
 
9
+ // Session advisory lock held while any migration runs (Flow's own, each
10
+ // integration's, and the Replit workspace sync), so two processes sharing one
11
+ // database never apply the same migration at once. Field uses the same
12
+ // pattern with its own key.
13
+ export const MIGRATION_LOCK_KEY = 7_139_000_101n;
14
+ const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 300_000;
15
+
9
16
  // Opens Flow's PostgreSQL database. The caller owns the pool: call close().
10
17
  export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {}) {
11
18
  if (!url) throw new Error('flow: DATABASE_URL is not set');
@@ -14,8 +21,72 @@ export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {})
14
21
  return { db, pool, close: () => pool.end() };
15
22
  }
16
23
 
17
- export function migrateDatabase(db) {
18
- return migrate(db, { migrationsFolder });
24
+ // A transaction-mode pooler (PgBouncer, Neon's "-pooler" hosts) does not keep
25
+ // a session, so it cannot hold a session lock. Neon names a pooled endpoint
26
+ // by adding "-pooler" to the endpoint id; the same host without it is the
27
+ // direct endpoint, which migrations use instead (as Field does).
28
+ const POOLER_HOST = /-pooler(?=\.|$)/i;
29
+
30
+ export function directUrl(url) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(url);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (!POOLER_HOST.test(parsed.hostname)) return null;
38
+ parsed.hostname = parsed.hostname.replace(POOLER_HOST, '');
39
+ return parsed.toString();
40
+ }
41
+
42
+ function migrationLockTimeoutMs(env = process.env) {
43
+ const value = Number.parseInt(env.FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS || '', 10);
44
+ return Number.isInteger(value) && value >= 0 ? value : DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
45
+ }
46
+
47
+ // Runs fn(lockedDb) under MIGRATION_LOCK_KEY. The lock and fn share one
48
+ // connection taken from db's pool (or the pooler's direct endpoint), because
49
+ // a session lock belongs to one connection. It waits up to
50
+ // FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS (300000) for another process to finish.
51
+ export async function withMigrationLock(db, fn, { timeoutMs = migrationLockTimeoutMs() } = {}) {
52
+ const source = db?.$client;
53
+ if (!source) throw new Error('flow: a migration needs a PostgreSQL database from openDatabase');
54
+ const direct = source instanceof pg.Pool ? directUrl(source.options?.connectionString) : null;
55
+ let client;
56
+ if (direct) {
57
+ client = new pg.Client({ connectionString: direct });
58
+ await client.connect();
59
+ } else {
60
+ client = source instanceof pg.Pool ? await source.connect() : source;
61
+ }
62
+ let broken = false;
63
+ try {
64
+ const deadline = Date.now() + timeoutMs;
65
+ for (;;) {
66
+ const { rows } = await client.query('select pg_try_advisory_lock($1::bigint) as locked', [MIGRATION_LOCK_KEY.toString()]);
67
+ if (rows[0].locked) break;
68
+ if (Date.now() >= deadline) {
69
+ throw new Error(`flow: another process held the migration lock (advisory lock ${MIGRATION_LOCK_KEY}) for over ${timeoutMs} ms; is a migration hung? (FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS)`);
70
+ }
71
+ await new Promise((wake) => setTimeout(wake, 250));
72
+ }
73
+ try {
74
+ return await fn(drizzle(client, { schema }));
75
+ } finally {
76
+ // A connection that cannot unlock is closed, which also frees the lock.
77
+ await client.query('select pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY.toString()]).catch(() => { broken = true; });
78
+ }
79
+ } finally {
80
+ if (direct) await client.end().catch(() => {});
81
+ else if (client !== source) client.release(broken);
82
+ }
83
+ }
84
+
85
+ // Applies the migrations in `folder` (this build's own by default) under the
86
+ // migration lock. `table` names the journal: drizzle.__drizzle_migrations for
87
+ // Flow's own, one per integration (server/integrations/schema.js).
88
+ export function migrateDatabase(db, { folder = migrationsFolder, table } = {}) {
89
+ return withMigrationLock(db, (locked) => migrate(locked, { migrationsFolder: folder, ...(table ? { migrationsTable: table } : {}) }));
19
90
  }
20
91
 
21
- export { schema };
92
+ export { migrationsFolder, schema };
@@ -3,10 +3,9 @@ import { adapters as shipped } from '../integrations/adapters.js';
3
3
  import { migrateAdapterSchema } from '../integrations/schema.js';
4
4
 
5
5
  // Brings a database up to this build: the core migrations, then the tables
6
- // of every adapter this build ships, whether or not it is on. On Replit the
7
- // workspace's database runs this before a publish (scripts/replit-deploy.sh
8
- // update-workspace), so it holds every table production can hold and
9
- // publishing never offers to drop one (docs/REPLIT.md).
6
+ // of every adapter this build ships, whether or not it is on
7
+ // (`flow-server migrate`). Not for a Replit workspace: its database follows
8
+ // production instead (scripts/replit-workspace-db.js, docs/REPLIT.md).
10
9
  export async function migrateEverything(db, { adapters = shipped, log = () => {} } = {}) {
11
10
  await migrateDatabase(db);
12
11
  for (const adapter of adapters) {
@@ -0,0 +1,21 @@
1
+ CREATE TABLE IF NOT EXISTS "job_field_settings" (
2
+ "organisation_id" uuid PRIMARY KEY NOT NULL,
3
+ "group_colors" jsonb DEFAULT '{}'::jsonb NOT NULL,
4
+ "assignee_made_at" timestamp with time zone,
5
+ "updated_by" uuid,
6
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
7
+ );
8
+ --> statement-breakpoint
9
+ ALTER TABLE "job_fields" ADD COLUMN IF NOT EXISTS "color" text;--> statement-breakpoint
10
+ DO $flow_guard$ BEGIN
11
+ ALTER TABLE "job_field_settings" ADD CONSTRAINT "job_field_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
12
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
13
+ END $flow_guard$;--> statement-breakpoint
14
+ DO $flow_guard$ BEGIN
15
+ ALTER TABLE "job_field_settings" ADD CONSTRAINT "job_field_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
16
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
17
+ END $flow_guard$;--> statement-breakpoint
18
+ DO $flow_guard$ BEGIN
19
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_color_valid" CHECK ("job_fields"."color" is null or "job_fields"."color" in ('gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'));
20
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
21
+ END $flow_guard$;