@12-apps/prisma 1.0.0 → 1.2.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 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/payments-backend` | `payments.prisma` | committed symlink |
64
- | `@12-apps/report-builder` | `report-builder.prisma` | committed symlink |
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
- Two rules that came from production incidents, gated by `package.test.ts`:
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.0.0",
3
+ "version": "1.2.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": {
52
+ "@12-apps/entitlements": "^1.20.1",
44
53
  "@12-apps/entity-lifecycle": "^2.0.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
+ );
@@ -23,6 +23,8 @@
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"
27
29
  ]
28
30
  }
@@ -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
+ }