@12-apps/prisma 1.0.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.
Files changed (47) hide show
  1. package/README.md +95 -0
  2. package/dist/actor-context.d.ts +127 -0
  3. package/dist/actor-context.js +139 -0
  4. package/dist/append-only-extension.d.ts +28 -0
  5. package/dist/append-only-extension.js +72 -0
  6. package/dist/audit-extension.d.ts +18 -0
  7. package/dist/audit-extension.js +123 -0
  8. package/dist/index.d.ts +46 -0
  9. package/dist/index.js +213 -0
  10. package/dist/search-normalize.d.ts +13 -0
  11. package/dist/search-normalize.js +20 -0
  12. package/package.json +96 -0
  13. package/prisma/migration-files.ts +33 -0
  14. package/prisma/migrations/20260725150000_payments_platform_core/migration.sql +98 -0
  15. package/prisma/migrations/20260725160000_payments_oauth_connections/migration.sql +25 -0
  16. package/prisma/migrations/20260725170000_add_saved_reports/migration.sql +22 -0
  17. package/prisma/migrations/20260726090000_payments_multi_provider_failover/migration.sql +67 -0
  18. package/prisma/migrations/20260726120000_payments_failover_policy/migration.sql +29 -0
  19. package/prisma/migrations/20260726130000_add_report_lifecycle/migration.sql +18 -0
  20. package/prisma/migrations/20260727120000_add_report_archived_status/migration.sql +9 -0
  21. package/prisma/migrations/20260727120000_payments_webhook_replay_budget/migration.sql +37 -0
  22. package/prisma/migrations/20260727190000_sweep_leases/migration.sql +13 -0
  23. package/prisma/migrations/20260728120000_add_product_research/migration.sql +112 -0
  24. package/prisma/migrations/20260728170000_add_manual_price_entries/migration.sql +31 -0
  25. package/prisma/migrations/20260729090000_integration_source_singleton/migration.sql +24 -0
  26. package/prisma/migrations/20260729120000_price_source_soft_delete/migration.sql +24 -0
  27. package/prisma/migrations/20260729140000_research_term_normalized/migration.sql +26 -0
  28. package/prisma/migrations/20260730120000_offer_outside_delivery_area/migration.sql +16 -0
  29. package/prisma/migrations/20260730120000_payments_charge_verified_at/migration.sql +23 -0
  30. package/prisma/migrations/20260730130000_research_runs_created_at_index/migration.sql +12 -0
  31. package/prisma/migrations/20260730210000_add_shifts/migration.sql +95 -0
  32. package/prisma/migrations/20260731000000_offer_shipping_unknown/migration.sql +34 -0
  33. package/prisma/migrations/20260731210000_shift_delete_guard/migration.sql +43 -0
  34. package/prisma/migrations/20260810120000_add_report_default_range/migration.sql +15 -0
  35. package/prisma/migrations/20260810160000_report_default_range_month/migration.sql +15 -0
  36. package/prisma/migrations/20260810180000_add_report_working_copy/migration.sql +13 -0
  37. package/prisma/plugin-migrations.json +28 -0
  38. package/prisma/schema/entity-lifecycle.prisma +126 -0
  39. package/prisma/schema/jobs.prisma +48 -0
  40. package/prisma/schema/product-research.prisma +217 -0
  41. package/prisma/schema/schema.prisma +19 -0
  42. package/prisma/schema/shift.prisma +34 -0
  43. package/src/actor-context.ts +218 -0
  44. package/src/append-only-extension.ts +75 -0
  45. package/src/audit-extension.ts +121 -0
  46. package/src/index.ts +233 -0
  47. package/src/search-normalize.ts +19 -0
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/entity-lifecycle — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // This file is the single source of truth for the four generic lifecycle
5
+ // tables. A host project does NOT copy these models into its main schema by
6
+ // hand: it uses Prisma's multi-file schema folder and SYNCS this file into it
7
+ // (see packages/shared-helpers/scripts/sync-lifecycle-schema.mjs in this repo
8
+ // for the reference sync step — run before `prisma generate`).
9
+ //
10
+ // Host-agnostic by design:
11
+ // - The tracked record is named BY VALUE via (entity_type, entity_id) — no
12
+ // FK to the host's entity tables, so any collection plugs in with zero
13
+ // schema change here.
14
+ // - The tenant is a by-value `client_id` scalar — no relation to the host's
15
+ // tenant model (whose name this package cannot know). The host's SQL
16
+ // migration may add the FK constraint (recommended: ON DELETE CASCADE).
17
+ // - `kind` / `status` / `action` are Strings; the host migration adds CHECK
18
+ // constraints for the closed sets documented on each field.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ // One row of an entity's version history. Version 1 (and any retention-
22
+ // compaction base) is a FULL snapshot (`is_snapshot`, `data` = whole entity);
23
+ // every other row is a DELTA: `data` holds only the top-level fields that
24
+ // changed (their new values) and `removed_fields` the fields that disappeared.
25
+ // The state at version N is rebuilt by replaying rows 1..N. `kind` is
26
+ // CREATE | UPDATE | RESTORE. `actor_id` is the host's user id (by value);
27
+ // null = system write. Append-only: no `updated_at`.
28
+ model EntityVersion {
29
+ id String @id @default(uuid())
30
+ clientId String @map("client_id")
31
+ entityType String @map("entity_type")
32
+ entityId String @map("entity_id")
33
+ version Int
34
+ kind String
35
+ isSnapshot Boolean @map("is_snapshot")
36
+ data Json
37
+ removedFields Json @default("[]") @map("removed_fields")
38
+ actorId String? @map("actor_id")
39
+ // For kind RESTORE: which version was restored.
40
+ restoredFrom Int? @map("restored_from")
41
+ createdAt DateTime @default(now()) @map("created_at")
42
+
43
+ @@unique([clientId, entityType, entityId, version])
44
+ @@index([clientId, entityType, entityId])
45
+ // Serves the retention sweep ("versions older than N days").
46
+ @@index([createdAt])
47
+ @@map("entity_versions")
48
+ }
49
+
50
+ // One deleted record in a tenant's recycle bin. Deleting an aggregate records
51
+ // a TREE: the root entry plus one child entry per dependent record deleted
52
+ // along with it, linked via `parent_entry_id` — the registry the bin page
53
+ // renders so a restore shows what it brings back. `snapshot` freezes the data
54
+ // needed to preview/restore. `status` is DELETED | RESTORED | PURGED:
55
+ // restore/purge KEEP the row as an audit trail (status flip, never deleted).
56
+ model RecycleBinEntry {
57
+ id String @id @default(uuid())
58
+ clientId String @map("client_id")
59
+ entityType String @map("entity_type")
60
+ entityId String @map("entity_id")
61
+ parentEntryId String? @map("parent_entry_id")
62
+ label String
63
+ snapshot Json
64
+ status String @default("DELETED")
65
+ deletedBy String? @map("deleted_by")
66
+ deletedAt DateTime @default(now()) @map("deleted_at")
67
+ updatedAt DateTime @updatedAt @map("updated_at")
68
+
69
+ parent RecycleBinEntry? @relation("RecycleBinTree", fields: [parentEntryId], references: [id], onDelete: Cascade)
70
+ children RecycleBinEntry[] @relation("RecycleBinTree")
71
+
72
+ // The bin page: a tenant's deleted roots, newest first.
73
+ @@index([clientId, status, deletedAt])
74
+ @@index([clientId, entityType, entityId])
75
+ @@index([parentEntryId])
76
+ @@map("recycle_bin_entries")
77
+ }
78
+
79
+ // A per-item draft: unpublished edits kept next to the live record.
80
+ // `entity_id` NULL = a draft of a brand-new item that doesn't exist yet. At
81
+ // most one OPEN draft per (tenant, type, entity) — enforce with a partial
82
+ // unique index in the host migration (WHERE status = 'OPEN' AND entity_id IS
83
+ // NOT NULL; Prisma cannot express the filter). `status` is
84
+ // OPEN | PUBLISHED | DISCARDED; publish/discard keep the row as history.
85
+ model EntityDraft {
86
+ id String @id @default(uuid())
87
+ clientId String @map("client_id")
88
+ entityType String @map("entity_type")
89
+ entityId String? @map("entity_id")
90
+ data Json
91
+ status String @default("OPEN")
92
+ createdBy String? @map("created_by")
93
+ updatedBy String? @map("updated_by")
94
+ createdAt DateTime @default(now()) @map("created_at")
95
+ updatedAt DateTime @updatedAt @map("updated_at")
96
+
97
+ @@index([clientId, entityType, status])
98
+ @@index([clientId, entityType, entityId])
99
+ @@map("entity_drafts")
100
+ }
101
+
102
+ // A pending change awaiting approval. When the approvals feature is active
103
+ // for a tenant+collection, a write by an actor without the approve permission
104
+ // is parked here instead of applied; an approver later applies or rejects it.
105
+ // `payload` is the full desired snapshot for CREATE/UPDATE (empty for
106
+ // DELETE). `action` is CREATE | UPDATE | DELETE; `status` is
107
+ // PENDING | APPROVED | REJECTED. Decided rows are kept as an audit trail.
108
+ model ChangeRequest {
109
+ id String @id @default(uuid())
110
+ clientId String @map("client_id")
111
+ entityType String @map("entity_type")
112
+ entityId String? @map("entity_id")
113
+ action String
114
+ payload Json
115
+ status String @default("PENDING")
116
+ requestedBy String? @map("requested_by")
117
+ requestedAt DateTime @default(now()) @map("requested_at")
118
+ decidedBy String? @map("decided_by")
119
+ decidedAt DateTime? @map("decided_at")
120
+ decisionNote String? @map("decision_note")
121
+
122
+ // The approvals inbox: a tenant's pending requests, newest first.
123
+ @@index([clientId, status, requestedAt])
124
+ @@index([clientId, entityType, entityId])
125
+ @@map("change_requests")
126
+ }
@@ -0,0 +1,48 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/jobs — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // This file is the single source of truth for the sweep-lease table. A host
5
+ // project does NOT copy this model into its main schema by hand: it uses
6
+ // Prisma's multi-file schema folder and SYNCS this file into it (see
7
+ // packages/shared-helpers/scripts/sync-jobs-schema.mjs in this repo for the
8
+ // reference sync step — run before `prisma generate`). The migration ships
9
+ // alongside, in prisma/migrations/, and is COPIED into the host's migrations
10
+ // folder — never symlinked, because Prisma enumerates that folder with lstat
11
+ // and silently skips a linked directory.
12
+ //
13
+ // Host-agnostic by design: the lease names a JOB, not a tenant or a user, so
14
+ // there is no relation to any host table at all.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /// Named, time-bounded claims on the scheduled sweeps.
18
+ ///
19
+ /// Within one worker the sweeps are single-flight (their own queue at
20
+ /// concurrency 1). Across two workers that guarantee evaporates: both read
21
+ /// the same work list and do the same work twice. Correctness is expected to
22
+ /// survive that (sweeps are idempotent by durable markers in the host's own
23
+ /// tables), but the effort and the user-visible notices double.
24
+ ///
25
+ /// A lease row rather than `pg_advisory_lock`, deliberately. A session-level
26
+ /// advisory lock has to be released on the SAME connection that took it, and
27
+ /// Prisma pools connections with no such guarantee — a release that lands on
28
+ /// another connection silently fails and strands the lock, after which that
29
+ /// sweep never runs again until the connection recycles. The transaction-
30
+ /// scoped variant releases correctly but only holds for the transaction, and
31
+ /// sweeps make external HTTP calls that have no business inside one.
32
+ ///
33
+ /// `expiresAt` is what makes a dead holder recoverable without anyone
34
+ /// intervening: a worker that is OOM-killed mid-sweep leaves the row behind,
35
+ /// and the next tick past the expiry simply takes it. The claim itself is a
36
+ /// conditional UPDATE, so the database decides the winner — two workers
37
+ /// racing cannot both see it as free.
38
+ model SweepLease {
39
+ /// The job name, e.g. "billing.tick". One lease per sweep.
40
+ name String @id
41
+ /// Which worker run holds it — release only ever matches its own holder.
42
+ holder String
43
+ acquiredAt DateTime @default(now()) @map("acquired_at")
44
+ /// Past this instant the lease is free, whether or not it was released.
45
+ expiresAt DateTime @map("expires_at")
46
+
47
+ @@map("sweep_leases")
48
+ }
@@ -0,0 +1,217 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/product-research — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // This file is the single source of truth for the four research-to-buy tables.
5
+ // A 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 (see
7
+ // packages/shared-helpers/scripts/sync-research-schema.mjs in this repo for
8
+ // the reference sync step — run before `prisma generate`). Migrations are
9
+ // owned by this package too (prisma/migrations) and reach the host through
10
+ // scripts/sync-prisma-plugins.mjs.
11
+ //
12
+ // Host-agnostic by design (entity-lifecycle / payments-backend doctrine):
13
+ // - The tenant is a by-value `client_id` scalar — no relation to the host's
14
+ // tenant model. The host's repository layer scopes every read/write.
15
+ // - The researched catalog item is named BY VALUE via
16
+ // (catalog_ref_type, catalog_ref_id) — no FK to host catalog tables, so a
17
+ // restaurant's MenuItem and a dental system's product plug in identically.
18
+ // - `type` / `status` are Strings; the migration adds CHECK constraints for
19
+ // the closed sets documented on each field.
20
+ // - Money is integer cents with an explicit `currency` (BRL first host).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ // One configured offer source for a tenant. `type` is the connector key:
24
+ // VTEX | MERCADO_LIVRE | SERP | AMAZON | MANUAL. `config` holds the
25
+ // connector-specific settings (e.g. a VTEX store's baseUrl + sales channel).
26
+ // `status` is ACTIVE | DEGRADED — a connector that keeps failing is marked
27
+ // DEGRADED (with `last_error_at`) so the UI can say "results may be
28
+ // incomplete" instead of silently shrinking.
29
+ //
30
+ // Paid connectors (SERP | AMAZON | MERCADO_LIVRE) are singleton INTEGRATIONS
31
+ // (FUT-434): at most one row per (client_id, type), holding the tenant's own
32
+ // encrypted API credential inside `config`. Enforced by a partial unique
33
+ // index in this package's migrations — like the CHECK constraints, DDL the
34
+ // Prisma schema cannot express (see src/integrations.ts for the config keys).
35
+ model PriceSource {
36
+ id String @id @default(uuid())
37
+ clientId String @map("client_id")
38
+ type String
39
+ name String
40
+ config Json @default("{}")
41
+ enabled Boolean @default(true)
42
+ status String @default("ACTIVE")
43
+ lastErrorAt DateTime? @map("last_error_at")
44
+ // Soft-delete: a non-null timestamp removes the source from the roster and
45
+ // from every new research run, while its SupplierOffer rows keep their FK so
46
+ // finished runs stay fully readable. The host filters `archived_at IS NULL`
47
+ // on every live read; there is no restore path.
48
+ archivedAt DateTime? @map("archived_at")
49
+ createdAt DateTime @default(now()) @map("created_at")
50
+ updatedAt DateTime @updatedAt @map("updated_at")
51
+
52
+ offers SupplierOffer[]
53
+ manualEntries ManualPriceEntry[]
54
+
55
+ // NO @@unique on (clientId, name): the tenant unique exists in the DB as a
56
+ // PARTIAL index — `price_sources_client_id_name_key` WHERE `archived_at IS
57
+ // NULL` (20260729120000_price_source_soft_delete). Prisma cannot express a
58
+ // filtered unique, and an unconditional `@@unique` would both be a lie the
59
+ // client acts on (a `clientId_name` findUnique the index cannot serve) and
60
+ // let a soft-deleted source squat its name forever. Same arrangement as the
61
+ // host's `discounts_client_id_name_key`.
62
+ @@index([clientId, enabled])
63
+ @@map("price_sources")
64
+ }
65
+
66
+ // What a buyer asked to research: a catalog item (by value) or a free-text
67
+ // term, a quantity and a region code (CEP in Brazil) that selects regional
68
+ // pricing. `requested_by` is the host's user id, by value; null = system.
69
+ // `term_normalized` mirrors normalizeText(term) — accent/case/punctuation
70
+ // folded — the indexed key the widened widget lookup prefilters by (FUT-430);
71
+ // hosts set it on write (no unaccent/pg_trgm, which PGlite lacks), and the
72
+ // owning migration backfills pre-existing rows in plain SQL.
73
+ model ResearchRequest {
74
+ id String @id @default(uuid())
75
+ clientId String @map("client_id")
76
+ catalogRefType String? @map("catalog_ref_type")
77
+ catalogRefId String? @map("catalog_ref_id")
78
+ term String
79
+ termNormalized String? @map("term_normalized")
80
+ brand String?
81
+ ean String?
82
+ quantity Int @default(1)
83
+ region String?
84
+ requestedBy String? @map("requested_by")
85
+ createdAt DateTime @default(now()) @map("created_at")
86
+
87
+ runs ResearchRun[]
88
+
89
+ @@index([clientId, createdAt])
90
+ @@index([clientId, termNormalized])
91
+ @@map("research_requests")
92
+ }
93
+
94
+ // One execution of a request across the enabled sources. `status` is
95
+ // PENDING | RUNNING | COMPLETED | FAILED. `source_stats` records one entry per
96
+ // source ({sourceId, type, name, status, offerCount, ms, error?}) — the data
97
+ // behind the per-source streaming row and the "degraded/cap" banners.
98
+ model ResearchRun {
99
+ id String @id @default(uuid())
100
+ clientId String @map("client_id")
101
+ requestId String @map("request_id")
102
+ status String @default("PENDING")
103
+ sourceStats Json @default("[]") @map("source_stats")
104
+ error String?
105
+ startedAt DateTime? @map("started_at")
106
+ finishedAt DateTime? @map("finished_at")
107
+ createdAt DateTime @default(now()) @map("created_at")
108
+ updatedAt DateTime @updatedAt @map("updated_at")
109
+
110
+ request ResearchRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
111
+ offers SupplierOffer[]
112
+
113
+ @@index([clientId, createdAt])
114
+ @@index([requestId])
115
+ // Cross-tenant reads by time alone (the host's superadmin diagnostics inbox:
116
+ // "every failed source in the last 24 h"). `(client_id, created_at)` cannot
117
+ // serve those — the leading column is unbound and Postgres has no btree skip
118
+ // scan — so without this index the query degrades to a full sequential scan
119
+ // of a table that nothing prunes.
120
+ @@index([createdAt])
121
+ @@map("research_runs")
122
+ }
123
+
124
+ // One purchasable offer found by a run. Prices are integer cents;
125
+ // `unit_price_cents` is the pack-normalized comparison key (a 12x350ml fardo
126
+ // and a single can compete on the same axis) and `total_cents` is the cost for
127
+ // the requested quantity plus whatever shipping the source stated — a LOWER
128
+ // BOUND when it stated none (see `shipping_cents`). `relevance_score` is the 0..1
129
+ // no-AI token-match confidence; offers below the threshold are never stored.
130
+ // `expires_at` is the freshness horizon — an expired offer must not be shown
131
+ // as a current price. `purchased_at` closes the loop when the buyer marks the
132
+ // offer they actually bought. `source_id` survives source deletion as NULL so
133
+ // history keeps its offers.
134
+ //
135
+ // `outside_delivery_area` (FUT-491) marks an offer whose price belongs to the
136
+ // STORE'S DEFAULT REGION because the store does not deliver to the researched
137
+ // CEP. A real column rather than a corner of `raw`: `raw` is the verbatim
138
+ // connector payload kept for offline replay, so writing our own field into it
139
+ // would break that contract, and the flag has to survive re-reads, cached runs
140
+ // and the offers API — plus be filterable — which a blob cannot promise.
141
+ //
142
+ // `shipping_cents` is NULLABLE with no default (FUT-518): NULL = the source
143
+ // never stated a shipping cost, 0 = the source stated FREE. It used to be
144
+ // `NOT NULL DEFAULT 0`, which made those two the same value — so every SERP
145
+ // offer (serp.ts states shipping never) was persisted as free shipping and
146
+ // competed on an understated total. Nullable rather than a companion boolean
147
+ // because `eta_days Int?` two lines down is this model's existing way of
148
+ // saying "the source did not say", and a flag beside an Int would be two
149
+ // facts where one suffices. HISTORICAL ROWS ARE UNRECOVERABLE: every row
150
+ // written before the migration holds 0 and stays 0 — see the migration.
151
+ model SupplierOffer {
152
+ id String @id @default(uuid())
153
+ clientId String @map("client_id")
154
+ runId String @map("run_id")
155
+ sourceId String? @map("source_id")
156
+ sourceType String @map("source_type")
157
+ supplierName String @map("supplier_name")
158
+ title String
159
+ url String?
160
+ imageUrl String? @map("image_url")
161
+ currency String @default("BRL")
162
+ priceCents Int @map("price_cents")
163
+ shippingCents Int? @map("shipping_cents")
164
+ packQuantity Int @default(1) @map("pack_quantity")
165
+ unitPriceCents Int @map("unit_price_cents")
166
+ totalCents Int @map("total_cents")
167
+ availability String?
168
+ etaDays Int? @map("eta_days")
169
+ relevanceScore Float @map("relevance_score")
170
+ rank Int?
171
+ raw Json?
172
+ expiresAt DateTime? @map("expires_at")
173
+ purchasedAt DateTime? @map("purchased_at")
174
+ hiddenAt DateTime? @map("hidden_at")
175
+ outsideDeliveryArea Boolean @default(false) @map("outside_delivery_area")
176
+ createdAt DateTime @default(now()) @map("created_at")
177
+
178
+ run ResearchRun @relation(fields: [runId], references: [id], onDelete: Cascade)
179
+ source PriceSource? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
180
+
181
+ @@index([runId, rank])
182
+ @@index([clientId, createdAt])
183
+ @@index([clientId, expiresAt])
184
+ @@map("supplier_offers")
185
+ }
186
+
187
+ // One row of a manually imported price list (or a one-off typed quote),
188
+ // belonging to a MANUAL-type PriceSource. This is the standing dataset the
189
+ // manual connector reads DURING research runs — unlike SupplierOffer rows,
190
+ // which are per-run results. `valid_until` is the staleness horizon: expired
191
+ // entries stop appearing in new research results (the host's store filters
192
+ // them), never shown as current prices. `batch_id` groups one import so a
193
+ // re-import replaces the previous list atomically. Tenant is a by-value
194
+ // `client_id` scalar, same doctrine as every other research table.
195
+ model ManualPriceEntry {
196
+ id String @id @default(uuid())
197
+ clientId String @map("client_id")
198
+ sourceId String @map("source_id")
199
+ batchId String @map("batch_id")
200
+ supplierName String @map("supplier_name")
201
+ title String
202
+ brand String?
203
+ ean String?
204
+ packQuantity Int? @map("pack_quantity")
205
+ priceCents Int @map("price_cents")
206
+ currency String @default("BRL")
207
+ url String?
208
+ availability String?
209
+ etaDays Int? @map("eta_days")
210
+ validUntil DateTime @map("valid_until")
211
+ createdAt DateTime @default(now()) @map("created_at")
212
+
213
+ source PriceSource @relation(fields: [sourceId], references: [id], onDelete: Cascade)
214
+
215
+ @@index([clientId, sourceId, validUntil])
216
+ @@map("manual_price_entries")
217
+ }
@@ -0,0 +1,19 @@
1
+ // Host schema root for @12-apps/shared-helpers.
2
+ //
3
+ // Datasource + generator ONLY — this package deliberately owns no domain
4
+ // models. The app's models live in the consuming application's own schema
5
+ // folder; what lands here besides this file are the model PARTIALS owned by the
6
+ // plugin packages in this repo, copied in by scripts/sync-*-schema.mjs.
7
+ //
8
+ // That is the whole plug-and-play contract: a host adopts a plugin by running
9
+ // the same sync step against its own schema folder, and generates a client that
10
+ // knows the plugin's models without hand-copying them. Generating here with no
11
+ // domain models is what a fresh consumer gets before adding their own.
12
+
13
+ generator client {
14
+ provider = "prisma-client-js"
15
+ }
16
+
17
+ datasource db {
18
+ provider = "postgresql"
19
+ }
@@ -0,0 +1,34 @@
1
+ // @12-apps/shift — canonical host-agnostic Prisma partial.
2
+ //
3
+ // Tenant, user and resource-assignment identifiers are stored by value so the
4
+ // package does not depend on host model names. The package migration adds the
5
+ // host's PostgreSQL CHECK/partial-index guarantees that Prisma cannot express.
6
+ model Shift {
7
+ id String @id @default(uuid())
8
+ clientId String @map("client_id")
9
+ userId String @map("user_id")
10
+ kind String
11
+ startedAt DateTime @default(now()) @map("started_at")
12
+ endedAt DateTime? @map("ended_at")
13
+ endedReason String? @map("ended_reason")
14
+ endedByUserId String? @map("ended_by_user_id")
15
+ resourceAssignmentId String? @unique @map("resource_assignment_id")
16
+ // Immutable snapshots retain the resource identity even if a generic
17
+ // ResourceAssignment is later removed with its host user or tenant.
18
+ resourceType String? @map("resource_type")
19
+ resourceId String? @map("resource_id")
20
+
21
+ @@index([clientId, endedAt])
22
+ @@index([clientId, userId, startedAt])
23
+ @@map("shifts")
24
+ }
25
+
26
+ // Optional per-tenant override. No row means the package default (16 hours);
27
+ // materialising a row also defaults to 16, keeping host reads simple.
28
+ model ShiftTenantConfig {
29
+ clientId String @id @map("client_id")
30
+ autoCloseHours Int @default(16) @map("auto_close_hours")
31
+ updatedAt DateTime @updatedAt @map("updated_at")
32
+
33
+ @@map("shift_tenant_configs")
34
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Per-request "who is acting" context (FUT-168), backed by Node's
3
+ * AsyncLocalStorage. The auth layer sets the current admin's `users.id` once a
4
+ * request is authorized; the Prisma audit extension (see `audit-extension.ts`)
5
+ * reads it to auto-stamp `created_by` / `updated_by` on tracked models — so no
6
+ * repository signature or call site has to thread the actor through by hand.
7
+ *
8
+ * FUT-152 enriches the context with the ROLE and SCOPE the request was
9
+ * authorized under, so audit entries can record not just who acted but under
10
+ * which authority — populated by the same guards that stamp the user id.
11
+ *
12
+ * FUT-458 adds a SECOND identity: the subject a request is being rendered as
13
+ * while a super-admin impersonation or a "Ver como" preview is live. It is
14
+ * stored as a PAIR — the subject plus the real human behind it — and the
15
+ * second half of that pair is written by this module alone. See
16
+ * {@link ActorAttributionSnapshot.realUserId} for why the real human cannot
17
+ * simply be read back out of {@link ActorContext.userId}.
18
+ *
19
+ * Server-only: AsyncLocalStorage is a Node API. Never import from the Edge
20
+ * middleware runtime.
21
+ */
22
+
23
+ import { AsyncLocalStorage } from 'node:async_hooks';
24
+
25
+ /** Role/scope authority attribution a caller may STAMP (FUT-152). */
26
+ export interface ActorAttribution {
27
+ /** The role name the request was authorized under (e.g. `ADMIN`), if known. */
28
+ role?: string;
29
+ /** The scope the authorization decision was made in (tenant id / `GLOBAL`). */
30
+ scope?: string;
31
+ /**
32
+ * The DB `users.id` this request is being rendered AS (FUT-458) — a
33
+ * super-admin impersonation target, or a "Ver como" previewed member.
34
+ *
35
+ * NEVER the actor: {@link ActorContext.userId} stays the real human whose
36
+ * credentials authorized the request, and this is recorded ALONGSIDE it. The
37
+ * audit trail must be able to answer "who really did this" and "who did the
38
+ * screen claim to be" independently, and a single field cannot.
39
+ *
40
+ * `undefined` leaves an existing value untouched (see {@link setActor}'s merge
41
+ * rule); pass `null` to CLEAR it explicitly. Merge semantics make the
42
+ * distinction load-bearing here — an impersonation that cannot be cleared
43
+ * would leak onto every later write in the same request.
44
+ */
45
+ onBehalfOfUserId?: string | null;
46
+ }
47
+
48
+ /**
49
+ * What {@link getActorAttribution} hands back: everything a caller may stamp,
50
+ * plus the one field only this module ever writes.
51
+ */
52
+ export interface ActorAttributionSnapshot extends ActorAttribution {
53
+ /**
54
+ * The REAL human behind a live impersonation (FUT-458) — captured from the
55
+ * same stamp that declared it, and absent/`null` when no impersonation is
56
+ * live. Deliberately NOT part of {@link ActorAttribution}: a caller cannot
57
+ * pass it, only this module derives it.
58
+ *
59
+ * Why it exists rather than "just read {@link ActorContext.userId}": that
60
+ * field is LAST-WRITE-WINS, and about sixty route bodies (plus
61
+ * `apps/web/lib/api/tenant.ts`) call `setActor(grant.userId, …)` themselves
62
+ * instead of going through the impersonation-aware stamp in
63
+ * `apps/web/lib/rbac/guards.ts`. While a session is impersonated the tenant
64
+ * guard resolves that grant for the EFFECTIVE subject, so those calls
65
+ * re-stamp `userId` with the person being impersonated — and an audit row
66
+ * derived from it then reads as though the impersonated person did the thing
67
+ * themselves. That is precisely the mis-attribution the epic calls
68
+ * unrecoverable, and `audit_logs` is append-only, so nothing can put it
69
+ * right afterwards.
70
+ *
71
+ * Editing those sixty call sites would fix today's tree and rot the moment
72
+ * someone writes the sixty-first, so the invariant is enforced HERE instead:
73
+ * the real human is recorded ONCE, by the stamp that knows both halves, and
74
+ * an unaware `setActor(someId)` has no way to reach it — it moves only when
75
+ * the impersonation itself is re-declared or cleared. The audit writer
76
+ * (`apps/web/lib/audit/audit.ts`) prefers it over `userId` whenever a live
77
+ * impersonation is present, which is what makes a plain re-stamp harmless.
78
+ *
79
+ * `userId` is left alone on purpose: it also feeds `created_by`/`updated_by`
80
+ * via the audit extension, where "the id this request is acting under" is a
81
+ * different (and mutable, therefore correctable) question from "who is
82
+ * answerable for this append-only row".
83
+ */
84
+ realUserId?: string | null;
85
+ }
86
+
87
+ export interface ActorContext extends ActorAttributionSnapshot {
88
+ /** The acting admin's DB `users.id`, stamped onto created_by/updated_by. */
89
+ userId: string;
90
+ }
91
+
92
+ // Kept on globalThis so Next dev / Turbopack hot-reload (which re-evaluates this
93
+ // module) can't create a second store whose context is invisible to closures
94
+ // captured against the first.
95
+ const globalStore = globalThis as unknown as {
96
+ __futurePayActorStore?: AsyncLocalStorage<ActorContext>;
97
+ };
98
+
99
+ const store = (): AsyncLocalStorage<ActorContext> =>
100
+ (globalStore.__futurePayActorStore ??= new AsyncLocalStorage<ActorContext>());
101
+
102
+ /**
103
+ * The REAL human behind `onBehalfOfUserId`, derived (never accepted) from the
104
+ * stamp that declares the impersonation (FUT-458).
105
+ *
106
+ * `userId` is that human by construction: the only stamp in the codebase that
107
+ * passes a non-null `onBehalfOfUserId` is `stampActor` in
108
+ * `apps/web/lib/rbac/guards.ts`, which hands over the real id and the subject
109
+ * in the SAME call. That co-location is the whole reason the pair can be
110
+ * trusted — nothing else knows both halves at once, so nothing else can forge
111
+ * one.
112
+ *
113
+ * Clearing is symmetric: ending an impersonation drops BOTH halves. A stale
114
+ * real id left behind would make every later write in the request look as
115
+ * though it still carried a hidden second identity.
116
+ */
117
+ const realActorFor = (userId: string, onBehalfOfUserId: string | null): string | null =>
118
+ onBehalfOfUserId === null ? null : userId;
119
+
120
+ /**
121
+ * A fresh context for `userId`. The impersonation pair is derived only when
122
+ * the stamp expressed an opinion — `undefined` means "no opinion" everywhere
123
+ * in this module, and must not be written as a value.
124
+ */
125
+ const freshContext = (userId: string, attribution: ActorAttribution): ActorContext => ({
126
+ userId,
127
+ ...attribution,
128
+ ...(attribution.onBehalfOfUserId !== undefined
129
+ ? { realUserId: realActorFor(userId, attribution.onBehalfOfUserId) }
130
+ : {}),
131
+ });
132
+
133
+ /** Run `fn` with `userId` as the current actor. Nested calls override. */
134
+ export const runWithActor = <T>(
135
+ userId: string,
136
+ fn: () => T,
137
+ attribution: ActorAttribution = {},
138
+ ): T => store().run(freshContext(userId, attribution), fn);
139
+
140
+ /**
141
+ * Establish an EMPTY actor scope for one request and run `fn` inside it — the
142
+ * request-boundary bootstrap (`createRouteHandler` wraps every handler in it).
143
+ *
144
+ * Why it must exist: {@link setActor} inside an AWAITED guard uses `enterWith`,
145
+ * which only applies to the guard's own async continuation — the CALLER resumes
146
+ * with the context it captured before the call, so the stamp silently vanishes
147
+ * and every audit entry reads "system". With a scope established here,
148
+ * `setActor` MUTATES the shared context object instead, which every frame of
149
+ * the request's async tree observes — stamps from arbitrarily deep guards
150
+ * survive back into the handler and its repositories.
151
+ */
152
+ export const runWithActorScope = <T>(fn: () => T): T =>
153
+ store().run({ userId: "" }, fn);
154
+
155
+ /**
156
+ * Stamp the current actor for the rest of this request. A falsy id (e.g. the
157
+ * superadmin env-grant carries no DB user id) is ignored so nothing is ever
158
+ * stamped with an empty string. Attribution fields MERGE — only the fields
159
+ * passed are updated — so a guard that knows just the scope doesn't erase a
160
+ * role a caller stamped (or vice versa). Inside a {@link runWithActorScope}
161
+ * boundary the stamp mutates the shared context (survives caller awaits);
162
+ * without one it falls back to `enterWith` (same-context callers only).
163
+ */
164
+ export const setActor = (
165
+ userId: string,
166
+ attribution: ActorAttribution = {},
167
+ ): void => {
168
+ if (!userId) return;
169
+ const current = store().getStore();
170
+ if (current) {
171
+ current.userId = userId;
172
+ if (attribution.role !== undefined) current.role = attribution.role;
173
+ if (attribution.scope !== undefined) current.scope = attribution.scope;
174
+ // FUT-458 — same merge rule, and the reason it has to be `!== undefined`
175
+ // rather than a truthiness check: ENDING an impersonation is expressed as
176
+ // `null`, and a truthy guard would treat that clear as "no opinion" and
177
+ // leave the previous target standing for the rest of the request.
178
+ //
179
+ // Note what this branch does NOT do: an unaware stamp — one that passes no
180
+ // `onBehalfOfUserId` at all — moves `userId` and nothing else. The
181
+ // impersonation pair survives it untouched, which is the property the
182
+ // audit trail is built on (see `ActorAttributionSnapshot.realUserId`).
183
+ if (attribution.onBehalfOfUserId !== undefined) {
184
+ current.onBehalfOfUserId = attribution.onBehalfOfUserId;
185
+ current.realUserId = realActorFor(userId, attribution.onBehalfOfUserId);
186
+ }
187
+ return;
188
+ }
189
+ store().enterWith(freshContext(userId, attribution));
190
+ };
191
+
192
+ /** The current actor's `users.id`, or undefined when no actor is set. */
193
+ export const getActorUserId = (): string | undefined =>
194
+ store().getStore()?.userId || undefined;
195
+
196
+ /**
197
+ * The current actor's role/scope attribution (FUT-152) plus the impersonation
198
+ * PAIR (FUT-458) — the subject the request is rendered as, and the real human
199
+ * behind it — if stamped. Every field is `undefined` when nothing stamped it:
200
+ * the audit writer normalizes that to NULL at the row, so the distinction
201
+ * between "never stamped" and "explicitly cleared" stays here, where
202
+ * {@link setActor}'s merge rule needs it, and never leaks into a column.
203
+ *
204
+ * Both halves of the pair are returned together, and consumers must read them
205
+ * together: `onBehalfOfUserId` alone says an impersonation was *declared*,
206
+ * `realUserId` says who is answerable for it. A consumer that sees one without
207
+ * the other is looking at a context nothing in production can produce, and
208
+ * should treat the session as NOT impersonated rather than guess.
209
+ */
210
+ export const getActorAttribution = (): ActorAttributionSnapshot => {
211
+ const context = store().getStore();
212
+ return {
213
+ role: context?.role,
214
+ scope: context?.scope,
215
+ onBehalfOfUserId: context?.onBehalfOfUserId,
216
+ realUserId: context?.realUserId,
217
+ };
218
+ };