@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.
- package/README.md +95 -0
- package/dist/actor-context.d.ts +127 -0
- package/dist/actor-context.js +139 -0
- package/dist/append-only-extension.d.ts +28 -0
- package/dist/append-only-extension.js +72 -0
- package/dist/audit-extension.d.ts +18 -0
- package/dist/audit-extension.js +123 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +213 -0
- package/dist/search-normalize.d.ts +13 -0
- package/dist/search-normalize.js +20 -0
- package/package.json +96 -0
- package/prisma/migration-files.ts +33 -0
- package/prisma/migrations/20260725150000_payments_platform_core/migration.sql +98 -0
- package/prisma/migrations/20260725160000_payments_oauth_connections/migration.sql +25 -0
- package/prisma/migrations/20260725170000_add_saved_reports/migration.sql +22 -0
- package/prisma/migrations/20260726090000_payments_multi_provider_failover/migration.sql +67 -0
- package/prisma/migrations/20260726120000_payments_failover_policy/migration.sql +29 -0
- package/prisma/migrations/20260726130000_add_report_lifecycle/migration.sql +18 -0
- package/prisma/migrations/20260727120000_add_report_archived_status/migration.sql +9 -0
- package/prisma/migrations/20260727120000_payments_webhook_replay_budget/migration.sql +37 -0
- package/prisma/migrations/20260727190000_sweep_leases/migration.sql +13 -0
- package/prisma/migrations/20260728120000_add_product_research/migration.sql +112 -0
- package/prisma/migrations/20260728170000_add_manual_price_entries/migration.sql +31 -0
- package/prisma/migrations/20260729090000_integration_source_singleton/migration.sql +24 -0
- package/prisma/migrations/20260729120000_price_source_soft_delete/migration.sql +24 -0
- package/prisma/migrations/20260729140000_research_term_normalized/migration.sql +26 -0
- package/prisma/migrations/20260730120000_offer_outside_delivery_area/migration.sql +16 -0
- package/prisma/migrations/20260730120000_payments_charge_verified_at/migration.sql +23 -0
- package/prisma/migrations/20260730130000_research_runs_created_at_index/migration.sql +12 -0
- package/prisma/migrations/20260730210000_add_shifts/migration.sql +95 -0
- package/prisma/migrations/20260731000000_offer_shipping_unknown/migration.sql +34 -0
- package/prisma/migrations/20260731210000_shift_delete_guard/migration.sql +43 -0
- package/prisma/migrations/20260810120000_add_report_default_range/migration.sql +15 -0
- package/prisma/migrations/20260810160000_report_default_range_month/migration.sql +15 -0
- package/prisma/migrations/20260810180000_add_report_working_copy/migration.sql +13 -0
- package/prisma/plugin-migrations.json +28 -0
- package/prisma/schema/entity-lifecycle.prisma +126 -0
- package/prisma/schema/jobs.prisma +48 -0
- package/prisma/schema/product-research.prisma +217 -0
- package/prisma/schema/schema.prisma +19 -0
- package/prisma/schema/shift.prisma +34 -0
- package/src/actor-context.ts +218 -0
- package/src/append-only-extension.ts +75 -0
- package/src/audit-extension.ts +121 -0
- package/src/index.ts +233 -0
- 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
|
+
};
|