@12-apps/prisma 1.0.0 → 1.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/README.md +15 -3
- package/package.json +15 -5
- package/prisma/migrations/20260812120000_add_rbac_tables/migration.sql +113 -0
- package/prisma/migrations/20260812120000_add_retention_watermarks/migration.sql +18 -0
- package/prisma/migrations/20260813120000_add_entity_lifecycle_tables/migration.sql +141 -0
- package/prisma/plugin-migrations.json +4 -1
- package/prisma/schema/entitlements.prisma +35 -0
- package/prisma/schema/payments.prisma +218 -0
- package/prisma/schema/rbac.prisma +157 -0
- package/prisma/schema/report-builder.prisma +69 -0
package/README.md
CHANGED
|
@@ -60,8 +60,15 @@ their own folder, and this package pulls them in:
|
|
|
60
60
|
| `@12-apps/product-research` | `product-research.prisma` | `scripts/sync-research-schema.mjs` |
|
|
61
61
|
| `@12-apps/shift` | `shift.prisma` | `scripts/sync-shift-schema.mjs` |
|
|
62
62
|
| `@12-apps/jobs` | `jobs.prisma` | `scripts/sync-jobs-schema.mjs` |
|
|
63
|
-
| `@12-apps/
|
|
64
|
-
| `@12-apps/
|
|
63
|
+
| `@12-apps/entitlements` | `entitlements.prisma` | `scripts/sync-entitlements-schema.mjs` |
|
|
64
|
+
| `@12-apps/payments-backend` | `payments.prisma` | `scripts/sync-payments-schema.mjs` |
|
|
65
|
+
| `@12-apps/report-builder` | `report-builder.prisma` | `scripts/sync-report-builder-schema.mjs` |
|
|
66
|
+
|
|
67
|
+
Every partial is a committed **COPY** — never a symlink, with no exceptions.
|
|
68
|
+
`payments.prisma` and `report-builder.prisma` used to be symlinks, and the
|
|
69
|
+
published tarball shipped without them: `npm pack` silently drops symlinked
|
|
70
|
+
entries, exactly the way Prisma's migration walk skips a linked migration.
|
|
71
|
+
The `--check` syncs and `package.test.ts`'s no-symlink walk keep it true.
|
|
65
72
|
|
|
66
73
|
Migrations travel separately, through `scripts/sync-prisma-plugins.mjs`, which
|
|
67
74
|
discovers every plugin-owned `migrations` directory **structurally** rather than
|
|
@@ -71,11 +78,16 @@ from a hardcoded list.
|
|
|
71
78
|
deliberately owns no domain models, and no seed command either. The consuming
|
|
72
79
|
application supplies both.
|
|
73
80
|
|
|
74
|
-
|
|
81
|
+
Three rules that came from production incidents, gated by `package.test.ts`:
|
|
75
82
|
|
|
76
83
|
- **Migrations are copied, never symlinked.** Prisma enumerates the migrations
|
|
77
84
|
folder with `lstat`, so a symlinked migration reports `isDirectory() === false`
|
|
78
85
|
and is silently skipped — a green deploy that changed no schema.
|
|
86
|
+
- **Schema partials are copies too — nothing under `prisma/` may be a symlink.**
|
|
87
|
+
`npm pack` drops symlinked entries from the tarball with no warning, so the
|
|
88
|
+
published package shipped a schema folder missing three models. A structural
|
|
89
|
+
walk asserts zero symlinks, and a pack-manifest gate asks `npm pack
|
|
90
|
+
--dry-run` itself that every partial and migration ships.
|
|
79
91
|
- **A partial's owning package must be a declared workspace dependency.**
|
|
80
92
|
`turbo prune` copies only what the dependency graph reaches; an undeclared
|
|
81
93
|
owner is dropped from the build context, the committed partial's source
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/prisma",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.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-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-rbac-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-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-report-builder-schema.mjs --check && node scripts/sync-rbac-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",
|
|
@@ -30,8 +30,16 @@
|
|
|
30
30
|
"prisma:sync-shift:check": "node scripts/sync-shift-schema.mjs --check",
|
|
31
31
|
"prisma:sync-jobs": "node scripts/sync-jobs-schema.mjs",
|
|
32
32
|
"prisma:sync-jobs:check": "node scripts/sync-jobs-schema.mjs --check",
|
|
33
|
+
"prisma:sync-entitlements": "node scripts/sync-entitlements-schema.mjs",
|
|
34
|
+
"prisma:sync-entitlements:check": "node scripts/sync-entitlements-schema.mjs --check",
|
|
35
|
+
"prisma:sync-payments": "node scripts/sync-payments-schema.mjs",
|
|
36
|
+
"prisma:sync-payments:check": "node scripts/sync-payments-schema.mjs --check",
|
|
37
|
+
"prisma:sync-report-builder": "node scripts/sync-report-builder-schema.mjs",
|
|
38
|
+
"prisma:sync-report-builder:check": "node scripts/sync-report-builder-schema.mjs --check",
|
|
33
39
|
"prisma:sync-plugins": "node scripts/sync-prisma-plugins.mjs",
|
|
34
|
-
"prisma:sync-plugins:check": "node scripts/sync-prisma-plugins.mjs --check"
|
|
40
|
+
"prisma:sync-plugins:check": "node scripts/sync-prisma-plugins.mjs --check",
|
|
41
|
+
"prisma:sync-rbac": "node scripts/sync-rbac-schema.mjs",
|
|
42
|
+
"prisma:sync-rbac:check": "node scripts/sync-rbac-schema.mjs --check"
|
|
35
43
|
},
|
|
36
44
|
"dependencies": {
|
|
37
45
|
"@electric-sql/pglite": "0.2.17",
|
|
@@ -41,11 +49,13 @@
|
|
|
41
49
|
"pglite-prisma-adapter": "0.7.2"
|
|
42
50
|
},
|
|
43
51
|
"devDependencies": {
|
|
44
|
-
"@12-apps/
|
|
52
|
+
"@12-apps/entitlements": "^1.20.1",
|
|
53
|
+
"@12-apps/entity-lifecycle": "^2.1.0",
|
|
45
54
|
"@12-apps/eslint-config": "^1.20.0",
|
|
46
55
|
"@12-apps/jobs": "^2.0.0",
|
|
47
56
|
"@12-apps/payments-backend": "^2.0.0",
|
|
48
57
|
"@12-apps/product-research": "^2.0.0",
|
|
58
|
+
"@12-apps/rbac": "^1.19.0",
|
|
49
59
|
"@12-apps/report-builder": "^3.0.0",
|
|
50
60
|
"@12-apps/shift": "^2.0.0",
|
|
51
61
|
"@12-apps/typescript-config": "^1.20.0",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
-- @12-apps/rbac (12-13): the five generic RBAC tables, owned by the package
|
|
2
|
+
-- and copied into a host's migrations folder by its plugin-migration sync.
|
|
3
|
+
-- Deliberately NO foreign keys into host tables (self-contained package
|
|
4
|
+
-- schema, the payments-backend doctrine): `user_id` and `client_id` are
|
|
5
|
+
-- by-value scalars, and the host's own migration may add FK constraints.
|
|
6
|
+
-- Relations INTERNAL to the partial (membership_roles -> memberships/roles)
|
|
7
|
+
-- ARE constrained, with cascades, so a removed membership or deleted role can
|
|
8
|
+
-- never leave a dangling grant. `role`/`kind` carry no CHECK here: the role
|
|
9
|
+
-- catalog is host config, so a closed set would be wrong for every host but
|
|
10
|
+
-- the first.
|
|
11
|
+
|
|
12
|
+
CREATE TABLE "memberships" (
|
|
13
|
+
"id" TEXT NOT NULL,
|
|
14
|
+
"user_id" TEXT NOT NULL,
|
|
15
|
+
"client_id" TEXT NOT NULL,
|
|
16
|
+
"role" TEXT NOT NULL DEFAULT 'CUSTOMER',
|
|
17
|
+
"active" BOOLEAN NOT NULL DEFAULT true,
|
|
18
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
19
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
20
|
+
|
|
21
|
+
CONSTRAINT "memberships_pkey" PRIMARY KEY ("id")
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE UNIQUE INDEX "memberships_user_id_client_id_key" ON "memberships"("user_id", "client_id");
|
|
25
|
+
CREATE INDEX "memberships_client_id_idx" ON "memberships"("client_id");
|
|
26
|
+
CREATE INDEX "memberships_user_id_idx" ON "memberships"("user_id");
|
|
27
|
+
|
|
28
|
+
CREATE TABLE "roles" (
|
|
29
|
+
"id" TEXT NOT NULL,
|
|
30
|
+
"client_id" TEXT,
|
|
31
|
+
"name" TEXT NOT NULL,
|
|
32
|
+
"permissions" TEXT NOT NULL,
|
|
33
|
+
"description" TEXT,
|
|
34
|
+
"is_template" BOOLEAN NOT NULL DEFAULT true,
|
|
35
|
+
"kind" TEXT NOT NULL DEFAULT 'CUSTOM',
|
|
36
|
+
"locked" BOOLEAN NOT NULL DEFAULT false,
|
|
37
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
38
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
39
|
+
"archived_at" TIMESTAMP(3),
|
|
40
|
+
"published_version" INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
|
|
42
|
+
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE UNIQUE INDEX "roles_client_id_name_key" ON "roles"("client_id", "name");
|
|
46
|
+
CREATE INDEX "roles_client_id_idx" ON "roles"("client_id");
|
|
47
|
+
CREATE INDEX "roles_client_id_archived_at_idx" ON "roles"("client_id", "archived_at");
|
|
48
|
+
-- Postgres treats NULL client_id as distinct in the unique above, so TEMPLATE
|
|
49
|
+
-- (platform) role names need their own partial unique index — Prisma cannot
|
|
50
|
+
-- express a filtered index, hence raw SQL.
|
|
51
|
+
CREATE UNIQUE INDEX "roles_template_name_key" ON "roles"("name") WHERE "client_id" IS NULL;
|
|
52
|
+
|
|
53
|
+
CREATE TABLE "membership_roles" (
|
|
54
|
+
"id" TEXT NOT NULL,
|
|
55
|
+
"membership_id" TEXT NOT NULL,
|
|
56
|
+
"role_id" TEXT NOT NULL,
|
|
57
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
58
|
+
|
|
59
|
+
CONSTRAINT "membership_roles_pkey" PRIMARY KEY ("id")
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE UNIQUE INDEX "membership_roles_membership_id_role_id_key" ON "membership_roles"("membership_id", "role_id");
|
|
63
|
+
CREATE INDEX "membership_roles_membership_id_idx" ON "membership_roles"("membership_id");
|
|
64
|
+
CREATE INDEX "membership_roles_role_id_idx" ON "membership_roles"("role_id");
|
|
65
|
+
|
|
66
|
+
ALTER TABLE "membership_roles"
|
|
67
|
+
ADD CONSTRAINT "membership_roles_membership_id_fkey"
|
|
68
|
+
FOREIGN KEY ("membership_id") REFERENCES "memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
69
|
+
ALTER TABLE "membership_roles"
|
|
70
|
+
ADD CONSTRAINT "membership_roles_role_id_fkey"
|
|
71
|
+
FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
72
|
+
|
|
73
|
+
CREATE TABLE "role_assignments" (
|
|
74
|
+
"id" TEXT NOT NULL,
|
|
75
|
+
"user_id" TEXT NOT NULL,
|
|
76
|
+
"role_name" TEXT NOT NULL,
|
|
77
|
+
"scope" TEXT NOT NULL,
|
|
78
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
79
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
80
|
+
|
|
81
|
+
CONSTRAINT "role_assignments_pkey" PRIMARY KEY ("id")
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE UNIQUE INDEX "role_assignments_user_id_role_name_scope_key" ON "role_assignments"("user_id", "role_name", "scope");
|
|
85
|
+
CREATE INDEX "role_assignments_user_id_idx" ON "role_assignments"("user_id");
|
|
86
|
+
CREATE INDEX "role_assignments_scope_idx" ON "role_assignments"("scope");
|
|
87
|
+
|
|
88
|
+
CREATE TABLE "resource_assignments" (
|
|
89
|
+
"id" TEXT NOT NULL,
|
|
90
|
+
"user_id" TEXT NOT NULL,
|
|
91
|
+
"client_id" TEXT NOT NULL,
|
|
92
|
+
"resource_type" TEXT NOT NULL,
|
|
93
|
+
"resource_id" TEXT NOT NULL,
|
|
94
|
+
"valid_from" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
95
|
+
"valid_to" TIMESTAMP(3),
|
|
96
|
+
|
|
97
|
+
CONSTRAINT "resource_assignments_pkey" PRIMARY KEY ("id")
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
CREATE INDEX "resource_assignments_user_id_resource_type_idx" ON "resource_assignments"("user_id", "resource_type") WHERE "valid_to" IS NULL;
|
|
101
|
+
CREATE INDEX "resource_assignments_client_id_resource_type_resource_id_idx" ON "resource_assignments"("client_id", "resource_type", "resource_id");
|
|
102
|
+
|
|
103
|
+
-- At most ONE active assignment per (user, tenant, resource). Two concurrent
|
|
104
|
+
-- requests could each INSERT a new active row; a partial UNIQUE index over the
|
|
105
|
+
-- ACTIVE rows makes the second concurrent insert fail at the DB rather than
|
|
106
|
+
-- relying on a racy app-level check-then-insert. Added while the table is
|
|
107
|
+
-- EMPTY (free) -- it is much harder to add later once live data may already
|
|
108
|
+
-- contain duplicates. Prisma cannot express a filtered index, hence raw SQL
|
|
109
|
+
-- (the same reason as roles_template_name_key above). Revocation stamps
|
|
110
|
+
-- valid_to, so historical rows never collide.
|
|
111
|
+
CREATE UNIQUE INDEX "resource_assignments_active_unique_idx"
|
|
112
|
+
ON "resource_assignments"("user_id", "client_id", "resource_type", "resource_id")
|
|
113
|
+
WHERE "valid_to" IS NULL;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- The "downgrade never deletes" anchor for retention quotas: one row per
|
|
2
|
+
-- (tenant, retention feature) recording when the CURRENT window took effect.
|
|
3
|
+
-- The retention sweep only prunes rows written after "since", so a shrinking
|
|
4
|
+
-- window never retroactively destroys history accumulated while the tenant
|
|
5
|
+
-- was entitled to keep it longer.
|
|
6
|
+
--
|
|
7
|
+
-- No foreign key into any host table on purpose (the payments doctrine):
|
|
8
|
+
-- tenant scoping is a plain client_id column, and the host repository layer
|
|
9
|
+
-- is the tenant boundary.
|
|
10
|
+
CREATE TABLE "retention_watermarks" (
|
|
11
|
+
"client_id" TEXT NOT NULL,
|
|
12
|
+
"feature" TEXT NOT NULL,
|
|
13
|
+
"window_days" INTEGER NOT NULL,
|
|
14
|
+
"since" TIMESTAMP(3) NOT NULL,
|
|
15
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
16
|
+
|
|
17
|
+
CONSTRAINT "retention_watermarks_pkey" PRIMARY KEY ("client_id", "feature")
|
|
18
|
+
);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
-- @12-apps/entity-lifecycle (12-17): the four generic lifecycle tables, owned
|
|
2
|
+
-- by the package and copied into a host's migrations folder by its
|
|
3
|
+
-- plugin-migration sync. Deliberately NO foreign keys into host tables (the
|
|
4
|
+
-- payments-backend doctrine): `client_id`, `entity_id` and the actor ids are
|
|
5
|
+
-- by-value scalars, and the host's own migration may add FK constraints
|
|
6
|
+
-- (recommended: client_id ON DELETE CASCADE). The tree relation INTERNAL to
|
|
7
|
+
-- recycle_bin_entries IS constrained, with a cascade, so a removed root can
|
|
8
|
+
-- never leave orphan children. The CHECK constraints ARE here: kind/status/
|
|
9
|
+
-- action are the library's own closed sets, not host vocabulary.
|
|
10
|
+
--
|
|
11
|
+
-- Runs identically on PostgreSQL + PGlite.
|
|
12
|
+
|
|
13
|
+
-- ---------------------------------------------------------------------------
|
|
14
|
+
-- Version history. v1 (and any retention-compaction base) is a FULL snapshot;
|
|
15
|
+
-- other rows are DELTAS (changed top-level fields + removed field names). The
|
|
16
|
+
-- state at version N is rebuilt by replaying rows 1..N. Append-only.
|
|
17
|
+
-- ---------------------------------------------------------------------------
|
|
18
|
+
CREATE TABLE "entity_versions" (
|
|
19
|
+
"id" TEXT NOT NULL,
|
|
20
|
+
"client_id" TEXT NOT NULL,
|
|
21
|
+
"entity_type" TEXT NOT NULL,
|
|
22
|
+
"entity_id" TEXT NOT NULL,
|
|
23
|
+
"version" INTEGER NOT NULL,
|
|
24
|
+
"kind" TEXT NOT NULL,
|
|
25
|
+
"is_snapshot" BOOLEAN NOT NULL,
|
|
26
|
+
"data" JSONB NOT NULL,
|
|
27
|
+
"removed_fields" JSONB NOT NULL DEFAULT '[]',
|
|
28
|
+
"actor_id" TEXT,
|
|
29
|
+
"restored_from" INTEGER,
|
|
30
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
31
|
+
|
|
32
|
+
CONSTRAINT "entity_versions_pkey" PRIMARY KEY ("id")
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE UNIQUE INDEX "entity_versions_client_id_entity_type_entity_id_version_key"
|
|
36
|
+
ON "entity_versions"("client_id", "entity_type", "entity_id", "version");
|
|
37
|
+
CREATE INDEX "entity_versions_client_id_entity_type_entity_id_idx"
|
|
38
|
+
ON "entity_versions"("client_id", "entity_type", "entity_id");
|
|
39
|
+
-- Serves the retention sweep ("versions older than N days").
|
|
40
|
+
CREATE INDEX "entity_versions_created_at_idx" ON "entity_versions"("created_at");
|
|
41
|
+
|
|
42
|
+
ALTER TABLE "entity_versions" ADD CONSTRAINT "entity_versions_kind_check"
|
|
43
|
+
CHECK ("kind" IN ('CREATE', 'UPDATE', 'RESTORE'));
|
|
44
|
+
ALTER TABLE "entity_versions" ADD CONSTRAINT "entity_versions_version_positive"
|
|
45
|
+
CHECK ("version" >= 1);
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- Recycle bin. A deletion records a TREE (root + one child per dependent
|
|
49
|
+
-- record, linked via parent_entry_id). Restore/purge keep the row as an audit
|
|
50
|
+
-- trail (status flip, never a delete).
|
|
51
|
+
-- ---------------------------------------------------------------------------
|
|
52
|
+
CREATE TABLE "recycle_bin_entries" (
|
|
53
|
+
"id" TEXT NOT NULL,
|
|
54
|
+
"client_id" TEXT NOT NULL,
|
|
55
|
+
"entity_type" TEXT NOT NULL,
|
|
56
|
+
"entity_id" TEXT NOT NULL,
|
|
57
|
+
"parent_entry_id" TEXT,
|
|
58
|
+
"label" TEXT NOT NULL,
|
|
59
|
+
"snapshot" JSONB NOT NULL,
|
|
60
|
+
"status" TEXT NOT NULL DEFAULT 'DELETED',
|
|
61
|
+
"deleted_by" TEXT,
|
|
62
|
+
"deleted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
63
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
64
|
+
|
|
65
|
+
CONSTRAINT "recycle_bin_entries_pkey" PRIMARY KEY ("id")
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX "recycle_bin_entries_client_id_status_deleted_at_idx"
|
|
69
|
+
ON "recycle_bin_entries"("client_id", "status", "deleted_at");
|
|
70
|
+
CREATE INDEX "recycle_bin_entries_client_id_entity_type_entity_id_idx"
|
|
71
|
+
ON "recycle_bin_entries"("client_id", "entity_type", "entity_id");
|
|
72
|
+
CREATE INDEX "recycle_bin_entries_parent_entry_id_idx"
|
|
73
|
+
ON "recycle_bin_entries"("parent_entry_id");
|
|
74
|
+
|
|
75
|
+
ALTER TABLE "recycle_bin_entries" ADD CONSTRAINT "recycle_bin_entries_status_check"
|
|
76
|
+
CHECK ("status" IN ('DELETED', 'RESTORED', 'PURGED'));
|
|
77
|
+
ALTER TABLE "recycle_bin_entries" ADD CONSTRAINT "recycle_bin_entries_parent_entry_id_fkey"
|
|
78
|
+
FOREIGN KEY ("parent_entry_id") REFERENCES "recycle_bin_entries"("id")
|
|
79
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
80
|
+
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
-- Drafts. entity_id NULL = draft of a brand-new item. At most one OPEN draft
|
|
83
|
+
-- per (tenant, type, entity) — a partial unique index, which Prisma cannot
|
|
84
|
+
-- express in the schema (hence raw SQL here).
|
|
85
|
+
-- ---------------------------------------------------------------------------
|
|
86
|
+
CREATE TABLE "entity_drafts" (
|
|
87
|
+
"id" TEXT NOT NULL,
|
|
88
|
+
"client_id" TEXT NOT NULL,
|
|
89
|
+
"entity_type" TEXT NOT NULL,
|
|
90
|
+
"entity_id" TEXT,
|
|
91
|
+
"data" JSONB NOT NULL,
|
|
92
|
+
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
|
93
|
+
"created_by" TEXT,
|
|
94
|
+
"updated_by" TEXT,
|
|
95
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
96
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
97
|
+
|
|
98
|
+
CONSTRAINT "entity_drafts_pkey" PRIMARY KEY ("id")
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
CREATE INDEX "entity_drafts_client_id_entity_type_status_idx"
|
|
102
|
+
ON "entity_drafts"("client_id", "entity_type", "status");
|
|
103
|
+
CREATE INDEX "entity_drafts_client_id_entity_type_entity_id_idx"
|
|
104
|
+
ON "entity_drafts"("client_id", "entity_type", "entity_id");
|
|
105
|
+
CREATE UNIQUE INDEX "entity_drafts_open_entity_key"
|
|
106
|
+
ON "entity_drafts"("client_id", "entity_type", "entity_id")
|
|
107
|
+
WHERE "status" = 'OPEN' AND "entity_id" IS NOT NULL;
|
|
108
|
+
|
|
109
|
+
ALTER TABLE "entity_drafts" ADD CONSTRAINT "entity_drafts_status_check"
|
|
110
|
+
CHECK ("status" IN ('OPEN', 'PUBLISHED', 'DISCARDED'));
|
|
111
|
+
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
-- Change approvals. Writes by non-approvers park here; deciders apply/reject.
|
|
114
|
+
-- Decided rows are kept as an audit trail.
|
|
115
|
+
-- ---------------------------------------------------------------------------
|
|
116
|
+
CREATE TABLE "change_requests" (
|
|
117
|
+
"id" TEXT NOT NULL,
|
|
118
|
+
"client_id" TEXT NOT NULL,
|
|
119
|
+
"entity_type" TEXT NOT NULL,
|
|
120
|
+
"entity_id" TEXT,
|
|
121
|
+
"action" TEXT NOT NULL,
|
|
122
|
+
"payload" JSONB NOT NULL,
|
|
123
|
+
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
|
124
|
+
"requested_by" TEXT,
|
|
125
|
+
"requested_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
126
|
+
"decided_by" TEXT,
|
|
127
|
+
"decided_at" TIMESTAMP(3),
|
|
128
|
+
"decision_note" TEXT,
|
|
129
|
+
|
|
130
|
+
CONSTRAINT "change_requests_pkey" PRIMARY KEY ("id")
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
CREATE INDEX "change_requests_client_id_status_requested_at_idx"
|
|
134
|
+
ON "change_requests"("client_id", "status", "requested_at");
|
|
135
|
+
CREATE INDEX "change_requests_client_id_entity_type_entity_id_idx"
|
|
136
|
+
ON "change_requests"("client_id", "entity_type", "entity_id");
|
|
137
|
+
|
|
138
|
+
ALTER TABLE "change_requests" ADD CONSTRAINT "change_requests_action_check"
|
|
139
|
+
CHECK ("action" IN ('CREATE', 'UPDATE', 'DELETE'));
|
|
140
|
+
ALTER TABLE "change_requests" ADD CONSTRAINT "change_requests_status_check"
|
|
141
|
+
CHECK ("status" IN ('PENDING', 'APPROVED', 'REJECTED'));
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"20260731210000_shift_delete_guard",
|
|
24
24
|
"20260810120000_add_report_default_range",
|
|
25
25
|
"20260810160000_report_default_range_month",
|
|
26
|
-
"20260810180000_add_report_working_copy"
|
|
26
|
+
"20260810180000_add_report_working_copy",
|
|
27
|
+
"20260812120000_add_rbac_tables",
|
|
28
|
+
"20260812120000_add_retention_watermarks",
|
|
29
|
+
"20260813120000_add_entity_lifecycle_tables"
|
|
27
30
|
]
|
|
28
31
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// OWNED BY @12-apps/entitlements — the host's multi-file schema folder holds
|
|
3
|
+
// only a copy/symlink of this file (created/verified by `pnpm --filter
|
|
4
|
+
// @12-apps/entitlements prisma:sync`): the retention persistence model lives
|
|
5
|
+
// here, in the package, never in the application.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately self-contained (the payments-backend doctrine): tenant scoping
|
|
8
|
+
// is a `client_id` String column — NO foreign key into any host table — so the
|
|
9
|
+
// model works in every repo without schema coupling. The host repository layer
|
|
10
|
+
// is the tenant boundary: every read/write is `clientId`-scoped.
|
|
11
|
+
//
|
|
12
|
+
// Note what is NOT here: Plan, Subscription, PlanChangeRequest. Those are
|
|
13
|
+
// BILLING models and stay in the host — this package must not learn about
|
|
14
|
+
// money. The only table the entitlement machinery itself needs is the
|
|
15
|
+
// retention watermark below.
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
// The "downgrade never deletes" anchor for retention quotas: one row per
|
|
19
|
+
// (tenant, retention feature) recording when the CURRENT window took effect.
|
|
20
|
+
// The retention sweep only prunes rows written after `since`, so a shrinking
|
|
21
|
+
// window never retroactively destroys history accumulated while the tenant
|
|
22
|
+
// was entitled to keep it longer (see src/server/retention.ts).
|
|
23
|
+
model RetentionWatermark {
|
|
24
|
+
clientId String @map("client_id")
|
|
25
|
+
/// The quota feature key, e.g. "audit.retention_days".
|
|
26
|
+
feature String
|
|
27
|
+
/// The window (days) in force since `since`.
|
|
28
|
+
windowDays Int @map("window_days")
|
|
29
|
+
/// When the sweep first observed the current window for this tenant.
|
|
30
|
+
since DateTime
|
|
31
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
32
|
+
|
|
33
|
+
@@id([clientId, feature])
|
|
34
|
+
@@map("retention_watermarks")
|
|
35
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// OWNED BY @12-apps/payments-backend — the host's multi-file schema folder holds
|
|
3
|
+
// only a SYMLINK to this file (created/verified by `pnpm --filter
|
|
4
|
+
// @12-apps/payments-backend prisma:sync`): every payment model lives here, in
|
|
5
|
+
// the package, never in the application.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately self-contained: merchant scoping is (merchant_kind,
|
|
8
|
+
// merchant_id) String columns — NO foreign key into any host table — so the
|
|
9
|
+
// same three models work in every repo (and in a future standalone payments
|
|
10
|
+
// service) without schema coupling. String-over-enum house style; the CHECK
|
|
11
|
+
// constraints live in the migration.
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
// One (merchant, provider) connection: enablement, rank in the failover
|
|
15
|
+
// chain, active environment, verification state, and the AES-encrypted
|
|
16
|
+
// credential blob (both environments' decrypted shape is
|
|
17
|
+
// `StoredProviderConfig.environments`, ciphered as ONE JSON blob by the
|
|
18
|
+
// host's cipher — never plaintext at rest).
|
|
19
|
+
model PaymentProviderConfig {
|
|
20
|
+
id String @id @default(uuid())
|
|
21
|
+
// PLATFORM | TENANT (CHECK in migration).
|
|
22
|
+
merchantKind String @map("merchant_kind")
|
|
23
|
+
merchantId String @map("merchant_id")
|
|
24
|
+
provider String
|
|
25
|
+
// In the failover chain (enabled) vs merely configured. A merchant may
|
|
26
|
+
// keep credentials for a provider it has taken out of rotation.
|
|
27
|
+
enabled Boolean @default(false)
|
|
28
|
+
// Rank in the failover chain, ASCENDING — 0 is tried first. Meaningful
|
|
29
|
+
// only while `enabled`; the migration's partial unique index keeps ranks
|
|
30
|
+
// distinct among a merchant's enabled rows so routing is never ambiguous.
|
|
31
|
+
priority Int @default(0)
|
|
32
|
+
// SANDBOX | PRODUCTION (CHECK in migration).
|
|
33
|
+
environment String @default("SANDBOX")
|
|
34
|
+
// UNVERIFIED | VERIFIED | FAILED | RECONNECT_REQUIRED (CHECK in migration).
|
|
35
|
+
status String @default("UNVERIFIED")
|
|
36
|
+
lastVerifiedAt DateTime? @map("last_verified_at")
|
|
37
|
+
// When a real charge through THIS connection last succeeded — the only
|
|
38
|
+
// thing that may put the row in the failover chain (FUT-463).
|
|
39
|
+
//
|
|
40
|
+
// Distinct from `lastVerifiedAt`, which the credential PROBE stamps: the
|
|
41
|
+
// probe asks "do these keys authenticate", and a PagBank connection answers
|
|
42
|
+
// yes while refusing every real charge with `403 ACCESS_DENIED` until the
|
|
43
|
+
// integration is homologated. A store read `VERIFIED`, was switched on by
|
|
44
|
+
// hand, and every shopper was declined.
|
|
45
|
+
//
|
|
46
|
+
// Its own nullable column rather than being inferred from `enabled`, because
|
|
47
|
+
// the two answer different questions: this one is "was it ever proven",
|
|
48
|
+
// which must SURVIVE an owner switching the provider off and back on.
|
|
49
|
+
chargeVerifiedAt DateTime? @map("charge_verified_at")
|
|
50
|
+
// The activation charge currently OUTSTANDING: `{ reference, checkoutUrl,
|
|
51
|
+
// slug, startedAt }`, or null when none is in flight (FUT-463).
|
|
52
|
+
//
|
|
53
|
+
// It has to be durable, and the reason is money. The attempt used to live in
|
|
54
|
+
// React state, so a refresh — or simply coming back to the tab after paying
|
|
55
|
+
// on the provider's site — erased it and offered to "generate a charge"
|
|
56
|
+
// again. That mints a SECOND real charge on the owner's own card, and the
|
|
57
|
+
// first one, already paid, is never looked at again.
|
|
58
|
+
//
|
|
59
|
+
// It also carries what confirmation needs and creation is the only chance to
|
|
60
|
+
// learn: InfinitePay's `payment_check` wants `slug` (the invoice code, in the
|
|
61
|
+
// link response) alongside `order_nsu`, and asking with the reference alone
|
|
62
|
+
// answers "not paid" forever — which is exactly what it did to a genuinely
|
|
63
|
+
// paid R$ 1,01, right up to the ten-minute timeout that then blamed the owner
|
|
64
|
+
// for not paying in time.
|
|
65
|
+
pendingVerification Json? @map("pending_verification")
|
|
66
|
+
// When an OAuth connection stops working. Its own column (not inside the
|
|
67
|
+
// encrypted blob) so a refresh sweep can query it; null for credential-mode
|
|
68
|
+
// providers, which never expire.
|
|
69
|
+
expiresAt DateTime? @map("expires_at")
|
|
70
|
+
stub Boolean @default(false)
|
|
71
|
+
// Cipher text of the JSON environments blob (host cipher, AES-256-GCM).
|
|
72
|
+
credentials String
|
|
73
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
74
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
75
|
+
|
|
76
|
+
@@unique([merchantKind, merchantId, provider])
|
|
77
|
+
@@index([merchantKind, merchantId])
|
|
78
|
+
// The migration also carries a PARTIAL unique index on
|
|
79
|
+
// (merchant_kind, merchant_id, priority) WHERE enabled — two enabled
|
|
80
|
+
// providers can never share a rank, so the chain order is total. Partial
|
|
81
|
+
// indexes cannot be expressed here, same as the CHECK constraints above.
|
|
82
|
+
@@map("payment_provider_configs")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Merchant-level payment settings — the things that belong to the STORE
|
|
86
|
+
// rather than to any one provider connection.
|
|
87
|
+
//
|
|
88
|
+
// Currently just the failover policy. It lives here and not on
|
|
89
|
+
// PaymentProviderConfig because "may a decline cascade to the next acquirer"
|
|
90
|
+
// is a decision about the chain as a whole; putting it on each provider row
|
|
91
|
+
// would let a merchant hold two contradictory answers at once.
|
|
92
|
+
model PaymentMerchantSettings {
|
|
93
|
+
id String @id @default(uuid())
|
|
94
|
+
merchantKind String @map("merchant_kind")
|
|
95
|
+
merchantId String @map("merchant_id")
|
|
96
|
+
// TECHNICAL | TECHNICAL_AND_DECLINE (CHECK in migration).
|
|
97
|
+
//
|
|
98
|
+
// Defaults to TECHNICAL: technical failures fail over, declines do not.
|
|
99
|
+
// Cascading a declined card onto a second acquirer is a real practice and a
|
|
100
|
+
// real fraud-and-interchange risk, so it is never the silent default.
|
|
101
|
+
failoverPolicy String @default("TECHNICAL") @map("failover_policy")
|
|
102
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
103
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
104
|
+
|
|
105
|
+
@@unique([merchantKind, merchantId])
|
|
106
|
+
@@map("payment_merchant_settings")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One charge as the gateway persists it. `(provider, provider_charge_id)`
|
|
110
|
+
// UNIQUE is the cross-system idempotency key (insert-or-ignore, never
|
|
111
|
+
// check-then-insert); `(merchant, idempotency_key)` UNIQUE closes the
|
|
112
|
+
// concurrent-retry window at the row level.
|
|
113
|
+
model PaymentCharge {
|
|
114
|
+
id String @id @default(uuid())
|
|
115
|
+
merchantKind String @map("merchant_kind")
|
|
116
|
+
merchantId String @map("merchant_id")
|
|
117
|
+
provider String
|
|
118
|
+
providerChargeId String @map("provider_charge_id")
|
|
119
|
+
// Host-side reference (order id, invoice id, ...) — correlation only.
|
|
120
|
+
reference String
|
|
121
|
+
idempotencyKey String? @map("idempotency_key")
|
|
122
|
+
// Normalized ChargeStatus (CHECK in migration).
|
|
123
|
+
status String @default("PENDING")
|
|
124
|
+
// PIX | CARD | BOLETO (CHECK in migration).
|
|
125
|
+
method String
|
|
126
|
+
amountCents Int @map("amount_cents")
|
|
127
|
+
currency String @default("BRL")
|
|
128
|
+
// The full normalized ChargeSnapshot (client-safe fields + raw audit).
|
|
129
|
+
snapshot Json
|
|
130
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
131
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
132
|
+
|
|
133
|
+
@@unique([provider, providerChargeId])
|
|
134
|
+
@@unique([merchantKind, merchantId, idempotencyKey])
|
|
135
|
+
@@index([merchantKind, merchantId, createdAt])
|
|
136
|
+
@@index([reference])
|
|
137
|
+
@@map("payment_charges")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Durable webhook inbox (transactional-inbox): persist first, process off
|
|
141
|
+
// the response path, dedup on the (merchant, provider, event_id) triple.
|
|
142
|
+
model PaymentWebhookEvent {
|
|
143
|
+
id String @id @default(uuid())
|
|
144
|
+
merchantKind String @map("merchant_kind")
|
|
145
|
+
merchantId String @map("merchant_id")
|
|
146
|
+
provider String
|
|
147
|
+
eventId String @map("event_id")
|
|
148
|
+
// PENDING | PROCESSED | FAILED (CHECK in migration).
|
|
149
|
+
status String @default("PENDING")
|
|
150
|
+
// Every processing attempt, live deliveries and replays together. Support
|
|
151
|
+
// telemetry: "how many times has anything tried this row".
|
|
152
|
+
attempts Int @default(0)
|
|
153
|
+
// Attempts made by the RETRY DRAIN, and only by it. Separate from `attempts`
|
|
154
|
+
// because it is a BUDGET, not a count: the drain stops listing a row once
|
|
155
|
+
// this reaches the cap. A provider redelivering a broken handler every few
|
|
156
|
+
// minutes would exhaust a shared counter on the live path alone, and the row
|
|
157
|
+
// would drop out of the drain's work list having never been replayed once —
|
|
158
|
+
// the recovery mechanism switching itself off for the rows that needed it
|
|
159
|
+
// most, invisibly, since an unlisted row moves no counter.
|
|
160
|
+
replayAttempts Int @default(0) @map("replay_attempts")
|
|
161
|
+
lastError String? @map("last_error")
|
|
162
|
+
headers String?
|
|
163
|
+
payload String
|
|
164
|
+
receivedAt DateTime @default(now()) @map("received_at")
|
|
165
|
+
processedAt DateTime? @map("processed_at")
|
|
166
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
167
|
+
|
|
168
|
+
@@unique([merchantKind, merchantId, provider, eventId])
|
|
169
|
+
@@index([status])
|
|
170
|
+
// The drain's own query: unsettled rows, one branch per eligible
|
|
171
|
+
// `replay_attempts` count, each with its own `updated_at` cutoff, ordered by
|
|
172
|
+
// `updated_at`. Leading with status keeps it off the PROCESSED rows, which
|
|
173
|
+
// are the overwhelming majority of the table.
|
|
174
|
+
@@index([status, replayAttempts, updatedAt], map: "payment_webhook_events_replay_idx")
|
|
175
|
+
@@map("payment_webhook_events")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// One ATTEMPT within a failover walk — the audit trail behind every routing
|
|
179
|
+
// decision. Written whether the attempt succeeded, was failed over, or
|
|
180
|
+
// stopped the walk, so "why did this order go to Stone instead of PagBank"
|
|
181
|
+
// is answerable from data rather than from logs.
|
|
182
|
+
//
|
|
183
|
+
// It is also load-bearing, not merely observational: a retried charge reads
|
|
184
|
+
// its own prior attempts and resumes AFTER them instead of re-walking the
|
|
185
|
+
// chain from the top, which would re-hit a provider that already refused.
|
|
186
|
+
model PaymentChargeAttempt {
|
|
187
|
+
id String @id @default(uuid())
|
|
188
|
+
merchantKind String @map("merchant_kind")
|
|
189
|
+
merchantId String @map("merchant_id")
|
|
190
|
+
// Null for charges raised without one: still audited, never resumable
|
|
191
|
+
// (there is no key to correlate a retry to this walk).
|
|
192
|
+
idempotencyKey String? @map("idempotency_key")
|
|
193
|
+
// Host-side reference (order id) — the column support actually searches by.
|
|
194
|
+
reference String
|
|
195
|
+
provider String
|
|
196
|
+
// 1-based position within THIS walk, not a global counter.
|
|
197
|
+
attemptNo Int @map("attempt_no")
|
|
198
|
+
// SUCCEEDED | ADOPTED | FAILED_OVER | STOPPED | DECLINED | SKIPPED
|
|
199
|
+
// (CHECK in migration).
|
|
200
|
+
outcome String
|
|
201
|
+
// DEFINITELY_NOT_CHARGED | AMBIGUOUS | BUSINESS_OUTCOME (CHECK in
|
|
202
|
+
// migration); null when the attempt did not fail.
|
|
203
|
+
failureBucket String? @map("failure_bucket")
|
|
204
|
+
// NOT_CHARGED | CHARGE_FOUND | PROBE_FAILED | UNSUPPORTED (CHECK in
|
|
205
|
+
// migration); null when no reconciliation probe was needed.
|
|
206
|
+
probeResult String? @map("probe_result")
|
|
207
|
+
// The provider charge id, once one is known (success or adopted).
|
|
208
|
+
providerChargeId String? @map("provider_charge_id")
|
|
209
|
+
// Operator-facing failure text. Never carries credentials or PAN data.
|
|
210
|
+
error String?
|
|
211
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
212
|
+
|
|
213
|
+
// Resume lookup: "what has this idempotency key already tried?"
|
|
214
|
+
@@index([merchantKind, merchantId, idempotencyKey])
|
|
215
|
+
@@index([reference])
|
|
216
|
+
@@index([merchantKind, merchantId, createdAt])
|
|
217
|
+
@@map("payment_charge_attempts")
|
|
218
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @12-apps/rbac — CANONICAL Prisma model partial (plug-and-play).
|
|
3
|
+
//
|
|
4
|
+
// This file is the single source of truth for the five generic RBAC tables. A
|
|
5
|
+
// host project does NOT copy these models into its main schema by hand: it
|
|
6
|
+
// uses Prisma's multi-file schema folder and SYNCS this file into it with this
|
|
7
|
+
// package's `prisma:sync` script (a byte-for-byte COPY — never a symlink; see
|
|
8
|
+
// scripts/sync-rbac-schema.mjs for why).
|
|
9
|
+
//
|
|
10
|
+
// Host-agnostic by design (the entity-lifecycle / report-builder doctrine):
|
|
11
|
+
// - The tenant is a by-value `client_id` scalar — no relation to the host's
|
|
12
|
+
// tenant model (whose name this package cannot know). The host's SQL
|
|
13
|
+
// migration may add the FK constraint (recommended: ON DELETE CASCADE).
|
|
14
|
+
// - The user is a by-value `user_id` scalar for the same reason.
|
|
15
|
+
// - Relations INTERNAL to the partial (Membership <-> Role via
|
|
16
|
+
// MembershipRole) are kept: both ends live in this file.
|
|
17
|
+
// - `role` / `kind` are Strings; a host migration may add CHECK constraints
|
|
18
|
+
// for its own closed role sets (this package's role catalog is config).
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
// A user's membership in a tenant. One row per (user, tenant); this row is
|
|
22
|
+
// what scopes a user to a tenant — a user shows up in tenant A's roster iff a
|
|
23
|
+
// Membership exists there. `role` is the member's PRIMARY role name (a
|
|
24
|
+
// display/coarse-gate mirror); the authoritative permission assignment is the
|
|
25
|
+
// `membership_roles` n:m join below. `active` is the reversible soft-disable:
|
|
26
|
+
// a disabled member keeps its row + role but resolves to zero permissions.
|
|
27
|
+
model Membership {
|
|
28
|
+
id String @id @default(uuid())
|
|
29
|
+
userId String @map("user_id")
|
|
30
|
+
clientId String @map("client_id")
|
|
31
|
+
role String @default("CUSTOMER")
|
|
32
|
+
active Boolean @default(true)
|
|
33
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
34
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
35
|
+
|
|
36
|
+
// The tenant roles this membership holds — the authoritative
|
|
37
|
+
// permission-assignment surface (a membership may hold 0..n roles).
|
|
38
|
+
roles MembershipRole[]
|
|
39
|
+
|
|
40
|
+
@@unique([userId, clientId])
|
|
41
|
+
@@index([clientId])
|
|
42
|
+
@@index([userId])
|
|
43
|
+
@@map("memberships")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A named RBAC role and its permission catalog. Every tenant staff role is a
|
|
47
|
+
// first-class per-tenant `Role` row (`client_id` set): on tenant creation the
|
|
48
|
+
// catalog is seeded from the host's role templates into per-tenant rows, so a
|
|
49
|
+
// tenant's roles are managed entirely from the DB. `client_id` NULL marks a
|
|
50
|
+
// PLATFORM/global TEMPLATE row (e.g. a platform-only SUPERADMIN the engine
|
|
51
|
+
// resolves by name for GLOBAL grants). `permissions` is a String holding a
|
|
52
|
+
// JSON array of permission strings or the literal '*' for all. `kind`
|
|
53
|
+
// (SYSTEM | CUSTOM) tells a seeded catalog role apart from a free-form tenant
|
|
54
|
+
// role; `locked` marks an Owner role that is never editable/deletable.
|
|
55
|
+
// `@@unique([clientId, name])` gives each tenant its own name namespace, but
|
|
56
|
+
// does NOT keep TEMPLATE names unique: Postgres treats NULL `client_id` as
|
|
57
|
+
// distinct, so template uniqueness is enforced by a raw partial unique index
|
|
58
|
+
// (`roles_template_name_key ON (name) WHERE client_id IS NULL`) in this
|
|
59
|
+
// package's migration — Prisma can't express a filtered index here.
|
|
60
|
+
model Role {
|
|
61
|
+
id String @id @default(uuid())
|
|
62
|
+
clientId String? @map("client_id")
|
|
63
|
+
name String
|
|
64
|
+
// JSON array of permission strings, or the literal '*' for all permissions.
|
|
65
|
+
permissions String
|
|
66
|
+
description String?
|
|
67
|
+
isTemplate Boolean @default(true) @map("is_template")
|
|
68
|
+
kind String @default("CUSTOM")
|
|
69
|
+
locked Boolean @default(false)
|
|
70
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
71
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
72
|
+
// Soft-delete: a non-null timestamp hides a CUSTOM role from the admin grid
|
|
73
|
+
// AND stops it granting at runtime (the resolver filters
|
|
74
|
+
// `archived_at IS NULL`). Null = active. SYSTEM rows are reset, never
|
|
75
|
+
// archived.
|
|
76
|
+
archivedAt DateTime? @map("archived_at")
|
|
77
|
+
// The version of this role currently live/published (an entity-lifecycle
|
|
78
|
+
// hook — NOT the schema-migration version). 0 = never versioned.
|
|
79
|
+
publishedVersion Int @default(0) @map("published_version")
|
|
80
|
+
|
|
81
|
+
// Memberships assigned this role. Cascades: deleting the role detaches its
|
|
82
|
+
// assignments (members fall back to their remaining roles).
|
|
83
|
+
memberRoles MembershipRole[]
|
|
84
|
+
|
|
85
|
+
@@unique([clientId, name])
|
|
86
|
+
@@index([clientId])
|
|
87
|
+
@@index([clientId, archivedAt])
|
|
88
|
+
@@map("roles")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The n:m assignment join between a tenant membership and a `Role`. This is
|
|
92
|
+
// the authoritative role-assignment surface: a membership holds 0..n roles, a
|
|
93
|
+
// role is held by many memberships, and the RBAC resolver derives a member's
|
|
94
|
+
// effective permissions from these rows (each role's `permissions` attached
|
|
95
|
+
// inline). Unique `(membership_id, role_id)` makes a grant idempotent; both
|
|
96
|
+
// FKs cascade so a removed membership or a deleted role takes its assignments
|
|
97
|
+
// with it — never a dangling grant.
|
|
98
|
+
model MembershipRole {
|
|
99
|
+
id String @id @default(uuid())
|
|
100
|
+
membershipId String @map("membership_id")
|
|
101
|
+
roleId String @map("role_id")
|
|
102
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
103
|
+
|
|
104
|
+
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
|
105
|
+
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
|
106
|
+
|
|
107
|
+
@@unique([membershipId, roleId])
|
|
108
|
+
@@index([membershipId])
|
|
109
|
+
@@index([roleId])
|
|
110
|
+
@@map("membership_roles")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A grant of a role to a user at a given scope. This carries PLATFORM-level
|
|
114
|
+
// grants (e.g. SUPERADMIN at scope 'GLOBAL') and any assignment not
|
|
115
|
+
// expressible as a per-tenant `Membership.role` (an `'org:<id>'` scope, or a
|
|
116
|
+
// tenant custom-role grant). `roleName` references a `Role.name` resolved in
|
|
117
|
+
// the engine's resolver — NOT a hard FK — so GLOBAL/platform roles (whose Role
|
|
118
|
+
// row has a NULL client) can be granted without a composite FK. `scope` is the
|
|
119
|
+
// scope key: a tenant id for a tenant-scoped grant, or the literal 'GLOBAL'.
|
|
120
|
+
// `@@unique([userId, roleName, scope])` makes granting an idempotent upsert.
|
|
121
|
+
model RoleAssignment {
|
|
122
|
+
id String @id @default(uuid())
|
|
123
|
+
userId String @map("user_id")
|
|
124
|
+
roleName String @map("role_name")
|
|
125
|
+
scope String
|
|
126
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
127
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
128
|
+
|
|
129
|
+
@@unique([userId, roleName, scope])
|
|
130
|
+
@@index([userId])
|
|
131
|
+
// Scope-first reads: "who holds a grant AT this tenant". `scope` is the
|
|
132
|
+
// THIRD column of the unique above, so a bare `scope =` predicate cannot use
|
|
133
|
+
// it and would seq-scan every tenant's grants.
|
|
134
|
+
@@index([scope])
|
|
135
|
+
@@map("role_assignments")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A GENERIC assignment of a user to a scoped resource within a tenant — the
|
|
139
|
+
// relation that backs "assign a waiter to table 12". `resourceType` + a
|
|
140
|
+
// by-value `resourceId` name the resource (not a hard FK), so any resource
|
|
141
|
+
// kind reuses this one relation. `clientId` scopes the assignment to a tenant.
|
|
142
|
+
// `validFrom`/`validTo` are the active window: `validTo` NULL means the
|
|
143
|
+
// assignment is STILL ACTIVE — revocation stamps `validTo` (filter-not-delete)
|
|
144
|
+
// rather than deleting the row, so the audit trail is never lost.
|
|
145
|
+
model ResourceAssignment {
|
|
146
|
+
id String @id @default(uuid())
|
|
147
|
+
userId String @map("user_id")
|
|
148
|
+
clientId String @map("client_id")
|
|
149
|
+
resourceType String @map("resource_type")
|
|
150
|
+
resourceId String @map("resource_id")
|
|
151
|
+
validFrom DateTime @default(now()) @map("valid_from")
|
|
152
|
+
validTo DateTime? @map("valid_to")
|
|
153
|
+
|
|
154
|
+
@@index([userId, resourceType])
|
|
155
|
+
@@index([clientId, resourceType, resourceId])
|
|
156
|
+
@@map("resource_assignments")
|
|
157
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// OWNED BY @12-apps/report-builder — the host's multi-file schema folder holds
|
|
3
|
+
// only a SYMLINK to this file (created/verified by `pnpm --filter
|
|
4
|
+
// @12-apps/report-builder prisma:sync`): the report persistence model lives here,
|
|
5
|
+
// in the package, never in the application.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately self-contained (the payments-backend doctrine): tenant scoping
|
|
8
|
+
// is a `client_id` String column — NO foreign key into any host table — so the
|
|
9
|
+
// model works in every repo without schema coupling. The host repository layer
|
|
10
|
+
// is the tenant boundary: every read/write is `clientId`-scoped.
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
// A tenant-authored custom report (FUT-138): the report-builder JSON
|
|
14
|
+
// `ReportSpec` persisted as DATA. Authored by the admin builder UI or by LLMs
|
|
15
|
+
// over MCP; the stored JSON is untrusted input, re-validated against the host's
|
|
16
|
+
// field catalog on EVERY write and EVERY run — it can only name allowlisted
|
|
17
|
+
// catalog fields, never SQL. Name is unique per tenant (upsert-friendly for
|
|
18
|
+
// MCP authoring). `createdBy` is the host's user id, kept as an opaque scalar.
|
|
19
|
+
model SavedReport {
|
|
20
|
+
id String @id @default(uuid())
|
|
21
|
+
clientId String @map("client_id")
|
|
22
|
+
name String
|
|
23
|
+
description String?
|
|
24
|
+
spec Json
|
|
25
|
+
// Lifecycle (FUT-307): draft | published (String + DB CHECK, house style).
|
|
26
|
+
// A draft is visible/runnable only by its author and tenant admins; the API
|
|
27
|
+
// layer decides — this model stays host-agnostic data.
|
|
28
|
+
status String @default("draft")
|
|
29
|
+
// Sharing (FUT-307): tenant (everyone in the entity's permission tier) |
|
|
30
|
+
// roles (the ids in visibilityRoles, plus author + admins) | private
|
|
31
|
+
// (author + admins). String + DB CHECK.
|
|
32
|
+
visibility String @default("tenant")
|
|
33
|
+
// Opaque HOST role ids granted access when visibility = 'roles' (JSON array
|
|
34
|
+
// of strings). No FK by design (the payments doctrine): a stale id simply
|
|
35
|
+
// matches no actor. Bounded by the wire contract (≤20 ids).
|
|
36
|
+
visibilityRoles Json @default("[]") @map("visibility_roles")
|
|
37
|
+
// The period the report OPENS on (FUT-755): today | 7d | 30d | month — the
|
|
38
|
+
// presets in `react/reports-api.ts` MINUS `custom`, which names explicit
|
|
39
|
+
// dates this column has nowhere to keep and would freeze the report on a
|
|
40
|
+
// window that ages badly. Nullable on purpose — NULL means "no preference",
|
|
41
|
+
// which the reader resolves to the pre-existing default of 30d, so every row
|
|
42
|
+
// that predates this column keeps behaving exactly as it did. String + DB
|
|
43
|
+
// CHECK, house style.
|
|
44
|
+
defaultRange String? @map("default_range")
|
|
45
|
+
// Unpublished changes to a PUBLISHED report (FUT-755): the author's
|
|
46
|
+
// in-progress edit, stored BESIDE the live document rather than over it.
|
|
47
|
+
//
|
|
48
|
+
// Deliberately NOT `status: 'draft'`. That column says the report has never
|
|
49
|
+
// been published; flipping it while someone edits would unpublish a report
|
|
50
|
+
// its readers are reading right now. So the whole editable payload — name,
|
|
51
|
+
// description, spec, sharing, default range — is parked here as JSON, `spec`
|
|
52
|
+
// is left untouched, and readers keep getting the published document until
|
|
53
|
+
// the author publishes. NULL = no unpublished changes, which is every row
|
|
54
|
+
// that predates this column.
|
|
55
|
+
//
|
|
56
|
+
// A COLUMN, not a side table: one working copy per report is then a
|
|
57
|
+
// structural fact rather than a partial unique index a host has to remember
|
|
58
|
+
// to create, publish is a single row update that cannot half-apply, and
|
|
59
|
+
// discard is setting it back to NULL. Untrusted JSON like `spec` —
|
|
60
|
+
// re-validated against the field catalog on write and again on publish.
|
|
61
|
+
workingCopy Json? @map("working_copy")
|
|
62
|
+
createdBy String? @map("created_by")
|
|
63
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
64
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
65
|
+
|
|
66
|
+
@@unique([clientId, name])
|
|
67
|
+
@@index([clientId])
|
|
68
|
+
@@map("saved_reports")
|
|
69
|
+
}
|