@arcforgelabs/flow 2026.10.37 → 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.
- package/README.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
package/server/core/sending.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
|
70
|
-
const
|
|
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
|
|
84
|
-
//
|
|
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:
|
|
104
|
-
ready: clean.
|
|
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' :
|
|
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
|
package/server/db/doctor.js
CHANGED
|
@@ -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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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 (!
|
|
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: '
|
|
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
|
}
|
package/server/db/index.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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 };
|
package/server/db/migrate-all.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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$;
|