@arcforgelabs/flow 0.0.0-stage → 2026.10.18-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.
@@ -0,0 +1,188 @@
1
+ import { and, asc, desc, eq, sql } from 'drizzle-orm';
2
+ import { contacts, invoiceLines, invoices, invoiceVersions, organisations, priceItems } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+ import { invoiceTotals, lineTotals } from './money.js';
5
+
6
+ // Invoices (accounts receivable). A draft can change. Issuing gives the
7
+ // invoice its number and stores the document exactly as sent, as a new
8
+ // version. To change an issued invoice, revise it back to a draft and issue
9
+ // it again: the number stays and the earlier versions stay as they were.
10
+
11
+ async function loadInvoice(tx, organisationId, invoiceId, { lock = false } = {}) {
12
+ let query = tx.select().from(invoices)
13
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
14
+ if (lock) query = query.for('update');
15
+ const [row] = await query;
16
+ if (!row) throw notFound('invoice');
17
+ return row;
18
+ }
19
+
20
+ async function resolveLines(tx, organisationId, lines) {
21
+ if (!Array.isArray(lines)) throw new FlowError('invalid', 'lines must be a list');
22
+ const resolved = [];
23
+ for (const [index, line] of lines.entries()) {
24
+ let { description, unitAmountCents, taxCode } = line;
25
+ const quantity = line.quantity ?? 1;
26
+ if (line.priceItemId) {
27
+ const [item] = await tx.select().from(priceItems)
28
+ .where(and(eq(priceItems.organisationId, organisationId), eq(priceItems.id, line.priceItemId)));
29
+ if (!item) throw new FlowError('invalid', `line ${index + 1}: price item not found`);
30
+ description ??= item.name;
31
+ unitAmountCents ??= item.unitAmountCents;
32
+ taxCode ??= item.taxCode;
33
+ }
34
+ taxCode ??= 'GST';
35
+ if (!description?.trim()) throw new FlowError('invalid', `line ${index + 1}: description is required`);
36
+ let totals;
37
+ try {
38
+ totals = lineTotals({ quantity, unitAmountCents, taxCode });
39
+ } catch (error) {
40
+ throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
41
+ }
42
+ resolved.push({
43
+ position: index + 1,
44
+ priceItemId: line.priceItemId ?? null,
45
+ description: description.trim(),
46
+ quantity,
47
+ unitAmountCents,
48
+ taxCode,
49
+ ...totals,
50
+ });
51
+ }
52
+ return resolved;
53
+ }
54
+
55
+ async function writeLines(tx, invoice, lines) {
56
+ await tx.delete(invoiceLines).where(eq(invoiceLines.invoiceId, invoice.id));
57
+ if (lines.length) {
58
+ await tx.insert(invoiceLines).values(lines.map((line) => ({
59
+ ...line,
60
+ organisationId: invoice.organisationId,
61
+ invoiceId: invoice.id,
62
+ })));
63
+ }
64
+ const [row] = await tx.update(invoices)
65
+ .set({ ...invoiceTotals(lines), updatedAt: sql`now()` })
66
+ .where(eq(invoices.id, invoice.id))
67
+ .returning();
68
+ return row;
69
+ }
70
+
71
+ export async function createDraftInvoice(db, organisationId, { contactId, reference = null, dueDate = null, lines = [] }) {
72
+ return db.transaction(async (tx) => {
73
+ const [contact] = await tx.select().from(contacts)
74
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
75
+ if (!contact) throw new FlowError('invalid', 'contact not found');
76
+ if (contact.kind !== 'customer') throw new FlowError('invalid', 'an invoice goes to a customer contact');
77
+ const resolved = await resolveLines(tx, organisationId, lines);
78
+ const [invoice] = await tx.insert(invoices)
79
+ .values({ organisationId, contactId, reference, dueDate })
80
+ .returning();
81
+ return writeLines(tx, invoice, resolved);
82
+ });
83
+ }
84
+
85
+ export async function updateDraftInvoice(db, organisationId, invoiceId, changes) {
86
+ return db.transaction(async (tx) => {
87
+ const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
88
+ if (invoice.status !== 'draft') throw new FlowError('not_draft', 'only a draft invoice can change; revise it first');
89
+ const fields = {};
90
+ if ('reference' in changes) fields.reference = changes.reference;
91
+ if ('dueDate' in changes) fields.dueDate = changes.dueDate;
92
+ let row = invoice;
93
+ if (Object.keys(fields).length) {
94
+ [row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })
95
+ .where(eq(invoices.id, invoice.id)).returning();
96
+ }
97
+ if ('lines' in changes) {
98
+ row = await writeLines(tx, row, await resolveLines(tx, organisationId, changes.lines));
99
+ }
100
+ return row;
101
+ });
102
+ }
103
+
104
+ export async function issueInvoice(db, organisationId, invoiceId, { issuedBy, issueDate }) {
105
+ if (!issuedBy) throw new FlowError('invalid', 'issuedBy is required');
106
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(issueDate ?? '')) throw new FlowError('invalid', 'issueDate must be YYYY-MM-DD');
107
+ return db.transaction(async (tx) => {
108
+ const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
109
+ if (invoice.status !== 'draft') throw new FlowError('not_draft', 'only a draft invoice can be issued');
110
+ const lines = await tx.select().from(invoiceLines)
111
+ .where(eq(invoiceLines.invoiceId, invoice.id)).orderBy(asc(invoiceLines.position));
112
+ if (!lines.length) throw new FlowError('invalid', 'an invoice needs at least one line');
113
+
114
+ let number = invoice.number;
115
+ if (!number) {
116
+ const [org] = await tx.update(organisations)
117
+ .set({ nextInvoiceNumber: sql`${organisations.nextInvoiceNumber} + 1` })
118
+ .where(eq(organisations.id, organisationId))
119
+ .returning({ prefix: organisations.invoicePrefix, next: organisations.nextInvoiceNumber });
120
+ number = `${org.prefix}${String(org.next - 1).padStart(4, '0')}`;
121
+ }
122
+ const [contact] = await tx.select().from(contacts).where(eq(contacts.id, invoice.contactId));
123
+ const version = invoice.issuedVersion + 1;
124
+ const document = {
125
+ number,
126
+ version,
127
+ issueDate,
128
+ dueDate: invoice.dueDate,
129
+ reference: invoice.reference,
130
+ currency: invoice.currency,
131
+ contact: { id: contact.id, name: contact.name, email: contact.email },
132
+ lines: lines.map(({ position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }) => (
133
+ { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
134
+ )),
135
+ subtotalCents: invoice.subtotalCents,
136
+ taxCents: invoice.taxCents,
137
+ totalCents: invoice.totalCents,
138
+ };
139
+ await tx.insert(invoiceVersions).values({ organisationId, invoiceId, version, issuedBy, document });
140
+ const [row] = await tx.update(invoices)
141
+ .set({ status: 'issued', number, issuedVersion: version, issueDate, updatedAt: sql`now()` })
142
+ .where(eq(invoices.id, invoice.id))
143
+ .returning();
144
+ return row;
145
+ });
146
+ }
147
+
148
+ // Opens an issued invoice for change. The sent versions are not touched.
149
+ export async function reviseInvoice(db, organisationId, invoiceId) {
150
+ return db.transaction(async (tx) => {
151
+ const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
152
+ if (invoice.status !== 'issued') throw new FlowError('not_issued', 'only an issued invoice can be revised');
153
+ const [row] = await tx.update(invoices).set({ status: 'draft', updatedAt: sql`now()` })
154
+ .where(eq(invoices.id, invoice.id)).returning();
155
+ return row;
156
+ });
157
+ }
158
+
159
+ export async function voidInvoice(db, organisationId, invoiceId) {
160
+ return db.transaction(async (tx) => {
161
+ const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
162
+ if (invoice.status === 'void') return invoice;
163
+ if (invoice.issuedVersion === 0) throw new FlowError('never_issued', 'delete a draft that was never issued; do not void it');
164
+ const [row] = await tx.update(invoices).set({ status: 'void', updatedAt: sql`now()` })
165
+ .where(eq(invoices.id, invoice.id)).returning();
166
+ return row;
167
+ });
168
+ }
169
+
170
+ export async function getInvoice(db, organisationId, invoiceId) {
171
+ const invoice = await loadInvoice(db, organisationId, invoiceId);
172
+ const lines = await db.select().from(invoiceLines)
173
+ .where(eq(invoiceLines.invoiceId, invoice.id)).orderBy(asc(invoiceLines.position));
174
+ return { ...invoice, lines };
175
+ }
176
+
177
+ export async function listInvoiceVersions(db, organisationId, invoiceId) {
178
+ await loadInvoice(db, organisationId, invoiceId);
179
+ return db.select().from(invoiceVersions)
180
+ .where(and(eq(invoiceVersions.organisationId, organisationId), eq(invoiceVersions.invoiceId, invoiceId)))
181
+ .orderBy(asc(invoiceVersions.version));
182
+ }
183
+
184
+ export async function listInvoices(db, organisationId, { status } = {}) {
185
+ const where = [eq(invoices.organisationId, organisationId)];
186
+ if (status) where.push(eq(invoices.status, status));
187
+ return db.select().from(invoices).where(and(...where)).orderBy(desc(invoices.createdAt));
188
+ }
@@ -0,0 +1,38 @@
1
+ // Money is integer cents. GST is 10% and is calculated and rounded per line,
2
+ // half away from zero, on tax-exclusive amounts (the Xero default), so a
3
+ // line's tax never changes when other lines are added.
4
+
5
+ export const GST_RATE_BASIS_POINTS = 1000;
6
+
7
+ function roundHalfAwayFromZero(numerator, denominator) {
8
+ const sign = Math.sign(numerator);
9
+ const abs = Math.abs(numerator);
10
+ return sign * Math.floor((abs * 2 + denominator) / (denominator * 2));
11
+ }
12
+
13
+ export function lineTotals({ quantity, unitAmountCents, taxCode }) {
14
+ if (!Number.isSafeInteger(quantity) || quantity <= 0) {
15
+ throw new TypeError('quantity must be a positive whole number');
16
+ }
17
+ if (!Number.isSafeInteger(unitAmountCents)) {
18
+ throw new TypeError('unitAmountCents must be whole cents');
19
+ }
20
+ if (taxCode !== 'GST' && taxCode !== 'GST_FREE') {
21
+ throw new TypeError(`unknown tax code: ${taxCode}`);
22
+ }
23
+ const lineAmountCents = quantity * unitAmountCents;
24
+ const taxCents = taxCode === 'GST'
25
+ ? roundHalfAwayFromZero(lineAmountCents * GST_RATE_BASIS_POINTS, 10_000)
26
+ : 0;
27
+ return { lineAmountCents, taxCents };
28
+ }
29
+
30
+ export function invoiceTotals(lines) {
31
+ let subtotalCents = 0;
32
+ let taxCents = 0;
33
+ for (const line of lines) {
34
+ subtotalCents += line.lineAmountCents;
35
+ taxCents += line.taxCents;
36
+ }
37
+ return { subtotalCents, taxCents, totalCents: subtotalCents + taxCents };
38
+ }
@@ -0,0 +1,21 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { drizzle } from 'drizzle-orm/node-postgres';
3
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
4
+ import pg from 'pg';
5
+ import * as schema from './schema.js';
6
+
7
+ const migrationsFolder = fileURLToPath(new URL('./migrations', import.meta.url));
8
+
9
+ // Opens Flow's PostgreSQL database. The caller owns the pool: call close().
10
+ export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {}) {
11
+ if (!url) throw new Error('flow: DATABASE_URL is not set');
12
+ const pool = new pg.Pool({ connectionString: url, max });
13
+ const db = drizzle(pool, { schema });
14
+ return { db, pool, close: () => pool.end() };
15
+ }
16
+
17
+ export function migrateDatabase(db) {
18
+ return migrate(db, { migrationsFolder });
19
+ }
20
+
21
+ export { schema };
@@ -0,0 +1,93 @@
1
+ CREATE TABLE "contacts" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "kind" text NOT NULL,
5
+ "name" text NOT NULL,
6
+ "email" text,
7
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
8
+ CONSTRAINT "contacts_org_id" UNIQUE("organisation_id","id"),
9
+ CONSTRAINT "contacts_org_kind_name" UNIQUE("organisation_id","kind","name"),
10
+ CONSTRAINT "contacts_kind_valid" CHECK ("contacts"."kind" in ('customer', 'supplier'))
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE TABLE "invoice_lines" (
14
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15
+ "organisation_id" uuid NOT NULL,
16
+ "invoice_id" uuid NOT NULL,
17
+ "position" integer NOT NULL,
18
+ "price_item_id" uuid,
19
+ "description" text NOT NULL,
20
+ "quantity" integer NOT NULL,
21
+ "unit_amount_cents" integer NOT NULL,
22
+ "tax_code" text NOT NULL,
23
+ "line_amount_cents" integer NOT NULL,
24
+ "tax_cents" integer NOT NULL,
25
+ CONSTRAINT "invoice_lines_invoice_position" UNIQUE("invoice_id","position"),
26
+ CONSTRAINT "invoice_lines_quantity_positive" CHECK ("invoice_lines"."quantity" > 0),
27
+ CONSTRAINT "invoice_lines_tax_code_valid" CHECK ("invoice_lines"."tax_code" in ('GST', 'GST_FREE'))
28
+ );
29
+ --> statement-breakpoint
30
+ CREATE TABLE "invoice_versions" (
31
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
32
+ "organisation_id" uuid NOT NULL,
33
+ "invoice_id" uuid NOT NULL,
34
+ "version" integer NOT NULL,
35
+ "issued_at" timestamp with time zone DEFAULT now() NOT NULL,
36
+ "issued_by" text NOT NULL,
37
+ "document" jsonb NOT NULL,
38
+ CONSTRAINT "invoice_versions_invoice_version" UNIQUE("invoice_id","version"),
39
+ CONSTRAINT "invoice_versions_version_positive" CHECK ("invoice_versions"."version" > 0)
40
+ );
41
+ --> statement-breakpoint
42
+ CREATE TABLE "invoices" (
43
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
44
+ "organisation_id" uuid NOT NULL,
45
+ "contact_id" uuid NOT NULL,
46
+ "number" text,
47
+ "status" text DEFAULT 'draft' NOT NULL,
48
+ "issued_version" integer DEFAULT 0 NOT NULL,
49
+ "reference" text,
50
+ "currency" text DEFAULT 'AUD' NOT NULL,
51
+ "issue_date" date,
52
+ "due_date" date,
53
+ "subtotal_cents" integer DEFAULT 0 NOT NULL,
54
+ "tax_cents" integer DEFAULT 0 NOT NULL,
55
+ "total_cents" integer DEFAULT 0 NOT NULL,
56
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
57
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
58
+ CONSTRAINT "invoices_org_id" UNIQUE("organisation_id","id"),
59
+ CONSTRAINT "invoices_org_number" UNIQUE("organisation_id","number"),
60
+ CONSTRAINT "invoices_status_valid" CHECK ("invoices"."status" in ('draft', 'issued', 'void')),
61
+ CONSTRAINT "invoices_issued_has_number" CHECK ("invoices"."status" = 'draft' or "invoices"."number" is not null)
62
+ );
63
+ --> statement-breakpoint
64
+ CREATE TABLE "organisations" (
65
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
66
+ "name" text NOT NULL,
67
+ "invoice_prefix" text DEFAULT 'INV-' NOT NULL,
68
+ "next_invoice_number" integer DEFAULT 1 NOT NULL,
69
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
70
+ CONSTRAINT "organisations_next_invoice_number_positive" CHECK ("organisations"."next_invoice_number" > 0)
71
+ );
72
+ --> statement-breakpoint
73
+ CREATE TABLE "price_items" (
74
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
75
+ "organisation_id" uuid NOT NULL,
76
+ "code" text NOT NULL,
77
+ "name" text NOT NULL,
78
+ "unit_amount_cents" integer NOT NULL,
79
+ "tax_code" text DEFAULT 'GST' NOT NULL,
80
+ "archived_at" timestamp with time zone,
81
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
82
+ CONSTRAINT "price_items_org_id" UNIQUE("organisation_id","id"),
83
+ CONSTRAINT "price_items_org_code" UNIQUE("organisation_id","code"),
84
+ CONSTRAINT "price_items_tax_code_valid" CHECK ("price_items"."tax_code" in ('GST', 'GST_FREE'))
85
+ );
86
+ --> statement-breakpoint
87
+ ALTER TABLE "contacts" ADD CONSTRAINT "contacts_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
88
+ ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_invoice_same_org" FOREIGN KEY ("organisation_id","invoice_id") REFERENCES "public"."invoices"("organisation_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
89
+ ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_price_item_same_org" FOREIGN KEY ("organisation_id","price_item_id") REFERENCES "public"."price_items"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
90
+ ALTER TABLE "invoice_versions" ADD CONSTRAINT "invoice_versions_invoice_same_org" FOREIGN KEY ("organisation_id","invoice_id") REFERENCES "public"."invoices"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
91
+ ALTER TABLE "invoices" ADD CONSTRAINT "invoices_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
92
+ ALTER TABLE "invoices" ADD CONSTRAINT "invoices_contact_same_org" FOREIGN KEY ("organisation_id","contact_id") REFERENCES "public"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
93
+ ALTER TABLE "price_items" ADD CONSTRAINT "price_items_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
@@ -0,0 +1,65 @@
1
+ -- What a customer was sent is a record. These triggers hold that rule in the
2
+ -- database, so no code path (or manual query) can quietly change it.
3
+
4
+ CREATE FUNCTION flow_refuse_change() RETURNS trigger LANGUAGE plpgsql AS $$
5
+ BEGIN
6
+ RAISE EXCEPTION 'flow: % on % is not allowed; issued records are append-only', TG_OP, TG_TABLE_NAME
7
+ USING ERRCODE = 'restrict_violation';
8
+ END;
9
+ $$;
10
+ --> statement-breakpoint
11
+ CREATE TRIGGER invoice_versions_append_only
12
+ BEFORE UPDATE OR DELETE ON invoice_versions
13
+ FOR EACH ROW EXECUTE FUNCTION flow_refuse_change();
14
+ --> statement-breakpoint
15
+ CREATE FUNCTION flow_invoice_lines_draft_only() RETURNS trigger LANGUAGE plpgsql AS $$
16
+ DECLARE
17
+ parent_status text;
18
+ BEGIN
19
+ SELECT status INTO parent_status FROM invoices
20
+ WHERE id = COALESCE(NEW.invoice_id, OLD.invoice_id);
21
+ -- A cascade from a deleted draft invoice finds no parent row.
22
+ IF parent_status IS NOT NULL AND parent_status <> 'draft' THEN
23
+ RAISE EXCEPTION 'flow: invoice lines change only while the invoice is a draft'
24
+ USING ERRCODE = 'restrict_violation';
25
+ END IF;
26
+ RETURN COALESCE(NEW, OLD);
27
+ END;
28
+ $$;
29
+ --> statement-breakpoint
30
+ CREATE TRIGGER invoice_lines_draft_only
31
+ BEFORE INSERT OR UPDATE OR DELETE ON invoice_lines
32
+ FOR EACH ROW EXECUTE FUNCTION flow_invoice_lines_draft_only();
33
+ --> statement-breakpoint
34
+ CREATE FUNCTION flow_invoice_rules() RETURNS trigger LANGUAGE plpgsql AS $$
35
+ BEGIN
36
+ IF TG_OP = 'DELETE' THEN
37
+ IF OLD.issued_version > 0 THEN
38
+ RAISE EXCEPTION 'flow: an invoice that was issued cannot be deleted; void it'
39
+ USING ERRCODE = 'restrict_violation';
40
+ END IF;
41
+ RETURN OLD;
42
+ END IF;
43
+ IF NEW.organisation_id <> OLD.organisation_id THEN
44
+ RAISE EXCEPTION 'flow: an invoice cannot move to another organisation'
45
+ USING ERRCODE = 'restrict_violation';
46
+ END IF;
47
+ IF OLD.number IS NOT NULL AND NEW.number IS DISTINCT FROM OLD.number THEN
48
+ RAISE EXCEPTION 'flow: an invoice number never changes'
49
+ USING ERRCODE = 'restrict_violation';
50
+ END IF;
51
+ IF OLD.status = 'void' THEN
52
+ RAISE EXCEPTION 'flow: a void invoice cannot change'
53
+ USING ERRCODE = 'restrict_violation';
54
+ END IF;
55
+ IF NEW.issued_version < OLD.issued_version THEN
56
+ RAISE EXCEPTION 'flow: issued_version only goes up'
57
+ USING ERRCODE = 'restrict_violation';
58
+ END IF;
59
+ RETURN NEW;
60
+ END;
61
+ $$;
62
+ --> statement-breakpoint
63
+ CREATE TRIGGER invoices_rules
64
+ BEFORE UPDATE OR DELETE ON invoices
65
+ FOR EACH ROW EXECUTE FUNCTION flow_invoice_rules();
@@ -0,0 +1,19 @@
1
+ CREATE TABLE "people" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "email" text NOT NULL,
4
+ "name" text,
5
+ "role" text NOT NULL,
6
+ "status" text DEFAULT 'active' NOT NULL,
7
+ "clerk_user_id" text,
8
+ "invited_by" uuid,
9
+ "linked_at" timestamp with time zone,
10
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
11
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ CONSTRAINT "people_email" UNIQUE("email"),
13
+ CONSTRAINT "people_clerk_user_id" UNIQUE("clerk_user_id"),
14
+ CONSTRAINT "people_email_lower" CHECK ("people"."email" = lower("people"."email")),
15
+ CONSTRAINT "people_role_valid" CHECK ("people"."role" in ('owner', 'admin', 'staff')),
16
+ CONSTRAINT "people_status_valid" CHECK ("people"."status" in ('active', 'pending', 'disabled'))
17
+ );
18
+ --> statement-breakpoint
19
+ ALTER TABLE "people" ADD CONSTRAINT "people_invited_by_people_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;