@arcforgelabs/flow 2026.10.32 → 2026.10.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +107 -4
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
// What happened to one job's custom field values, newest first.
|
|
290
|
+
export async function jobFieldHistory(db, organisationId, raw, { limit = 50 } = {}) {
|
|
291
|
+
const job = readJob(raw);
|
|
292
|
+
const rows = await db.select().from(jobFieldEvents).where(and(
|
|
293
|
+
eq(jobFieldEvents.organisationId, organisationId),
|
|
294
|
+
eq(jobFieldEvents.sourceSystem, job.sourceSystem), eq(jobFieldEvents.sourceJobRef, job.sourceJobRef),
|
|
295
|
+
)).orderBy(sql`${jobFieldEvents.createdAt} desc`).limit(limit);
|
|
296
|
+
return rows.map((r) => ({
|
|
297
|
+
fieldId: r.fieldId, from: r.fromValue, to: r.toValue, actor: r.actor ?? null,
|
|
298
|
+
at: r.createdAt?.toISOString?.() ?? r.createdAt,
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import { FlowError, notFound } from './errors.js';
|
|
6
6
|
import { listRules, ruleReasons } from './invoice-rules.js';
|
|
7
7
|
import { draftInvoiceForObligation } from './ledger.js';
|
|
8
|
+
import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
|
|
8
9
|
import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
|
|
9
10
|
|
|
10
11
|
// Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
|
|
@@ -221,6 +222,12 @@ export function copyCheck(accounting, organisationId) {
|
|
|
221
222
|
};
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
async function reloadObligation(db, organisationId, id) {
|
|
226
|
+
const [row] = await db.select().from(billingObligations)
|
|
227
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, id)));
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
224
231
|
// Drafts the invoice of each job that is ready to invoice and passes the
|
|
225
232
|
// ledger's checks and every rule, except for customers whose drafting is off.
|
|
226
233
|
// It writes only Flow's own records: the drafts wait in Flow for a person.
|
|
@@ -228,7 +235,7 @@ export function copyCheck(accounting, organisationId) {
|
|
|
228
235
|
// With `accounting` null no accounting system is connected: Flow's own records
|
|
229
236
|
// are the only check, as for a person's draft (ledger.js, ledgerView).
|
|
230
237
|
export async function autoDraftJobs(db, organisationId, { accounting = null, work = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
231
|
-
const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
|
|
238
|
+
const result = { drafted: 0, waiting: 0, off: 0, refused: 0, readFirst: 0 };
|
|
232
239
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
233
240
|
const rules = await listRules(db, organisationId);
|
|
234
241
|
if (accounting) {
|
|
@@ -251,17 +258,56 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
251
258
|
const customers = await customerBillingByContact(db, organisationId, candidates.map((o) => o.contactId));
|
|
252
259
|
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
253
260
|
const invoiceDate = isoDay(now);
|
|
261
|
+
let accountingRead = null;
|
|
254
262
|
|
|
255
|
-
for (const
|
|
256
|
-
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
let effective = effectiveSettings(settings, customers.get(candidate.contactId));
|
|
257
265
|
if (!effective.autoDraft) {
|
|
258
266
|
result.off += 1;
|
|
259
267
|
continue;
|
|
260
268
|
}
|
|
261
|
-
if (ruleBlockers(
|
|
269
|
+
if (ruleBlockers(candidate, effective, { now, rules }).length) {
|
|
270
|
+
result.waiting += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Issue #141: an old report is read again first. The rules then check
|
|
274
|
+
// what the work system says now; the ledger checks the rest.
|
|
275
|
+
if (result.readFirst >= READS_PER_PASS && needsRead(candidate, { work, now })) {
|
|
262
276
|
result.waiting += 1;
|
|
263
277
|
continue;
|
|
264
278
|
}
|
|
279
|
+
let { outcome, obligation } = (await readBeforeAct(db, organisationId, [candidate], { work, now, actor })).get(candidate.id);
|
|
280
|
+
if (outcome !== 'fresh') result.readFirst += 1;
|
|
281
|
+
if (outcome === 'waiting' || outcome === 'review') {
|
|
282
|
+
result.waiting += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Issue #45: the accounting copy reads its latest changes once, before
|
|
286
|
+
// the first draft. When it changed, the matcher ran: this job may now
|
|
287
|
+
// have its invoice.
|
|
288
|
+
if (accounting) {
|
|
289
|
+
if (!accountingRead) {
|
|
290
|
+
accountingRead = await readAccountingFirst(db, organisationId, { accounting, now });
|
|
291
|
+
// The summary says when the copy read or could not read.
|
|
292
|
+
if (accountingRead.outcome !== 'fresh') result.accountingRead = accountingRead.outcome;
|
|
293
|
+
}
|
|
294
|
+
if (accountingRead.outcome === 'waiting') {
|
|
295
|
+
result.waiting += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (accountingRead.outcome === 'read') obligation = await reloadObligation(db, organisationId, obligation.id);
|
|
299
|
+
if (obligation.accountingState !== 'not_found') {
|
|
300
|
+
result.waiting += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (obligation !== candidate) {
|
|
305
|
+
effective = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
306
|
+
if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
|
|
307
|
+
result.waiting += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
265
311
|
try {
|
|
266
312
|
await draftInvoiceForObligation(db, organisationId, obligation.id, {
|
|
267
313
|
liveCheck,
|
package/server/core/job-board.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
|
-
import { contacts, invoices, priceItems } from '../db/schema.js';
|
|
2
|
+
import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
|
|
3
3
|
import { listRules } from './invoice-rules.js';
|
|
4
4
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
|
-
import { listObligations } from './ledger.js';
|
|
5
|
+
import { REVIEW_CHANGED, listObligations } from './ledger.js';
|
|
6
6
|
import { isHistoryHeld } from './history.js';
|
|
7
7
|
import { invoiceViews } from './sending.js';
|
|
8
8
|
|
|
@@ -21,7 +21,8 @@ import { invoiceViews } from './sending.js';
|
|
|
21
21
|
export async function describeObligations(db, organisationId, listed, { accounting = null } = {}) {
|
|
22
22
|
const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
|
|
23
23
|
const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
|
|
24
|
-
const
|
|
24
|
+
const changedIds = listed.filter((e) => e.obligation.holdState === 'review' && e.obligation.invoiceId).map((e) => e.obligation.id);
|
|
25
|
+
const [names, numbers, views, changed] = await Promise.all([
|
|
25
26
|
contactIds.length
|
|
26
27
|
? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
27
28
|
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
|
|
@@ -31,7 +32,16 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
31
32
|
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
32
33
|
: [],
|
|
33
34
|
invoiceIds.length ? invoiceViews(db, organisationId, { accounting }) : null,
|
|
35
|
+
// The open "job changed" alerts (ledger.js): only the work system changed
|
|
36
|
+
// and the draft is not sent, so a person can use the new lines.
|
|
37
|
+
changedIds.length
|
|
38
|
+
? db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence }).from(reviewItems).where(and(
|
|
39
|
+
eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_CHANGED),
|
|
40
|
+
eq(reviewItems.state, 'open'), inArray(reviewItems.flowId, changedIds),
|
|
41
|
+
))
|
|
42
|
+
: [],
|
|
34
43
|
]);
|
|
44
|
+
const offers = new Set(changed.filter((r) => r.evidence?.canUseNewLines).map((r) => r.flowId));
|
|
35
45
|
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
36
46
|
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
37
47
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
@@ -46,8 +56,9 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
46
56
|
// draft can still come from.
|
|
47
57
|
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
48
58
|
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
49
|
-
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view, reasons }) => ({
|
|
59
|
+
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
|
|
50
60
|
...o,
|
|
61
|
+
canUseNewLines: offers.has(o.id),
|
|
51
62
|
contactName: nameOf.get(o.contactId) ?? null,
|
|
52
63
|
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
53
64
|
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
|
@@ -125,6 +136,8 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
125
136
|
totalCents: o?.totalCents ?? item.totalCents ?? null,
|
|
126
137
|
lines: o ? ledgerLines(o.lines)
|
|
127
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,
|
|
128
141
|
obligation: o,
|
|
129
142
|
});
|
|
130
143
|
}
|