@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,37 @@
1
+ -- @12-apps/payments-backend owned migration: give the webhook retry drain its own
2
+ -- retry budget (FUT-341 follow-up).
3
+ --
4
+ -- `attempts` was doing two incompatible jobs at once. It counted every
5
+ -- processing attempt AND it was the cap the drain used to decide a row had had
6
+ -- enough — but the live inbound path increments it too, and how often THAT
7
+ -- happens is the provider's decision, not ours.
8
+ --
9
+ -- The failure that forces this column: a store's order-confirmation handler
10
+ -- throws. The live delivery fails, the endpoint answers 5xx, and the provider
11
+ -- starts redelivering — every ten minutes or so, for hours. Each redelivery
12
+ -- re-enters the pipeline, fails again, and spends one of the ten attempts the
13
+ -- drain was supposed to have. Under two hours later the row is at the cap, drops
14
+ -- out of the drain's work list permanently, and has been replayed zero or one
15
+ -- times. The recovery mechanism disables itself precisely for the rows whose
16
+ -- provider retried hardest, and nothing reports it: a row that is not listed
17
+ -- moves no counter, so the drain looks idle and healthy.
18
+ --
19
+ -- So the two jobs get two columns. `attempts` stays what its name says — every
20
+ -- attempt, from either path, which is what support wants when asking "how many
21
+ -- times has anything tried this". `replay_attempts` is the drain's budget and
22
+ -- only the drain writes it.
23
+ --
24
+ -- Backfilled to 0 rather than to `attempts`. Nothing incremented `attempts`
25
+ -- before the drain existed, so every historical row's real replay count IS
26
+ -- zero; copying `attempts` across would import live-path failures as spent
27
+ -- budget and re-create the exact bug this column removes.
28
+ ALTER TABLE "payment_webhook_events"
29
+ ADD COLUMN IF NOT EXISTS "replay_attempts" INTEGER NOT NULL DEFAULT 0;
30
+
31
+ -- The drain's query, exactly: unsettled rows, one branch per eligible
32
+ -- `replay_attempts` count (each with its own `updated_at` backoff cutoff),
33
+ -- ordered by `updated_at`. Leading with `status` is what keeps it away from the
34
+ -- PROCESSED rows, which are the overwhelming majority of this table and grow
35
+ -- without bound while the unsettled set stays small.
36
+ CREATE INDEX IF NOT EXISTS "payment_webhook_events_replay_idx"
37
+ ON "payment_webhook_events"("status", "replay_attempts", "updated_at");
@@ -0,0 +1,13 @@
1
+ -- Named, time-bounded claims on the scheduled sweeps (FUT-343).
2
+ --
3
+ -- One row per sweep. The claim is a conditional UPDATE on `expires_at`, so the
4
+ -- database picks the winner between racing workers; `expires_at` also makes a
5
+ -- crashed holder recoverable with no operator action.
6
+ CREATE TABLE IF NOT EXISTS "sweep_leases" (
7
+ "name" TEXT NOT NULL,
8
+ "holder" TEXT NOT NULL,
9
+ "acquired_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ "expires_at" TIMESTAMP(3) NOT NULL,
11
+
12
+ CONSTRAINT "sweep_leases_pkey" PRIMARY KEY ("name")
13
+ );
@@ -0,0 +1,112 @@
1
+ -- FUT #405: research-to-buy core tables. Owned by @12-apps/product-research (this
2
+ -- migration reaches the host's migrations folder via sync-prisma-plugins).
3
+ -- Deliberately NO foreign key into host tables (self-contained package schema,
4
+ -- entity-lifecycle / payments-backend doctrine): the host repository layer
5
+ -- scopes every read/write by client_id, and the researched catalog item is
6
+ -- named by value via (catalog_ref_type, catalog_ref_id).
7
+
8
+ CREATE TABLE "price_sources" (
9
+ "id" TEXT NOT NULL,
10
+ "client_id" TEXT NOT NULL,
11
+ "type" TEXT NOT NULL,
12
+ "name" TEXT NOT NULL,
13
+ "config" JSONB NOT NULL DEFAULT '{}',
14
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
15
+ "status" TEXT NOT NULL DEFAULT 'ACTIVE',
16
+ "last_error_at" TIMESTAMP(3),
17
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ "updated_at" TIMESTAMP(3) NOT NULL,
19
+
20
+ CONSTRAINT "price_sources_pkey" PRIMARY KEY ("id"),
21
+ CONSTRAINT "price_sources_type_check" CHECK (
22
+ "type" IN ('VTEX', 'MERCADO_LIVRE', 'SERP', 'AMAZON', 'MANUAL')
23
+ ),
24
+ CONSTRAINT "price_sources_status_check" CHECK (
25
+ "status" IN ('ACTIVE', 'DEGRADED')
26
+ )
27
+ );
28
+
29
+ CREATE UNIQUE INDEX "price_sources_client_id_name_key" ON "price_sources"("client_id", "name");
30
+
31
+ CREATE INDEX "price_sources_client_id_enabled_idx" ON "price_sources"("client_id", "enabled");
32
+
33
+ CREATE TABLE "research_requests" (
34
+ "id" TEXT NOT NULL,
35
+ "client_id" TEXT NOT NULL,
36
+ "catalog_ref_type" TEXT,
37
+ "catalog_ref_id" TEXT,
38
+ "term" TEXT NOT NULL,
39
+ "brand" TEXT,
40
+ "ean" TEXT,
41
+ "quantity" INTEGER NOT NULL DEFAULT 1,
42
+ "region" TEXT,
43
+ "requested_by" TEXT,
44
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
45
+
46
+ CONSTRAINT "research_requests_pkey" PRIMARY KEY ("id")
47
+ );
48
+
49
+ CREATE INDEX "research_requests_client_id_created_at_idx" ON "research_requests"("client_id", "created_at");
50
+
51
+ CREATE TABLE "research_runs" (
52
+ "id" TEXT NOT NULL,
53
+ "client_id" TEXT NOT NULL,
54
+ "request_id" TEXT NOT NULL,
55
+ "status" TEXT NOT NULL DEFAULT 'PENDING',
56
+ "source_stats" JSONB NOT NULL DEFAULT '[]',
57
+ "error" TEXT,
58
+ "started_at" TIMESTAMP(3),
59
+ "finished_at" TIMESTAMP(3),
60
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
61
+ "updated_at" TIMESTAMP(3) NOT NULL,
62
+
63
+ CONSTRAINT "research_runs_pkey" PRIMARY KEY ("id"),
64
+ CONSTRAINT "research_runs_status_check" CHECK (
65
+ "status" IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')
66
+ ),
67
+ CONSTRAINT "research_runs_request_id_fkey" FOREIGN KEY ("request_id")
68
+ REFERENCES "research_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE
69
+ );
70
+
71
+ CREATE INDEX "research_runs_client_id_created_at_idx" ON "research_runs"("client_id", "created_at");
72
+
73
+ CREATE INDEX "research_runs_request_id_idx" ON "research_runs"("request_id");
74
+
75
+ CREATE TABLE "supplier_offers" (
76
+ "id" TEXT NOT NULL,
77
+ "client_id" TEXT NOT NULL,
78
+ "run_id" TEXT NOT NULL,
79
+ "source_id" TEXT,
80
+ "source_type" TEXT NOT NULL,
81
+ "supplier_name" TEXT NOT NULL,
82
+ "title" TEXT NOT NULL,
83
+ "url" TEXT,
84
+ "image_url" TEXT,
85
+ "currency" TEXT NOT NULL DEFAULT 'BRL',
86
+ "price_cents" INTEGER NOT NULL,
87
+ "shipping_cents" INTEGER NOT NULL DEFAULT 0,
88
+ "pack_quantity" INTEGER NOT NULL DEFAULT 1,
89
+ "unit_price_cents" INTEGER NOT NULL,
90
+ "total_cents" INTEGER NOT NULL,
91
+ "availability" TEXT,
92
+ "eta_days" INTEGER,
93
+ "relevance_score" DOUBLE PRECISION NOT NULL,
94
+ "rank" INTEGER,
95
+ "raw" JSONB,
96
+ "expires_at" TIMESTAMP(3),
97
+ "purchased_at" TIMESTAMP(3),
98
+ "hidden_at" TIMESTAMP(3),
99
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
100
+
101
+ CONSTRAINT "supplier_offers_pkey" PRIMARY KEY ("id"),
102
+ CONSTRAINT "supplier_offers_run_id_fkey" FOREIGN KEY ("run_id")
103
+ REFERENCES "research_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
104
+ CONSTRAINT "supplier_offers_source_id_fkey" FOREIGN KEY ("source_id")
105
+ REFERENCES "price_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE
106
+ );
107
+
108
+ CREATE INDEX "supplier_offers_run_id_rank_idx" ON "supplier_offers"("run_id", "rank");
109
+
110
+ CREATE INDEX "supplier_offers_client_id_created_at_idx" ON "supplier_offers"("client_id", "created_at");
111
+
112
+ CREATE INDEX "supplier_offers_client_id_expires_at_idx" ON "supplier_offers"("client_id", "expires_at");
@@ -0,0 +1,31 @@
1
+ -- Manual price import (FUT-415): the standing dataset behind MANUAL-type
2
+ -- price sources. Rows come from price-list imports or one-off typed quotes
3
+ -- and are read by the manual connector during research runs; `valid_until`
4
+ -- is the staleness horizon and `batch_id` groups one import so a re-import
5
+ -- replaces the previous list. PGlite-safe (plain DDL).
6
+
7
+ CREATE TABLE "manual_price_entries" (
8
+ "id" TEXT NOT NULL,
9
+ "client_id" TEXT NOT NULL,
10
+ "source_id" TEXT NOT NULL,
11
+ "batch_id" TEXT NOT NULL,
12
+ "supplier_name" TEXT NOT NULL,
13
+ "title" TEXT NOT NULL,
14
+ "brand" TEXT,
15
+ "ean" TEXT,
16
+ "pack_quantity" INTEGER,
17
+ "price_cents" INTEGER NOT NULL,
18
+ "currency" TEXT NOT NULL DEFAULT 'BRL',
19
+ "url" TEXT,
20
+ "availability" TEXT,
21
+ "eta_days" INTEGER,
22
+ "valid_until" TIMESTAMP(3) NOT NULL,
23
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
24
+
25
+ CONSTRAINT "manual_price_entries_pkey" PRIMARY KEY ("id"),
26
+ CONSTRAINT "manual_price_entries_source_id_fkey" FOREIGN KEY ("source_id")
27
+ REFERENCES "price_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE
28
+ );
29
+
30
+ CREATE INDEX "manual_price_entries_client_id_source_id_valid_until_idx"
31
+ ON "manual_price_entries"("client_id", "source_id", "valid_until");
@@ -0,0 +1,24 @@
1
+ -- FUT-434: paid connectors are singleton integrations — at most ONE source row
2
+ -- per (tenant, type) for the credentialed types, each running on the tenant's
3
+ -- own API key. VTEX and MANUAL stay multi-instance, created by name.
4
+ --
5
+ -- Belt-and-braces order: any duplicates that slipped in while the create
6
+ -- dialog still offered these types are collapsed first (the OLDEST row wins —
7
+ -- deterministic, and its id is the one past runs reference; supplier_offers
8
+ -- survive via ON DELETE SET NULL), then the partial unique index makes the
9
+ -- invariant a database fact. Like the CHECK constraints, the partial index is
10
+ -- migration-only DDL the Prisma schema cannot express. PGlite-safe (plain DDL).
11
+
12
+ DELETE FROM "price_sources" AS dup
13
+ USING "price_sources" AS keeper
14
+ WHERE dup."type" IN ('SERP', 'AMAZON', 'MERCADO_LIVRE')
15
+ AND keeper."client_id" = dup."client_id"
16
+ AND keeper."type" = dup."type"
17
+ AND (
18
+ keeper."created_at" < dup."created_at"
19
+ OR (keeper."created_at" = dup."created_at" AND keeper."id" < dup."id")
20
+ );
21
+
22
+ CREATE UNIQUE INDEX "price_sources_client_id_integration_type_key"
23
+ ON "price_sources"("client_id", "type")
24
+ WHERE "type" IN ('SERP', 'AMAZON', 'MERCADO_LIVRE');
@@ -0,0 +1,24 @@
1
+ -- Price sources become soft-deletable (FUT-433).
2
+ --
3
+ -- Deleting a source must keep past research runs readable: `supplier_offers`
4
+ -- rows point at the source (`source_id`), and a run's report renders the
5
+ -- supplier name / source type it snapshotted by value. A hard DELETE would
6
+ -- SET NULL the FK (the schema's backstop), but soft-delete is strictly better
7
+ -- here — the row survives, so `source_id` keeps resolving, and the roster,
8
+ -- new runs and manual imports simply filter `archived_at IS NULL`. Same
9
+ -- doctrine as the host's Discount model: history-bearing rows are archived,
10
+ -- never removed. There is no restore path.
11
+ ALTER TABLE "price_sources" ADD COLUMN "archived_at" TIMESTAMP(3);
12
+
13
+ -- The tenant unique on (client_id, name) becomes PARTIAL, binding LIVE rows
14
+ -- only. Unconditional, a deleted "Atacadão" would squat its name forever —
15
+ -- the exact dead-duplicate pile-up this migration exists to clean up. The
16
+ -- index NAME is preserved so a P2002 keeps classifying by constraint name.
17
+ -- Partial indexes are plain PostgreSQL and PGlite runs the real engine, so
18
+ -- this replays identically there; IF EXISTS / IF NOT EXISTS keep the replay
19
+ -- idempotent for suites that apply every migration into a fresh database.
20
+ DROP INDEX IF EXISTS "price_sources_client_id_name_key";
21
+
22
+ CREATE UNIQUE INDEX IF NOT EXISTS "price_sources_client_id_name_key"
23
+ ON "price_sources"("client_id", "name")
24
+ WHERE "archived_at" IS NULL;
@@ -0,0 +1,26 @@
1
+ -- FUT-430: widened widget lookup. `term_normalized` mirrors the package's
2
+ -- normalizeText(term) — lowercased, accents stripped, punctuation runs
3
+ -- collapsed to single spaces — so the best-prices widget finds a product's
4
+ -- research past renames and accent variants. Kept in sync by the host on
5
+ -- write; this migration backfills existing rows.
6
+ --
7
+ -- The backfill folds accents in pure SQL via translate() (no `unaccent`
8
+ -- extension, which PGlite lacks — same doctrine as the FUT-168 search_name
9
+ -- migration) so it runs identically on PostgreSQL + PGlite. Characters
10
+ -- outside the mapped pt-BR set fall through to the punctuation collapse,
11
+ -- matching normalizeText for every term the pt-BR hosts store.
12
+
13
+ ALTER TABLE "research_requests" ADD COLUMN "term_normalized" TEXT;
14
+
15
+ UPDATE "research_requests"
16
+ SET "term_normalized" = btrim(regexp_replace(
17
+ translate(
18
+ lower("term"),
19
+ 'áàâãäçéèêëíìîïóòôõöúùûüýÿñ',
20
+ 'aaaaaceeeeiiiiooooouuuuyyn'
21
+ ),
22
+ '[^a-z0-9,.]+', ' ', 'g'
23
+ ));
24
+
25
+ CREATE INDEX "research_requests_client_id_term_normalized_idx"
26
+ ON "research_requests"("client_id", "term_normalized");
@@ -0,0 +1,16 @@
1
+ -- FUT-491: an offer can now carry "this price is the store's DEFAULT region's".
2
+ --
3
+ -- A VTEX store that does not deliver to the researched CEP used to answer with
4
+ -- nothing (FUT-416). It now answers with its default-region catalog, flagged —
5
+ -- information beats silence, as long as the caveat travels with the price. The
6
+ -- flag has to survive the re-read, the cached run and the offers API, so it is
7
+ -- a real column: `raw` is the VERBATIM connector payload kept for offline
8
+ -- replay and must not carry fields of ours.
9
+ --
10
+ -- NOT NULL DEFAULT false: every offer written before this change was priced for
11
+ -- the buyer's own region (the only arms that produced offers were "served" and
12
+ -- "no regional knowledge"), so false is the truthful backfill rather than a
13
+ -- placeholder. IF NOT EXISTS keeps the replay idempotent for suites that apply
14
+ -- every migration into a fresh database.
15
+ ALTER TABLE "supplier_offers"
16
+ ADD COLUMN IF NOT EXISTS "outside_delivery_area" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,23 @@
1
+ -- @12-apps/payments-backend owned migration: record when a REAL charge last
2
+ -- succeeded, so activation can be earned instead of asserted (FUT-463).
3
+ --
4
+ -- `last_verified_at` cannot answer this. It is stamped by the credential
5
+ -- PROBE, and the probe only asks "do these keys authenticate" — a PagBank
6
+ -- Connect grant answers yes while refusing every real charge with
7
+ -- `403 ACCESS_DENIED` until the integration is homologated. That is not a
8
+ -- hypothetical: a store showed `VERIFIED`, was switched on by hand, and every
9
+ -- shopper was declined at checkout.
10
+ --
11
+ -- Nullable with NO backfill, deliberately. Backfilling from `last_verified_at`
12
+ -- would grandfather exactly the rows this exists to catch — including the one
13
+ -- that prompted it, which is enabled today and cannot charge. Leaving it null
14
+ -- means "never proven", which is the truth for every pre-existing row.
15
+ --
16
+ -- No row is disabled here either: taking a working store's checkout offline
17
+ -- from a migration is not recoverable by the person it happens to. The
18
+ -- application enforces the rule from both ends instead — enabling REQUIRES a
19
+ -- proven charge, and a failed verification disables — so a connection that
20
+ -- cannot charge switches itself off the next time it is tested, in front of
21
+ -- the owner, rather than silently at deploy time.
22
+ ALTER TABLE "payment_provider_configs"
23
+ ADD COLUMN "charge_verified_at" TIMESTAMP(3);
@@ -0,0 +1,12 @@
1
+ -- FUT-496: a time-only index on research_runs.
2
+ --
3
+ -- The host's superadmin diagnostics inbox reads recent runs ACROSS EVERY TENANT
4
+ -- ("every failed source in the last 24 h"), i.e. `created_at >= $1` with no
5
+ -- client_id bound. The existing `(client_id, created_at)` index cannot serve
6
+ -- that predicate — its leading column is unbound and PostgreSQL has no btree
7
+ -- skip scan — so the planner falls back to a Parallel Seq Scan over the whole
8
+ -- table. research_runs has no retention/pruning job, so that scan only ever
9
+ -- gets more expensive. A tenant-scoped read is unaffected: it keeps using the
10
+ -- composite index, which is strictly better for it.
11
+
12
+ CREATE INDEX "research_runs_created_at_idx" ON "research_runs"("created_at");
@@ -0,0 +1,95 @@
1
+ -- @12-apps/shift (FUT-446): immutable work periods and optional tenant policy.
2
+ CREATE TABLE "shifts" (
3
+ "id" TEXT NOT NULL,
4
+ "client_id" TEXT NOT NULL,
5
+ "user_id" TEXT NOT NULL,
6
+ "kind" TEXT NOT NULL,
7
+ "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
+ "ended_at" TIMESTAMP(3),
9
+ "ended_reason" TEXT,
10
+ "ended_by_user_id" TEXT,
11
+ "resource_assignment_id" TEXT,
12
+ "resource_type" TEXT,
13
+ "resource_id" TEXT,
14
+
15
+ CONSTRAINT "shifts_pkey" PRIMARY KEY ("id"),
16
+ CONSTRAINT "shifts_kind_check"
17
+ CHECK ("kind" IN ('kitchen', 'service')),
18
+ CONSTRAINT "shifts_ended_reason_check"
19
+ CHECK ("ended_reason" IS NULL OR "ended_reason" IN ('user', 'supervisor', 'auto')),
20
+ CONSTRAINT "shifts_end_state_check"
21
+ CHECK (
22
+ ("ended_at" IS NULL AND "ended_reason" IS NULL AND "ended_by_user_id" IS NULL)
23
+ OR
24
+ ("ended_at" IS NOT NULL AND "ended_reason" IS NOT NULL
25
+ AND ("ended_reason" = 'auto' OR "ended_by_user_id" IS NOT NULL))
26
+ ),
27
+ CONSTRAINT "shifts_time_order_check"
28
+ CHECK ("ended_at" IS NULL OR "ended_at" >= "started_at"),
29
+ CONSTRAINT "shifts_resource_snapshot_check"
30
+ CHECK (
31
+ ("resource_assignment_id" IS NULL AND "resource_type" IS NULL AND "resource_id" IS NULL)
32
+ OR
33
+ ("resource_assignment_id" IS NOT NULL AND "resource_type" IS NOT NULL AND "resource_id" IS NOT NULL)
34
+ )
35
+ );
36
+
37
+ CREATE UNIQUE INDEX "shifts_resource_assignment_id_key"
38
+ ON "shifts"("resource_assignment_id");
39
+
40
+ -- Prisma cannot express a partial unique index. This is the concurrency
41
+ -- guarantee: two tabs may race the pre-check, but only one open period commits.
42
+ CREATE UNIQUE INDEX "shifts_open_client_user_key"
43
+ ON "shifts"("client_id", "user_id")
44
+ WHERE "ended_at" IS NULL;
45
+
46
+ CREATE INDEX "shifts_client_id_ended_at_idx"
47
+ ON "shifts"("client_id", "ended_at");
48
+
49
+ CREATE INDEX "shifts_client_id_user_id_started_at_idx"
50
+ ON "shifts"("client_id", "user_id", "started_at");
51
+
52
+ -- A shift's identity/resource snapshots never change, and a closed row is the
53
+ -- immutable work record. The service exposes no edit/delete API; this trigger
54
+ -- keeps direct SQL and future adapters from creating a back door around it.
55
+ CREATE FUNCTION "guard_shift_immutability"()
56
+ RETURNS TRIGGER
57
+ LANGUAGE plpgsql
58
+ AS $$
59
+ BEGIN
60
+ IF TG_OP = 'DELETE' THEN
61
+ RAISE EXCEPTION 'shift records cannot be deleted';
62
+ END IF;
63
+
64
+ IF OLD."ended_at" IS NOT NULL THEN
65
+ RAISE EXCEPTION 'closed shifts are immutable';
66
+ END IF;
67
+
68
+ IF NEW."client_id" IS DISTINCT FROM OLD."client_id"
69
+ OR NEW."user_id" IS DISTINCT FROM OLD."user_id"
70
+ OR NEW."kind" IS DISTINCT FROM OLD."kind"
71
+ OR NEW."started_at" IS DISTINCT FROM OLD."started_at"
72
+ OR NEW."resource_assignment_id" IS DISTINCT FROM OLD."resource_assignment_id"
73
+ OR NEW."resource_type" IS DISTINCT FROM OLD."resource_type"
74
+ OR NEW."resource_id" IS DISTINCT FROM OLD."resource_id" THEN
75
+ RAISE EXCEPTION 'shift identity and resource snapshots are immutable';
76
+ END IF;
77
+
78
+ RETURN NEW;
79
+ END;
80
+ $$;
81
+
82
+ CREATE TRIGGER "shifts_immutable_guard"
83
+ BEFORE UPDATE OR DELETE ON "shifts"
84
+ FOR EACH ROW
85
+ EXECUTE FUNCTION "guard_shift_immutability"();
86
+
87
+ CREATE TABLE "shift_tenant_configs" (
88
+ "client_id" TEXT NOT NULL,
89
+ "auto_close_hours" INTEGER NOT NULL DEFAULT 16,
90
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
91
+
92
+ CONSTRAINT "shift_tenant_configs_pkey" PRIMARY KEY ("client_id"),
93
+ CONSTRAINT "shift_tenant_configs_auto_close_hours_check"
94
+ CHECK ("auto_close_hours" BETWEEN 1 AND 168)
95
+ );
@@ -0,0 +1,34 @@
1
+ -- FUT-518: "the source never said what shipping costs" stops being spelled 0.
2
+ --
3
+ -- `shipping_cents` was NOT NULL DEFAULT 0, so an offer whose freight nobody
4
+ -- knew was stored — and RANKED — as free shipping. That is not a rare corner:
5
+ -- the SERP connector (Google Shopping) never states a shipping cost at all, so
6
+ -- every one of its offers competed on an understated total against Amazon
7
+ -- (which writes 0 only on a real free-shipping signal) and Mercado Livre
8
+ -- (which parses it). Cheapest-first then hands the recommendation to whichever
9
+ -- offer hid its freight best — the same understated-total failure as FUT-497.
10
+ --
11
+ -- After this: NULL = the source never stated a shipping cost, so `total_cents`
12
+ -- is a MINIMUM and every surface caveats it; 0 = the source stated FREE.
13
+ -- The DEFAULT goes too, deliberately: with two writers of this table
14
+ -- (`appendRunOffers` and `insertOffers`, both through `toOfferRow` in
15
+ -- apps/web/lib/repositories/research-store.ts) and no raw SQL insert anywhere,
16
+ -- the column is always stated explicitly. Keeping a default would let a future
17
+ -- writer that forgets the column silently re-introduce the phantom zero.
18
+ --
19
+ -- NO BACKFILL, AND THAT IS THE HONEST ANSWER. Every row written before this
20
+ -- migration holds 0, and 0 now means "the source stated free". Those rows are
21
+ -- indistinguishable from genuinely free-shipping ones — the information to tell
22
+ -- them apart was destroyed at write time and no query can recover it. They are
23
+ -- left as they are: historical runs keep reading as free shipping, which is
24
+ -- wrong for some of them, but rewriting them to NULL would be equally wrong for
25
+ -- the rest and would additionally erase the real free-shipping answers Amazon
26
+ -- and Mercado Livre did give. The distinction starts with the runs from here on.
27
+ --
28
+ -- Idempotent (IF EXISTS / DROP DEFAULT is a no-op when absent) so the replay
29
+ -- suites that apply every migration into a fresh database stay green.
30
+ ALTER TABLE "supplier_offers"
31
+ ALTER COLUMN "shipping_cents" DROP NOT NULL;
32
+
33
+ ALTER TABLE "supplier_offers"
34
+ ALTER COLUMN "shipping_cents" DROP DEFAULT;
@@ -0,0 +1,43 @@
1
+ -- @12-apps/shift (FUT-446): the immutability guard becomes UPDATE-only.
2
+ --
3
+ -- The original trigger fired `BEFORE UPDATE OR DELETE` and raised
4
+ -- unconditionally on DELETE. `shifts.client_id` is a by-value tenant reference
5
+ -- with no foreign key (that is what keeps the package host-agnostic), so a host
6
+ -- that deletes its tenant row and then sweeps the remaining by-value tables by
7
+ -- `client_id` could never remove a shift: the RAISE aborted the whole
8
+ -- transaction. Future Pay's demo-store reset does exactly that on EVERY deploy,
9
+ -- so a single shift row would have wedged the deploy step.
10
+ --
11
+ -- Deletability is host policy, not a package invariant. What the guard actually
12
+ -- protects — a closed shift is an immutable work record, and a shift's
13
+ -- identity/resource snapshot never changes — is entirely an UPDATE concern and
14
+ -- is preserved verbatim below. The package still exposes no delete API.
15
+ DROP TRIGGER IF EXISTS "shifts_immutable_guard" ON "shifts";
16
+
17
+ CREATE OR REPLACE FUNCTION "guard_shift_immutability"()
18
+ RETURNS TRIGGER
19
+ LANGUAGE plpgsql
20
+ AS $$
21
+ BEGIN
22
+ IF OLD."ended_at" IS NOT NULL THEN
23
+ RAISE EXCEPTION 'closed shifts are immutable';
24
+ END IF;
25
+
26
+ IF NEW."client_id" IS DISTINCT FROM OLD."client_id"
27
+ OR NEW."user_id" IS DISTINCT FROM OLD."user_id"
28
+ OR NEW."kind" IS DISTINCT FROM OLD."kind"
29
+ OR NEW."started_at" IS DISTINCT FROM OLD."started_at"
30
+ OR NEW."resource_assignment_id" IS DISTINCT FROM OLD."resource_assignment_id"
31
+ OR NEW."resource_type" IS DISTINCT FROM OLD."resource_type"
32
+ OR NEW."resource_id" IS DISTINCT FROM OLD."resource_id" THEN
33
+ RAISE EXCEPTION 'shift identity and resource snapshots are immutable';
34
+ END IF;
35
+
36
+ RETURN NEW;
37
+ END;
38
+ $$;
39
+
40
+ CREATE TRIGGER "shifts_immutable_guard"
41
+ BEFORE UPDATE ON "shifts"
42
+ FOR EACH ROW
43
+ EXECUTE FUNCTION "guard_shift_immutability"();
@@ -0,0 +1,15 @@
1
+ -- The period a saved report OPENS on (FUT-755). Nullable: NULL means "no
2
+ -- preference", which readers resolve to 30d — the behaviour every existing row
3
+ -- already has, so this migration changes nothing about them.
4
+ ALTER TABLE "saved_reports"
5
+ ADD COLUMN IF NOT EXISTS "default_range" TEXT;
6
+
7
+ -- The presets the range API actually supports (REPORT_RANGES). Enforced here as
8
+ -- well as at the wire, because the store is written by MCP authors too and a
9
+ -- value the reader cannot resolve would open the report on nothing.
10
+ ALTER TABLE "saved_reports"
11
+ DROP CONSTRAINT IF EXISTS "saved_reports_default_range_check";
12
+
13
+ ALTER TABLE "saved_reports"
14
+ ADD CONSTRAINT "saved_reports_default_range_check"
15
+ CHECK ("default_range" IS NULL OR "default_range" IN ('today', '7d', '30d'));
@@ -0,0 +1,15 @@
1
+ -- `Este mês` joins the period toggle (FUT-755), so it joins the periods a saved
2
+ -- report may OPEN on. The CHECK is the third place the list is written — after
3
+ -- `REPORT_DEFAULT_RANGES` and the wire's `z.enum` — and the one an MCP author
4
+ -- writing straight to the store hits, so it has to learn the new value or a
5
+ -- report whose default the toggle offers cannot be saved.
6
+ --
7
+ -- Re-stated rather than altered: Postgres has no `ALTER CONSTRAINT` for a
8
+ -- CHECK's expression, and DROP-then-ADD under `IF EXISTS` is idempotent, so
9
+ -- this replays cleanly onto a database that already ran it.
10
+ ALTER TABLE "saved_reports"
11
+ DROP CONSTRAINT IF EXISTS "saved_reports_default_range_check";
12
+
13
+ ALTER TABLE "saved_reports"
14
+ ADD CONSTRAINT "saved_reports_default_range_check"
15
+ CHECK ("default_range" IS NULL OR "default_range" IN ('today', '7d', '30d', 'month'));
@@ -0,0 +1,13 @@
1
+ -- Unpublished changes to a PUBLISHED report (FUT-755).
2
+ --
3
+ -- A report already has `status` ('draft' | 'published' | 'archived'), but that
4
+ -- is the report's LIFECYCLE, not a revision of it: a report with status
5
+ -- 'draft' has never been published. Editing a PUBLISHED report is the case
6
+ -- this column exists for — its readers are looking at it right now, so the
7
+ -- in-progress edit is stored beside the live document and `spec` is left
8
+ -- alone until the author publishes.
9
+ --
10
+ -- NULL means "no unpublished changes", which is every row that predates this
11
+ -- migration, so nothing about existing reports changes.
12
+ ALTER TABLE "saved_reports"
13
+ ADD COLUMN IF NOT EXISTS "working_copy" JSONB;
@@ -0,0 +1,28 @@
1
+ {
2
+ "$comment": "Migrations in prisma/migrations that were COPIED from a plugin package. Generated by scripts/sync-prisma-plugins.mjs — do not edit by hand. Its only job is to let the sync recognise a copy whose owner renamed or deleted it, since a copy is otherwise indistinguishable from a host-owned migration.",
3
+ "migrations": [
4
+ "20260725150000_payments_platform_core",
5
+ "20260725160000_payments_oauth_connections",
6
+ "20260725170000_add_saved_reports",
7
+ "20260726090000_payments_multi_provider_failover",
8
+ "20260726120000_payments_failover_policy",
9
+ "20260726130000_add_report_lifecycle",
10
+ "20260727120000_add_report_archived_status",
11
+ "20260727120000_payments_webhook_replay_budget",
12
+ "20260727190000_sweep_leases",
13
+ "20260728120000_add_product_research",
14
+ "20260728170000_add_manual_price_entries",
15
+ "20260729090000_integration_source_singleton",
16
+ "20260729120000_price_source_soft_delete",
17
+ "20260729140000_research_term_normalized",
18
+ "20260730120000_offer_outside_delivery_area",
19
+ "20260730120000_payments_charge_verified_at",
20
+ "20260730130000_research_runs_created_at_index",
21
+ "20260730210000_add_shifts",
22
+ "20260731000000_offer_shipping_unknown",
23
+ "20260731210000_shift_delete_guard",
24
+ "20260810120000_add_report_default_range",
25
+ "20260810160000_report_default_range_month",
26
+ "20260810180000_add_report_working_copy"
27
+ ]
28
+ }