@12-apps/prisma 6.1.1 → 6.3.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/prisma",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Prisma host: the multi-file schema folder, the plugin migration seam, and the shared PrismaClient singleton with its audit / append-only extensions",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
|
|
15
|
+
"build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-discounts-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
|
|
16
16
|
"clean": "rm -rf dist node_modules coverage",
|
|
17
17
|
"test": "node ../../scripts/vitest-with-teardown.mjs run",
|
|
18
18
|
"test:watch": "vitest watch",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"lint": "pnpm run lint:files .",
|
|
21
21
|
"lint:fix": "bash -c 'eslint \"${@:-.}\" --max-warnings 0 --fix' _",
|
|
22
22
|
"typecheck": "tsc --noEmit",
|
|
23
|
-
"prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-auth-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
|
|
23
|
+
"prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-auth-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-discounts-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
|
|
24
24
|
"prisma:migrate": "prisma migrate dev",
|
|
25
25
|
"prisma:push": "prisma db push",
|
|
26
26
|
"prisma:studio": "prisma studio",
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
|
|
54
54
|
"prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check",
|
|
55
55
|
"prisma:sync-auth": "node scripts/sync-auth-schema.mjs",
|
|
56
|
-
"prisma:sync-auth:check": "node scripts/sync-auth-schema.mjs --check"
|
|
56
|
+
"prisma:sync-auth:check": "node scripts/sync-auth-schema.mjs --check",
|
|
57
|
+
"prisma:sync-discounts": "node scripts/sync-discounts-schema.mjs",
|
|
58
|
+
"prisma:sync-discounts:check": "node scripts/sync-discounts-schema.mjs --check"
|
|
57
59
|
},
|
|
58
60
|
"dependencies": {
|
|
59
61
|
"@electric-sql/pglite": "0.2.17",
|
|
@@ -63,23 +65,24 @@
|
|
|
63
65
|
"pglite-prisma-adapter": "0.7.2"
|
|
64
66
|
},
|
|
65
67
|
"devDependencies": {
|
|
66
|
-
"@12-apps/audit": "^5.0.
|
|
67
|
-
"@12-apps/auth": "^2.
|
|
68
|
-
"@12-apps/
|
|
69
|
-
"@12-apps/
|
|
70
|
-
"@12-apps/
|
|
71
|
-
"@12-apps/
|
|
72
|
-
"@12-apps/
|
|
73
|
-
"@12-apps/
|
|
74
|
-
"@12-apps/
|
|
75
|
-
"@12-apps/
|
|
76
|
-
"@12-apps/
|
|
77
|
-
"@12-apps/
|
|
78
|
-
"@12-apps/
|
|
79
|
-
"@12-apps/
|
|
80
|
-
"@12-apps/
|
|
81
|
-
"@12-apps/
|
|
82
|
-
"@12-apps/
|
|
68
|
+
"@12-apps/audit": "^5.0.2",
|
|
69
|
+
"@12-apps/auth": "^2.8.0",
|
|
70
|
+
"@12-apps/discounts": "^1.3.0",
|
|
71
|
+
"@12-apps/entitlements": "^3.2.0",
|
|
72
|
+
"@12-apps/entity-lifecycle": "^4.4.0",
|
|
73
|
+
"@12-apps/eslint-config": "^1.21.1",
|
|
74
|
+
"@12-apps/feature-flags": "^2.1.1",
|
|
75
|
+
"@12-apps/jobs": "^4.4.0",
|
|
76
|
+
"@12-apps/mcp": "^3.5.1",
|
|
77
|
+
"@12-apps/notifications": "^4.2.0",
|
|
78
|
+
"@12-apps/onboarding": "^2.1.0",
|
|
79
|
+
"@12-apps/payments-backend": "^4.16.0",
|
|
80
|
+
"@12-apps/product-research": "^2.3.0",
|
|
81
|
+
"@12-apps/rbac": "^4.2.0",
|
|
82
|
+
"@12-apps/realtime": "^2.3.0",
|
|
83
|
+
"@12-apps/report-builder": "^5.4.1",
|
|
84
|
+
"@12-apps/shift": "^3.3.0",
|
|
85
|
+
"@12-apps/typescript-config": "^1.20.1",
|
|
83
86
|
"@types/node": "^22.10.6",
|
|
84
87
|
"@vitest/coverage-v8": "^3.2.4",
|
|
85
88
|
"eslint": "^9.39.1",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- @12-apps/shift: `kind` carries HOST vocabulary, so the database stops naming it.
|
|
2
|
+
--
|
|
3
|
+
-- `shifts_kind_check` restricted every adopter's `shifts.kind` to two values
|
|
4
|
+
-- taken from the staff structure of the application this package was extracted
|
|
5
|
+
-- from. That is the most structural form this leak can take. A leaked string is
|
|
6
|
+
-- cosmetic and a leaked type union is at least deletable in a major; a CHECK
|
|
7
|
+
-- constraint is a fact recorded in the adopter's own database, and a host whose
|
|
8
|
+
-- workers are not organised into those two groups could not insert a row at all
|
|
9
|
+
-- — no configuration this package offered could reach it.
|
|
10
|
+
--
|
|
11
|
+
-- What replaces it is the guarantee the package genuinely owns: `kind` is
|
|
12
|
+
-- present and not blank. WHICH kinds exist is now stated by the host and
|
|
13
|
+
-- enforced at the open, by the service, against the vocabulary it was
|
|
14
|
+
-- constructed with (`createShiftService(db, { kinds })`).
|
|
15
|
+
--
|
|
16
|
+
-- Deliberately NOT touched: `shifts_ended_reason_check`. Those three values are
|
|
17
|
+
-- this package's own vocabulary — a shift ends because a worker said so, a
|
|
18
|
+
-- supervisor said so, or the sweep did — and no adopter renames them.
|
|
19
|
+
--
|
|
20
|
+
-- Existing rows all satisfy the replacement, so this is safe to deploy against
|
|
21
|
+
-- a live table with no backfill.
|
|
22
|
+
ALTER TABLE "shifts" DROP CONSTRAINT IF EXISTS "shifts_kind_check";
|
|
23
|
+
|
|
24
|
+
ALTER TABLE "shifts"
|
|
25
|
+
ADD CONSTRAINT "shifts_kind_present_check" CHECK (btrim("kind") <> '');
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
-- @12-apps/discounts: the promotions tables, owned by the package and copied
|
|
2
|
+
-- into a host's migrations folder by its plugin-migration sync. Runs
|
|
3
|
+
-- identically on PostgreSQL and PGlite.
|
|
4
|
+
--
|
|
5
|
+
-- ── REPLAY-SAFE ON PURPOSE ────────────────────────────────────────────────
|
|
6
|
+
-- Every statement here is idempotent, because the first host to adopt this
|
|
7
|
+
-- package already HAS a `discounts` table created by its own, earlier
|
|
8
|
+
-- migration. A package migration is applied by name order, so this one sorts
|
|
9
|
+
-- AFTER the host's — and a bare `CREATE TABLE` would then fail
|
|
10
|
+
-- `prisma migrate deploy` on an existing database AND on a fresh one built from
|
|
11
|
+
-- the full folder. `prisma migrate resolve --applied` can only paper over the
|
|
12
|
+
-- first case, by hand, once per database. So this migration ADOPTS an existing
|
|
13
|
+
-- table instead of demanding a baseline:
|
|
14
|
+
--
|
|
15
|
+
-- * a fresh host gets the three tables, every index and every CHECK;
|
|
16
|
+
-- * a host that already has `discounts` gets only what it is missing — the
|
|
17
|
+
-- three combo columns, the two new tables, and the widened CHECKs;
|
|
18
|
+
-- * replaying the whole folder is a no-op.
|
|
19
|
+
--
|
|
20
|
+
-- What it deliberately does NOT do is MOVE a host's existing target rows. A
|
|
21
|
+
-- host adopting this package has `discount_categories` / `discount_items` rows
|
|
22
|
+
-- that belong in `discount_targets`, and only that host knows whether its
|
|
23
|
+
-- catalog tables are named what this package would have to guess. Backfilling
|
|
24
|
+
-- and dropping the old tables is one migration in the host, written once, and
|
|
25
|
+
-- it sorts after this one.
|
|
26
|
+
--
|
|
27
|
+
-- ── NO FOREIGN KEYS INTO HOST TABLES ──────────────────────────────────────
|
|
28
|
+
-- `client_id` and `target_id` are by-value scalars (the payments-backend
|
|
29
|
+
-- doctrine). The relations INTERNAL to these three tables ARE constrained, with
|
|
30
|
+
-- cascades, so a deleted rule can never leave orphan targets or orphan slots.
|
|
31
|
+
-- A host that wants referential integrity against its own catalog adds those
|
|
32
|
+
-- constraints in its own migration — and should first read the partial's note
|
|
33
|
+
-- on what the absence actually costs.
|
|
34
|
+
|
|
35
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
-- discounts — the rule itself.
|
|
37
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
CREATE TABLE IF NOT EXISTS "discounts" (
|
|
39
|
+
"id" TEXT NOT NULL,
|
|
40
|
+
"client_id" TEXT NOT NULL,
|
|
41
|
+
"name" TEXT NOT NULL,
|
|
42
|
+
"type" TEXT NOT NULL,
|
|
43
|
+
"percent_off_bp" INTEGER,
|
|
44
|
+
"amount_off_cents" INTEGER,
|
|
45
|
+
"bundle_price_cents" INTEGER,
|
|
46
|
+
"free_units" INTEGER,
|
|
47
|
+
"max_combo_applications" INTEGER,
|
|
48
|
+
"scope" TEXT NOT NULL,
|
|
49
|
+
"trigger" TEXT NOT NULL,
|
|
50
|
+
"code" TEXT,
|
|
51
|
+
"starts_at" TIMESTAMP(3),
|
|
52
|
+
"ends_at" TIMESTAMP(3),
|
|
53
|
+
"min_subtotal_cents" INTEGER,
|
|
54
|
+
"usage_limit" INTEGER,
|
|
55
|
+
"per_buyer_limit" INTEGER,
|
|
56
|
+
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
"stackable" BOOLEAN NOT NULL DEFAULT true,
|
|
58
|
+
"active" BOOLEAN NOT NULL DEFAULT true,
|
|
59
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
60
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
61
|
+
"created_by" TEXT,
|
|
62
|
+
"updated_by" TEXT,
|
|
63
|
+
"search_name" TEXT,
|
|
64
|
+
"archived_at" TIMESTAMP(3),
|
|
65
|
+
|
|
66
|
+
CONSTRAINT "discounts_pkey" PRIMARY KEY ("id")
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
-- The three combo columns, for a host whose `discounts` predates them.
|
|
70
|
+
ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "bundle_price_cents" INTEGER;
|
|
71
|
+
ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "free_units" INTEGER;
|
|
72
|
+
ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "max_combo_applications" INTEGER;
|
|
73
|
+
|
|
74
|
+
-- One live name and one live coupon per tenant. PARTIAL on purpose: deleting is
|
|
75
|
+
-- a soft archive, and an unconditional unique lets a deleted coupon squat its
|
|
76
|
+
-- code forever, so the operator can never recreate a code they removed. NULLs
|
|
77
|
+
-- stay distinct, so the many automatic rules (all `code` NULL) never collide.
|
|
78
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "discounts_client_id_name_key"
|
|
79
|
+
ON "discounts"("client_id", "name") WHERE "archived_at" IS NULL;
|
|
80
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "discounts_client_id_code_key"
|
|
81
|
+
ON "discounts"("client_id", "code") WHERE "archived_at" IS NULL;
|
|
82
|
+
|
|
83
|
+
-- The storefront's "which rules could fire right now", and the admin list's
|
|
84
|
+
-- default active-first ordering.
|
|
85
|
+
CREATE INDEX IF NOT EXISTS "discounts_client_id_active_archived_at_idx"
|
|
86
|
+
ON "discounts"("client_id", "active", "archived_at");
|
|
87
|
+
|
|
88
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
-- discount_combo_slots — the quantified groups a COMBO matches.
|
|
90
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
CREATE TABLE IF NOT EXISTS "discount_combo_slots" (
|
|
92
|
+
"id" TEXT NOT NULL,
|
|
93
|
+
"discount_id" TEXT NOT NULL,
|
|
94
|
+
-- The operator's order, which is also the order a card describes the combo
|
|
95
|
+
-- in. Stable because two slots of the same size are otherwise
|
|
96
|
+
-- indistinguishable in a list.
|
|
97
|
+
"position" INTEGER NOT NULL,
|
|
98
|
+
"quantity" INTEGER NOT NULL,
|
|
99
|
+
|
|
100
|
+
CONSTRAINT "discount_combo_slots_pkey" PRIMARY KEY ("id")
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "discount_combo_slots_discount_id_position_key"
|
|
104
|
+
ON "discount_combo_slots"("discount_id", "position");
|
|
105
|
+
|
|
106
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
-- discount_targets — what a rule points at, BY VALUE.
|
|
108
|
+
--
|
|
109
|
+
-- One table for both jobs, told apart by `slot_id`: NULL is a SCOPE target,
|
|
110
|
+
-- non-null is one combo slot's. They are the same fact — "this rule cares about
|
|
111
|
+
-- this row of this collection" — and two tables would need every reverse read,
|
|
112
|
+
-- every ownership check and every cascade written twice.
|
|
113
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
CREATE TABLE IF NOT EXISTS "discount_targets" (
|
|
115
|
+
"id" TEXT NOT NULL,
|
|
116
|
+
"discount_id" TEXT NOT NULL,
|
|
117
|
+
"slot_id" TEXT,
|
|
118
|
+
"target_type" TEXT NOT NULL,
|
|
119
|
+
"target_id" TEXT NOT NULL,
|
|
120
|
+
|
|
121
|
+
CONSTRAINT "discount_targets_pkey" PRIMARY KEY ("id")
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
-- PARTIAL uniques, and they have to be: NULLs are distinct in Postgres, so one
|
|
125
|
+
-- unconditional unique over `slot_id` would not constrain the scope rows at all
|
|
126
|
+
-- — a rule could name the same category twice and cover it twice.
|
|
127
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "discount_targets_scope_key"
|
|
128
|
+
ON "discount_targets"("discount_id", "target_type", "target_id")
|
|
129
|
+
WHERE "slot_id" IS NULL;
|
|
130
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "discount_targets_slot_key"
|
|
131
|
+
ON "discount_targets"("slot_id", "target_type", "target_id")
|
|
132
|
+
WHERE "slot_id" IS NOT NULL;
|
|
133
|
+
|
|
134
|
+
-- The reverse read a menu badge does: "which rules touch this row".
|
|
135
|
+
CREATE INDEX IF NOT EXISTS "discount_targets_target_type_target_id_idx"
|
|
136
|
+
ON "discount_targets"("target_type", "target_id");
|
|
137
|
+
CREATE INDEX IF NOT EXISTS "discount_targets_discount_id_idx"
|
|
138
|
+
ON "discount_targets"("discount_id");
|
|
139
|
+
CREATE INDEX IF NOT EXISTS "discount_targets_slot_id_idx"
|
|
140
|
+
ON "discount_targets"("slot_id");
|
|
141
|
+
|
|
142
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
-- Foreign keys — INTERNAL only.
|
|
144
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
DO $$ BEGIN
|
|
146
|
+
ALTER TABLE "discount_combo_slots" ADD CONSTRAINT "discount_combo_slots_discount_id_fkey"
|
|
147
|
+
FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
148
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
149
|
+
|
|
150
|
+
DO $$ BEGIN
|
|
151
|
+
ALTER TABLE "discount_targets" ADD CONSTRAINT "discount_targets_discount_id_fkey"
|
|
152
|
+
FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
153
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
154
|
+
|
|
155
|
+
-- Cascading from the SLOT as well as from the discount: removing one slot of a
|
|
156
|
+
-- combo must take its targets, or the next write would merge them into the slot
|
|
157
|
+
-- that replaced it.
|
|
158
|
+
DO $$ BEGIN
|
|
159
|
+
ALTER TABLE "discount_targets" ADD CONSTRAINT "discount_targets_slot_id_fkey"
|
|
160
|
+
FOREIGN KEY ("slot_id") REFERENCES "discount_combo_slots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
161
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
162
|
+
|
|
163
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
-- CHECK constraints — the String+CHECK house style. The closed sets here and
|
|
165
|
+
-- the arrays in `src/engine/kinds.ts` are two halves of ONE set: widening one
|
|
166
|
+
-- without the other is how a write starts failing at the database rather than
|
|
167
|
+
-- at the validator.
|
|
168
|
+
--
|
|
169
|
+
-- The three that a host may already have in a NARROWER form are dropped and
|
|
170
|
+
-- re-added rather than guarded, because the whole point is to widen them.
|
|
171
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
ALTER TABLE "discounts" DROP CONSTRAINT IF EXISTS "discounts_type_check";
|
|
173
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_type_check"
|
|
174
|
+
CHECK ("type" IN ('PERCENTAGE', 'FIXED_AMOUNT', 'BUNDLE_PRICE', 'FREE_UNITS'));
|
|
175
|
+
|
|
176
|
+
ALTER TABLE "discounts" DROP CONSTRAINT IF EXISTS "discounts_scope_check";
|
|
177
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_scope_check"
|
|
178
|
+
CHECK ("scope" IN ('ORDER', 'CATEGORY', 'ITEM', 'COMBO'));
|
|
179
|
+
|
|
180
|
+
-- Exactly one value column, chosen by the type. A percentage with no rate, or a
|
|
181
|
+
-- bundle with no price, would silently discount nothing.
|
|
182
|
+
ALTER TABLE "discounts" DROP CONSTRAINT IF EXISTS "discounts_value_check";
|
|
183
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_value_check"
|
|
184
|
+
CHECK (("type" = 'PERCENTAGE') = ("percent_off_bp" IS NOT NULL)
|
|
185
|
+
AND ("type" = 'FIXED_AMOUNT') = ("amount_off_cents" IS NOT NULL)
|
|
186
|
+
AND ("type" = 'BUNDLE_PRICE') = ("bundle_price_cents" IS NOT NULL)
|
|
187
|
+
AND ("type" = 'FREE_UNITS') = ("free_units" IS NOT NULL));
|
|
188
|
+
|
|
189
|
+
-- A combo REWARD only means something against a matched group: "the group costs
|
|
190
|
+
-- R$ 25" and "one of them is free" are both statements ABOUT a group.
|
|
191
|
+
DO $$ BEGIN
|
|
192
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_combo_reward_scope_check"
|
|
193
|
+
CHECK ("type" NOT IN ('BUNDLE_PRICE', 'FREE_UNITS') OR "scope" = 'COMBO');
|
|
194
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
195
|
+
|
|
196
|
+
DO $$ BEGIN
|
|
197
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_bundle_price_check"
|
|
198
|
+
CHECK ("bundle_price_cents" IS NULL OR "bundle_price_cents" > 0);
|
|
199
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
200
|
+
|
|
201
|
+
DO $$ BEGIN
|
|
202
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_free_units_check"
|
|
203
|
+
CHECK ("free_units" IS NULL OR "free_units" > 0);
|
|
204
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
205
|
+
|
|
206
|
+
-- A per-cart cap on a rule that is not a combo would be a number nothing reads.
|
|
207
|
+
DO $$ BEGIN
|
|
208
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_max_combo_applications_check"
|
|
209
|
+
CHECK ("max_combo_applications" IS NULL
|
|
210
|
+
OR ("max_combo_applications" > 0 AND "scope" = 'COMBO'));
|
|
211
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
212
|
+
|
|
213
|
+
DO $$ BEGIN
|
|
214
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_trigger_check"
|
|
215
|
+
CHECK ("trigger" IN ('AUTOMATIC', 'CODE'));
|
|
216
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
217
|
+
|
|
218
|
+
-- 0 bp is not a promotion; over 100% would credit the buyer.
|
|
219
|
+
DO $$ BEGIN
|
|
220
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_percent_off_bp_check"
|
|
221
|
+
CHECK ("percent_off_bp" IS NULL
|
|
222
|
+
OR ("percent_off_bp" > 0 AND "percent_off_bp" <= 10000));
|
|
223
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
224
|
+
|
|
225
|
+
DO $$ BEGIN
|
|
226
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_amount_off_cents_check"
|
|
227
|
+
CHECK ("amount_off_cents" IS NULL OR "amount_off_cents" > 0);
|
|
228
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
229
|
+
|
|
230
|
+
-- A CODE rule with no code can never be redeemed; an AUTOMATIC one with a code
|
|
231
|
+
-- would be redeemable two different ways.
|
|
232
|
+
DO $$ BEGIN
|
|
233
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_code_check"
|
|
234
|
+
CHECK (("trigger" = 'CODE') = ("code" IS NOT NULL));
|
|
235
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
236
|
+
|
|
237
|
+
-- An inverted window is always empty; whoever typed it meant something else.
|
|
238
|
+
DO $$ BEGIN
|
|
239
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_window_check"
|
|
240
|
+
CHECK ("ends_at" IS NULL OR "starts_at" IS NULL OR "ends_at" > "starts_at");
|
|
241
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
242
|
+
|
|
243
|
+
DO $$ BEGIN
|
|
244
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_min_subtotal_check"
|
|
245
|
+
CHECK ("min_subtotal_cents" IS NULL OR "min_subtotal_cents" > 0);
|
|
246
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
247
|
+
|
|
248
|
+
DO $$ BEGIN
|
|
249
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_usage_limit_check"
|
|
250
|
+
CHECK ("usage_limit" IS NULL OR "usage_limit" > 0);
|
|
251
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
252
|
+
|
|
253
|
+
DO $$ BEGIN
|
|
254
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_per_buyer_limit_check"
|
|
255
|
+
CHECK ("per_buyer_limit" IS NULL OR "per_buyer_limit" > 0);
|
|
256
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
257
|
+
|
|
258
|
+
DO $$ BEGIN
|
|
259
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_usage_count_check"
|
|
260
|
+
CHECK ("usage_count" >= 0);
|
|
261
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
262
|
+
|
|
263
|
+
-- The invariant the predicated PAID-time UPDATE exists to keep true.
|
|
264
|
+
DO $$ BEGIN
|
|
265
|
+
ALTER TABLE "discounts" ADD CONSTRAINT "discounts_usage_within_limit_check"
|
|
266
|
+
CHECK ("usage_limit" IS NULL OR "usage_count" <= "usage_limit");
|
|
267
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
268
|
+
|
|
269
|
+
-- A slot asking for zero units can never be filled; the upper bound is
|
|
270
|
+
-- MAX_COMBO_SLOT_QUANTITY, and a combo nobody can carry is not an offer.
|
|
271
|
+
DO $$ BEGIN
|
|
272
|
+
ALTER TABLE "discount_combo_slots" ADD CONSTRAINT "discount_combo_slots_quantity_check"
|
|
273
|
+
CHECK ("quantity" > 0 AND "quantity" <= 50);
|
|
274
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
275
|
+
|
|
276
|
+
DO $$ BEGIN
|
|
277
|
+
ALTER TABLE "discount_combo_slots" ADD CONSTRAINT "discount_combo_slots_position_check"
|
|
278
|
+
CHECK ("position" >= 0);
|
|
279
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
280
|
+
|
|
281
|
+
-- The closed set of discountable dimensions. Widening it is a host registering
|
|
282
|
+
-- one more DiscountableCollection, plus this line.
|
|
283
|
+
DO $$ BEGIN
|
|
284
|
+
ALTER TABLE "discount_targets" ADD CONSTRAINT "discount_targets_target_type_check"
|
|
285
|
+
CHECK ("target_type" IN ('CATEGORY', 'ITEM'));
|
|
286
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"20260813140000_add_notification_tables",
|
|
34
34
|
"20260813180000_add_realtime_outbox",
|
|
35
35
|
"20260819230000_auth_email_password",
|
|
36
|
-
"20260820120000_add_user_feature_grants"
|
|
36
|
+
"20260820120000_add_user_feature_grants",
|
|
37
|
+
"20260821120000_shift_kind_host_vocabulary",
|
|
38
|
+
"20260821140000_discounts_package_owned"
|
|
37
39
|
]
|
|
38
40
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @12-apps/discounts — CANONICAL Prisma model partial (plug-and-play).
|
|
3
|
+
//
|
|
4
|
+
// The single source of truth for the three tables a promotions surface owns. A
|
|
5
|
+
// host does NOT copy these models into its schema by hand: it uses Prisma's
|
|
6
|
+
// multi-file schema folder and SYNCS this file into it
|
|
7
|
+
// (`scripts/sync-discounts-schema.mjs`), then discovers the migrations beside
|
|
8
|
+
// it structurally.
|
|
9
|
+
//
|
|
10
|
+
// Host-agnostic by design, and this is the whole reason the shape differs from
|
|
11
|
+
// the origin's:
|
|
12
|
+
//
|
|
13
|
+
// - The TENANT is a by-value `client_id` scalar — no relation to the host's
|
|
14
|
+
// tenant model, whose name this package cannot know.
|
|
15
|
+
// - A TARGET is named BY VALUE via (target_type, target_id) — no FK into the
|
|
16
|
+
// host's catalog tables, so any collection plugs in with zero schema change
|
|
17
|
+
// here. That replaces one join table per dimension, which is what made the
|
|
18
|
+
// old shape unshippable: a partial literally naming `product_categories`
|
|
19
|
+
// and `menu_items` only compiles inside a host that has tables under those
|
|
20
|
+
// names. Which collections are discountable is answered at RUNTIME by a
|
|
21
|
+
// `DiscountableCollection` registration (`src/server/collections.ts`), not
|
|
22
|
+
// by the schema.
|
|
23
|
+
// - `type` / `scope` / `trigger` / `target_type` are Strings; the migration
|
|
24
|
+
// adds the CHECK constraints for the closed sets `src/engine/kinds.ts`
|
|
25
|
+
// declares. Those arrays and these CHECKs are two halves of ONE set.
|
|
26
|
+
//
|
|
27
|
+
// WHAT THE HOST STILL OWNS: the REDEMPTION snapshot (`order_discounts`). It is
|
|
28
|
+
// a child of the host's own order with a cascade, and its whole purpose is to
|
|
29
|
+
// freeze what a buyer received on an order the host owns. Shipping the rule and
|
|
30
|
+
// leaving the receipt behind is the clean cut.
|
|
31
|
+
//
|
|
32
|
+
// WHAT THE BY-VALUE TARGET COSTS, stated plainly: this schema no longer
|
|
33
|
+
// guarantees that deleting a category cannot leave a discount pointing at a
|
|
34
|
+
// ghost. Three things carry that instead — the evaluator already tolerates an
|
|
35
|
+
// unmatched target (it simply covers nothing), `DiscountableOps.ownsAll`
|
|
36
|
+
// refuses a foreign or missing id on every write, and a host that wants the
|
|
37
|
+
// constraint back may add it in its own migration.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// The rule itself: what it takes off, from what, when, and how often.
|
|
41
|
+
//
|
|
42
|
+
// `usage_count` is the live redemption counter, incremented inside the host's
|
|
43
|
+
// PAID confirmation transaction by a predicated UPDATE. That predicate is what
|
|
44
|
+
// makes `usage_count <= usage_limit` an invariant the CHECK can assert; a plain
|
|
45
|
+
// read-then-write under READ COMMITTED would lose increments and break it.
|
|
46
|
+
model Discount {
|
|
47
|
+
id String @id @default(uuid())
|
|
48
|
+
// BY VALUE — no relation to the host's tenant model. Every read and every
|
|
49
|
+
// write carries it: a discount is a direct lever on what a buyer is charged,
|
|
50
|
+
// so a query that forgot its tenant would let one store reprice another's.
|
|
51
|
+
clientId String @map("client_id")
|
|
52
|
+
name String
|
|
53
|
+
// PERCENTAGE | FIXED_AMOUNT | BUNDLE_PRICE | FREE_UNITS (CHECK in migration).
|
|
54
|
+
type String
|
|
55
|
+
// Basis points off, 1..10000. Set iff type = PERCENTAGE.
|
|
56
|
+
percentOffBp Int? @map("percent_off_bp")
|
|
57
|
+
// Cents off. Set iff type = FIXED_AMOUNT. Clamped at application time to what
|
|
58
|
+
// is left, so it can never credit the buyer.
|
|
59
|
+
amountOffCents Int? @map("amount_off_cents")
|
|
60
|
+
// The matched combo group costs exactly this. Set iff type = BUNDLE_PRICE.
|
|
61
|
+
bundlePriceCents Int? @map("bundle_price_cents")
|
|
62
|
+
// The cheapest N units of the matched group are free. Set iff type =
|
|
63
|
+
// FREE_UNITS, and always fewer than the units one application requires — a
|
|
64
|
+
// combo that gives away everything it asks for sells nothing.
|
|
65
|
+
freeUnits Int? @map("free_units")
|
|
66
|
+
// How many times one cart may claim this combo. NULL = as often as it matches.
|
|
67
|
+
maxComboApplications Int? @map("max_combo_applications")
|
|
68
|
+
// ORDER | CATEGORY | ITEM | COMBO (CHECK in migration). ORDER carries no
|
|
69
|
+
// targets; CATEGORY/ITEM resolve through `discount_targets`; COMBO resolves by
|
|
70
|
+
// MATCHING the cart against its quantified slots.
|
|
71
|
+
scope String
|
|
72
|
+
// AUTOMATIC | CODE (CHECK in migration).
|
|
73
|
+
trigger String
|
|
74
|
+
// The coupon the buyer types, upper-cased and unmasked. Non-null iff
|
|
75
|
+
// trigger = CODE. Unique per tenant among LIVE rows only — see the note below.
|
|
76
|
+
code String?
|
|
77
|
+
// Active window, half-open [starts_at, ends_at): `ends_at` is EXCLUSIVE, so
|
|
78
|
+
// an operator setting midnight of the next day means exactly that. Null on
|
|
79
|
+
// either side = open-ended.
|
|
80
|
+
startsAt DateTime? @map("starts_at")
|
|
81
|
+
endsAt DateTime? @map("ends_at")
|
|
82
|
+
// Minimum PRE-DISCOUNT cart subtotal, in cents. Always compared against the
|
|
83
|
+
// untouched subtotal, never a running total — which is what makes the result
|
|
84
|
+
// independent of the order the discounts are applied in.
|
|
85
|
+
minSubtotalCents Int? @map("min_subtotal_cents")
|
|
86
|
+
// Redemptions allowed across every buyer, and per buyer. Null = no cap.
|
|
87
|
+
usageLimit Int? @map("usage_limit")
|
|
88
|
+
perBuyerLimit Int? @map("per_buyer_limit")
|
|
89
|
+
usageCount Int @default(0) @map("usage_count")
|
|
90
|
+
// When false this rule stacks with nothing: if it wins it is the only one
|
|
91
|
+
// applied to the order ("best single discount wins").
|
|
92
|
+
stackable Boolean @default(true)
|
|
93
|
+
// The operator's on/off switch, ORTHOGONAL to `archived_at` (deleted) and to
|
|
94
|
+
// the window (scheduled).
|
|
95
|
+
active Boolean @default(true)
|
|
96
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
97
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
98
|
+
// Change attribution: the host's user id, by value, nullable for system rows.
|
|
99
|
+
createdBy String? @map("created_by")
|
|
100
|
+
updatedBy String? @map("updated_by")
|
|
101
|
+
// Accent/case-insensitive search key over `name`, written by the host.
|
|
102
|
+
searchName String? @map("search_name")
|
|
103
|
+
// Soft delete. A non-null timestamp hides the rule from every live read while
|
|
104
|
+
// its redemption history and its counter survive. Null = live.
|
|
105
|
+
archivedAt DateTime? @map("archived_at")
|
|
106
|
+
|
|
107
|
+
targets DiscountTarget[]
|
|
108
|
+
comboSlots DiscountComboSlot[]
|
|
109
|
+
|
|
110
|
+
// NO @@unique here, and that is an honest declaration rather than an
|
|
111
|
+
// omission. The tenant uniques on (client_id, name) and (client_id, code)
|
|
112
|
+
// exist in the database as PARTIAL indexes, both `WHERE archived_at IS NULL`.
|
|
113
|
+
// Prisma cannot express a filtered unique, and declaring an unconditional one
|
|
114
|
+
// here would be a lie the generated client acts on: it would expose a
|
|
115
|
+
// `clientId_code` findUnique the index cannot serve.
|
|
116
|
+
//
|
|
117
|
+
// Partial because deleting is a SOFT archive: an unconditional unique lets a
|
|
118
|
+
// soft-deleted coupon squat its code forever, so an operator could never
|
|
119
|
+
// recreate a code they had deleted. Live rows still own one name and one code
|
|
120
|
+
// per tenant; archived rows leave the index.
|
|
121
|
+
@@index([clientId, active, archivedAt])
|
|
122
|
+
@@map("discounts")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One quantified slot of a combo: "2 units of anything in Drinks".
|
|
126
|
+
//
|
|
127
|
+
// A child table rather than a JSON column, for the reason the target rows are
|
|
128
|
+
// rows: the slot's own targets need the same by-value shape and the same
|
|
129
|
+
// reverse read, and a JSON blob would put half the combo behind a scan.
|
|
130
|
+
//
|
|
131
|
+
// `position` is the operator's order, which is also the order a card describes
|
|
132
|
+
// the combo in — a stable order matters because two slots of the same size are
|
|
133
|
+
// otherwise indistinguishable in a UI that lists them.
|
|
134
|
+
model DiscountComboSlot {
|
|
135
|
+
id String @id @default(uuid())
|
|
136
|
+
discountId String @map("discount_id")
|
|
137
|
+
position Int
|
|
138
|
+
// Units of this slot ONE application consumes. Bounded by
|
|
139
|
+
// MAX_COMBO_SLOT_QUANTITY (CHECK in migration).
|
|
140
|
+
quantity Int
|
|
141
|
+
|
|
142
|
+
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
|
|
143
|
+
targets DiscountTarget[]
|
|
144
|
+
|
|
145
|
+
@@unique([discountId, position])
|
|
146
|
+
@@map("discount_combo_slots")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// What a discount points at, BY VALUE.
|
|
150
|
+
//
|
|
151
|
+
// One table for both jobs, told apart by `slot_id`: NULL means a SCOPE target
|
|
152
|
+
// (what a CATEGORY- or ITEM-scoped rule covers), non-null means the target of
|
|
153
|
+
// one combo slot. One table because they are the same fact — "this rule cares
|
|
154
|
+
// about this row of this collection" — and two would need every reverse read,
|
|
155
|
+
// every ownership check and every cascade written twice.
|
|
156
|
+
//
|
|
157
|
+
// The uniques are PARTIAL (`WHERE slot_id IS NULL` / `IS NOT NULL`) and
|
|
158
|
+
// therefore live only in the migration, for the same reason the tenant uniques
|
|
159
|
+
// on `Discount` do: NULLs are distinct in Postgres, so one unconditional unique
|
|
160
|
+
// over a nullable column would not constrain the scope rows at all.
|
|
161
|
+
model DiscountTarget {
|
|
162
|
+
id String @id @default(uuid())
|
|
163
|
+
discountId String @map("discount_id")
|
|
164
|
+
// NULL = a scope target. Non-null = a target of that combo slot.
|
|
165
|
+
slotId String? @map("slot_id")
|
|
166
|
+
// CATEGORY | ITEM (CHECK in migration) — which host collection `targetId`
|
|
167
|
+
// names. Widening this set is a host registering one more
|
|
168
|
+
// `DiscountableCollection`, plus this CHECK.
|
|
169
|
+
targetType String @map("target_type")
|
|
170
|
+
// The host row's id. No FK: see the partial's header.
|
|
171
|
+
targetId String @map("target_id")
|
|
172
|
+
|
|
173
|
+
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
|
|
174
|
+
slot DiscountComboSlot? @relation(fields: [slotId], references: [id], onDelete: Cascade)
|
|
175
|
+
|
|
176
|
+
// The reverse read a menu badge does: "which rules touch this row".
|
|
177
|
+
@@index([targetType, targetId])
|
|
178
|
+
@@index([discountId])
|
|
179
|
+
@@index([slotId])
|
|
180
|
+
@@map("discount_targets")
|
|
181
|
+
}
|