@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +32 -1
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +84 -6
- package/server/core/price-list.js +348 -0
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +16 -4
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +23 -2
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +74 -21
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +51 -2
- package/server/integrations/seazona/lanes.js +251 -62
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +116 -0
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +5 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -16
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/monitoring.js +14 -0
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
package/server/core/sending.js
CHANGED
|
@@ -4,7 +4,7 @@ import { invoiceLines, invoices } from '../db/schema.js';
|
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
5
|
import { issueInvoice } from './invoices.js';
|
|
6
6
|
import { soleOrganisation } from './pipeline.js';
|
|
7
|
-
import { enqueueJob, runNextJob } from './queue.js';
|
|
7
|
+
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
8
8
|
|
|
9
9
|
// Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
|
|
10
10
|
//
|
|
@@ -124,9 +124,10 @@ export async function markReady(db, organisationId, invoiceIds, { personId, acco
|
|
|
124
124
|
return { marked: good.length, refused: [...problems].map(([invoiceId, found]) => ({ invoiceId, problems: found })) };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// Back to drafts: the drafts need another look before they go.
|
|
127
|
+
// Back to drafts: the drafts need another look before they go. The new
|
|
128
|
+
// updatedAt keeps "Ready when drafted" (automations.js) off them.
|
|
128
129
|
export async function markNotReady(db, organisationId, invoiceIds) {
|
|
129
|
-
const rows = await db.update(invoices).set({ readyAt: null, readyBy: null })
|
|
130
|
+
const rows = await db.update(invoices).set({ readyAt: null, readyBy: null, updatedAt: sql`now()` })
|
|
130
131
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, invoiceIds)))
|
|
131
132
|
.returning({ id: invoices.id });
|
|
132
133
|
return { moved: rows.length };
|
|
@@ -215,6 +216,7 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
215
216
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
216
217
|
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
217
218
|
summary.failed += 1;
|
|
219
|
+
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
}
|
|
@@ -230,6 +232,7 @@ function settle(db, organisationId, invoiceId, fields) {
|
|
|
230
232
|
// the job queue.
|
|
231
233
|
export function createSendWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = WORKER_EVERY_MS }) {
|
|
232
234
|
const workerId = `send-${randomUUID()}`;
|
|
235
|
+
const startedAtMs = Date.now();
|
|
233
236
|
let timer = null;
|
|
234
237
|
let running = null;
|
|
235
238
|
let stopped = true;
|
|
@@ -252,13 +255,22 @@ export function createSendWorker({ db, integrations, log = console, timers = { s
|
|
|
252
255
|
const [waiting] = await db.select({ id: invoices.id }).from(invoices)
|
|
253
256
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.sendState, 'queued'))).limit(1);
|
|
254
257
|
if (!waiting) return;
|
|
258
|
+
// Issue #101: a send run that gave up waits for a person, a restart or
|
|
259
|
+
// the next day; the timer never starts it again by itself.
|
|
260
|
+
if (await deadSince(db, organisationId, { kind: SEND_KIND, dedupeKey: 'all', since: deadHoldSince(startedAtMs) })) return;
|
|
255
261
|
await enqueueJob(db, organisationId, { kind: SEND_KIND, dedupeKey: 'all' });
|
|
256
262
|
const ran = await runNextJob(db, {
|
|
257
263
|
workerId,
|
|
258
264
|
leaseMs: LEASE_MS,
|
|
259
265
|
handlers: { [SEND_KIND]: () => sendQueued(db, organisationId, { accounting }) },
|
|
260
266
|
});
|
|
261
|
-
if (ran)
|
|
267
|
+
if (!ran) return;
|
|
268
|
+
const line = `[sending] ${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}`;
|
|
269
|
+
// An invoice the accounting system refused waits in Problem for a
|
|
270
|
+
// person; it is reported once, when it is refused.
|
|
271
|
+
if (ran.outcome === 'dead') log.error?.('[sending] the send run gave up after its last attempt');
|
|
272
|
+
if (ran.job.result?.failed) log.error?.('[sending] the accounting system refused an invoice');
|
|
273
|
+
(ran.outcome === 'succeeded' && !ran.job.result?.failed ? log.info : log.warn)?.(line);
|
|
262
274
|
} catch (error) {
|
|
263
275
|
log.error?.(`[sending] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
264
276
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { adapters } from '../integrations/adapters.js';
|
|
2
|
+
import { diagnoseDatabase, formatReport, liveMigrationCounts, publishVerdict } from './doctor.js';
|
|
3
|
+
import { openDatabase } from './index.js';
|
|
4
|
+
import { migrateEverything } from './migrate-all.js';
|
|
5
|
+
|
|
6
|
+
// `flow-server migrate` and `flow-server doctor` (server/flow-server.js).
|
|
7
|
+
// Each returns the exit code.
|
|
8
|
+
export async function runCommand(command, args, { log = console } = {}) {
|
|
9
|
+
const option = (name) => {
|
|
10
|
+
const at = args.indexOf(name);
|
|
11
|
+
return at === -1 ? undefined : args[at + 1];
|
|
12
|
+
};
|
|
13
|
+
let handle;
|
|
14
|
+
try {
|
|
15
|
+
handle = openDatabase({ max: 1 });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
log.error(`[doctor] ${error.message}`);
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
if (command === 'migrate') {
|
|
22
|
+
await migrateEverything(handle.db, { log: (line) => log.log(`[migrate] ${line}`) });
|
|
23
|
+
log.log('[migrate] the database is at this build');
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const report = await diagnoseDatabase(handle.pool, { adapters });
|
|
27
|
+
const origin = option('--live');
|
|
28
|
+
const live = origin ? await liveMigrationCounts(origin) : undefined;
|
|
29
|
+
const verdict = origin ? publishVerdict(report, live) : null;
|
|
30
|
+
if (args.includes('--json')) {
|
|
31
|
+
log.log(JSON.stringify({ ...report, live: live ?? null, verdict }, null, 2));
|
|
32
|
+
} else {
|
|
33
|
+
for (const line of formatReport(report, verdict)) log.log(`[doctor] ${line}`);
|
|
34
|
+
}
|
|
35
|
+
return report.ok ? 0 : 1;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
log.error(`[doctor] ${error.message}`);
|
|
38
|
+
return 2;
|
|
39
|
+
} finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { migrationsTable } from '../integrations/schema.js';
|
|
4
|
+
|
|
5
|
+
// Flow's doctor: checks a database against this build. It reads, and never
|
|
6
|
+
// changes anything.
|
|
7
|
+
//
|
|
8
|
+
// - each migration journal (Flow's own, and each shipped adapter's): how
|
|
9
|
+
// many of this build's migrations the database has applied;
|
|
10
|
+
// - every table and column this build's latest core snapshot declares, and
|
|
11
|
+
// every table each adapter declares: which are missing;
|
|
12
|
+
// - the tables in public that neither declares (another app's, or an
|
|
13
|
+
// earlier Flow's).
|
|
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`.
|
|
21
|
+
|
|
22
|
+
const CORE_MIGRATIONS = fileURLToPath(new URL('./migrations', import.meta.url));
|
|
23
|
+
const CORE_TABLE = '__drizzle_migrations';
|
|
24
|
+
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What this build expects: one journal per migrations folder, and the tables
|
|
30
|
+
// (with their columns, where a snapshot names them) those migrations create.
|
|
31
|
+
export function expectedSchema({ adapters = [] } = {}) {
|
|
32
|
+
const journals = [{ id: 'core', table: CORE_TABLE, folder: CORE_MIGRATIONS }];
|
|
33
|
+
for (const adapter of adapters) {
|
|
34
|
+
if (!adapter.schema) continue;
|
|
35
|
+
journals.push({ id: adapter.id, table: migrationsTable(adapter), folder: adapter.schema.migrationsFolder, tables: adapter.schema.tables });
|
|
36
|
+
}
|
|
37
|
+
const tables = new Map();
|
|
38
|
+
for (const journal of journals) {
|
|
39
|
+
const { entries } = readJson(`${journal.folder}/meta/_journal.json`);
|
|
40
|
+
journal.tags = entries.map((entry) => entry.tag);
|
|
41
|
+
const last = entries.at(-1);
|
|
42
|
+
let snapshot = null;
|
|
43
|
+
try {
|
|
44
|
+
snapshot = last ? readJson(`${journal.folder}/meta/${String(last.idx).padStart(4, '0')}_snapshot.json`) : null;
|
|
45
|
+
} catch {
|
|
46
|
+
snapshot = null; // An adapter may ship its migrations without snapshots.
|
|
47
|
+
}
|
|
48
|
+
for (const table of Object.values(snapshot?.tables ?? {})) {
|
|
49
|
+
if ((table.schema || 'public') !== 'public') continue;
|
|
50
|
+
tables.set(table.name, new Set(Object.keys(table.columns)));
|
|
51
|
+
}
|
|
52
|
+
for (const name of journal.tables ?? []) {
|
|
53
|
+
if (!tables.has(name)) tables.set(name, null);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { journals, tables };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// How many migrations each journal has applied: { core: 14, xero: 6, ... }.
|
|
60
|
+
// A journal whose table does not exist (an adapter never turned on) counts 0.
|
|
61
|
+
export async function appliedMigrationCounts(pool, journals) {
|
|
62
|
+
const { rows } = await pool.query(
|
|
63
|
+
"select table_name from information_schema.tables where table_schema = 'drizzle'",
|
|
64
|
+
);
|
|
65
|
+
const present = new Set(rows.map((row) => row.table_name));
|
|
66
|
+
const counts = {};
|
|
67
|
+
for (const journal of journals) {
|
|
68
|
+
if (!present.has(journal.table)) {
|
|
69
|
+
counts[journal.id] = 0;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// The name was read from the catalogue above, so quoting it is enough.
|
|
73
|
+
const result = await pool.query(`select count(*)::int as n from drizzle."${journal.table.replaceAll('"', '""')}"`);
|
|
74
|
+
counts[journal.id] = result.rows[0].n;
|
|
75
|
+
}
|
|
76
|
+
return counts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function diagnoseDatabase(pool, { adapters = [] } = {}) {
|
|
80
|
+
const expected = expectedSchema({ adapters });
|
|
81
|
+
const applied = await appliedMigrationCounts(pool, expected.journals);
|
|
82
|
+
const pending = expected.journals
|
|
83
|
+
.filter((journal) => applied[journal.id] < journal.tags.length)
|
|
84
|
+
.map((journal) => ({ journal: journal.id, applied: applied[journal.id], shipped: journal.tags.length, next: journal.tags.slice(applied[journal.id]) }));
|
|
85
|
+
|
|
86
|
+
const { rows } = await pool.query(
|
|
87
|
+
"select table_name, column_name from information_schema.columns where table_schema = 'public'",
|
|
88
|
+
);
|
|
89
|
+
const actual = new Map();
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
if (!actual.has(row.table_name)) actual.set(row.table_name, new Set());
|
|
92
|
+
actual.get(row.table_name).add(row.column_name);
|
|
93
|
+
}
|
|
94
|
+
const missingTables = [];
|
|
95
|
+
const missingColumns = [];
|
|
96
|
+
for (const [table, columns] of expected.tables) {
|
|
97
|
+
const have = actual.get(table);
|
|
98
|
+
if (!have) {
|
|
99
|
+
missingTables.push(table);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const column of columns ?? []) {
|
|
103
|
+
if (!have.has(column)) missingColumns.push(`${table}.${column}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const unknownTables = [...actual.keys()].filter((table) => !expected.tables.has(table)).sort();
|
|
107
|
+
return {
|
|
108
|
+
ok: pending.length === 0 && missingTables.length === 0 && missingColumns.length === 0,
|
|
109
|
+
migrations: applied,
|
|
110
|
+
pending,
|
|
111
|
+
missingTables: missingTables.sort(),
|
|
112
|
+
missingColumns: missingColumns.sort(),
|
|
113
|
+
unknownTables,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What Replit's publish step will offer, from the workspace's report and the
|
|
118
|
+
// live app's migration counts (GET /health's schema.migrations, or null when
|
|
119
|
+
// the live app could not say).
|
|
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
|
+
// same the two match: no list
|
|
126
|
+
// unknown the live app did not say: approve additions, Cancel a DROP
|
|
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.' };
|
|
130
|
+
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
|
+
const ahead = Object.entries(report.migrations).filter(([id, n]) => n > (live[id] ?? 0)).map(([id]) => id);
|
|
135
|
+
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.` };
|
|
137
|
+
}
|
|
138
|
+
return { verdict: 'same', say: 'The workspace and production are at the same migrations. Publish: Replit shows no database changes.' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The live app's migration counts from its GET /health, or null.
|
|
142
|
+
export async function liveMigrationCounts(origin, { fetchImpl = fetch, timeoutMs = 8000 } = {}) {
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL('/health', origin);
|
|
145
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
146
|
+
if (!res.ok && res.status !== 503) return null;
|
|
147
|
+
const body = await res.json();
|
|
148
|
+
const counts = body?.schema?.migrations;
|
|
149
|
+
return counts && typeof counts === 'object' ? counts : null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function formatReport(report, verdict = null) {
|
|
156
|
+
const lines = [];
|
|
157
|
+
for (const [id, n] of Object.entries(report.migrations)) {
|
|
158
|
+
const pending = report.pending.find((p) => p.journal === id);
|
|
159
|
+
lines.push(`${id} migrations: ${n} applied${pending ? `, ${pending.next.length} pending (${pending.next.join(', ')})` : ''}`);
|
|
160
|
+
}
|
|
161
|
+
lines.push(report.missingTables.length ? `missing tables: ${report.missingTables.join(', ')}` : 'tables: all present');
|
|
162
|
+
lines.push(report.missingColumns.length ? `missing columns: ${report.missingColumns.join(', ')}` : 'columns: all present');
|
|
163
|
+
if (report.unknownTables.length) {
|
|
164
|
+
lines.push(`tables this build does not know: ${report.unknownTables.join(', ')}. On Replit, publishing offers to copy them to production.`);
|
|
165
|
+
}
|
|
166
|
+
if (verdict) lines.push(`verdict: ${verdict.verdict}. ${verdict.say}`);
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "price_item_links" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"organisation_id" uuid NOT NULL,
|
|
4
|
+
"price_item_id" uuid NOT NULL,
|
|
5
|
+
"system" text NOT NULL,
|
|
6
|
+
"external_id" text NOT NULL,
|
|
7
|
+
"external_code" text,
|
|
8
|
+
"matched_by" text NOT NULL,
|
|
9
|
+
"created_by" uuid,
|
|
10
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
11
|
+
CONSTRAINT "price_item_links_matched_by_valid" CHECK ("price_item_links"."matched_by" in ('code', 'person')),
|
|
12
|
+
CONSTRAINT "price_item_links_system_valid" CHECK ("price_item_links"."system" ~ '^[a-z][a-z0-9-]{1,31}$')
|
|
13
|
+
);
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "account_code" text;--> statement-breakpoint
|
|
16
|
+
ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "updated_by" uuid;--> statement-breakpoint
|
|
17
|
+
ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
|
|
18
|
+
DO $flow_guard$ BEGIN
|
|
19
|
+
ALTER TABLE "price_item_links" ADD CONSTRAINT "price_item_links_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
|
|
20
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
21
|
+
END $flow_guard$;--> statement-breakpoint
|
|
22
|
+
DO $flow_guard$ BEGIN
|
|
23
|
+
ALTER TABLE "price_item_links" ADD CONSTRAINT "price_item_links_created_by_people_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
|
|
24
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
25
|
+
END $flow_guard$;--> statement-breakpoint
|
|
26
|
+
DO $flow_guard$ BEGIN
|
|
27
|
+
ALTER TABLE "price_item_links" ADD CONSTRAINT "price_item_links_item_same_org" FOREIGN KEY ("organisation_id","price_item_id") REFERENCES "public"."price_items"("organisation_id","id") ON DELETE cascade ON UPDATE no action;
|
|
28
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
29
|
+
END $flow_guard$;--> statement-breakpoint
|
|
30
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "price_item_links_external" ON "price_item_links" USING btree ("organisation_id","system","external_id");--> statement-breakpoint
|
|
31
|
+
DO $flow_guard$ BEGIN
|
|
32
|
+
ALTER TABLE "price_items" ADD CONSTRAINT "price_items_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
|
|
33
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
34
|
+
END $flow_guard$;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "alert_dismissals" (
|
|
2
|
+
"organisation_id" uuid NOT NULL,
|
|
3
|
+
"person_id" uuid NOT NULL,
|
|
4
|
+
"alert_key" text NOT NULL,
|
|
5
|
+
"members" text[] DEFAULT '{}'::text[] NOT NULL,
|
|
6
|
+
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
7
|
+
CONSTRAINT "alert_dismissals_organisation_id_person_id_alert_key_pk" PRIMARY KEY("organisation_id","person_id","alert_key")
|
|
8
|
+
);
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
DO $flow_guard$ BEGIN
|
|
11
|
+
ALTER TABLE "alert_dismissals" ADD CONSTRAINT "alert_dismissals_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 "alert_dismissals" ADD CONSTRAINT "alert_dismissals_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE cascade ON UPDATE no action;
|
|
16
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
17
|
+
END $flow_guard$;
|