Skip to content

Commit 14046a5

Browse files
committed
feat(W3): app-owned catalog schema (Coupon/StoreConfig/Source) + synthetic seed + secrecy-gate inversion
1 parent 6952c45 commit 14046a5

4 files changed

Lines changed: 370 additions & 84 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- CreateTable
2+
CREATE TABLE "public"."coupons" (
3+
"id" TEXT NOT NULL,
4+
"code" TEXT NOT NULL,
5+
"site" TEXT,
6+
"title" TEXT NOT NULL,
7+
"description" TEXT NOT NULL,
8+
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0,
9+
"discount_type" TEXT,
10+
"discount_amount" DOUBLE PRECISION,
11+
"expiry" TEXT,
12+
"expired" BOOLEAN NOT NULL DEFAULT false,
13+
"times_used" INTEGER NOT NULL DEFAULT 0,
14+
"last_time_used" TIMESTAMP(3),
15+
"status" TEXT NOT NULL,
16+
"verification_message" TEXT,
17+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
20+
CONSTRAINT "coupons_pkey" PRIMARY KEY ("id")
21+
);
22+
23+
-- CreateTable
24+
CREATE TABLE "public"."store_configs" (
25+
"store_name" TEXT NOT NULL,
26+
"show_input_xpath" TEXT,
27+
"dismiss_button_xpath" TEXT,
28+
"coupon_input_xpath" TEXT,
29+
"apply_button_xpath" TEXT,
30+
"price_container_xpath" TEXT,
31+
"success_indicator_xpath" TEXT,
32+
"error_indicator_xpath" TEXT,
33+
"coupon_remove_xpath" TEXT,
34+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
35+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36+
37+
CONSTRAINT "store_configs_pkey" PRIMARY KEY ("store_name")
38+
);
39+
40+
-- CreateTable
41+
CREATE TABLE "public"."sources" (
42+
"id" TEXT NOT NULL,
43+
"source" TEXT NOT NULL,
44+
"websites" TEXT[] DEFAULT ARRAY[]::TEXT[],
45+
"status" TEXT NOT NULL,
46+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
47+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
48+
49+
CONSTRAINT "sources_pkey" PRIMARY KEY ("id")
50+
);
51+
52+
-- CreateIndex
53+
CREATE INDEX "coupons_site_idx" ON "public"."coupons"("site");
54+
55+
-- CreateIndex
56+
CREATE INDEX "coupons_status_expired_idx" ON "public"."coupons"("status", "expired");
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
-- SYNTHETIC SAMPLE DATA — NOT real scraped catalog (secrecy boundary). See DESIGN §2.
2+
--
3+
-- W3a catalog seed. Runs via `prisma migrate deploy` (CI + compose + the
4+
-- clean-volume proof) so a fresh app DB has meaningful coupon/store/source data
5+
-- for W4's reads WITHOUT any dependency on the external Python-owned
6+
-- caramel_coupons DB. Every value here is INVENTED (hand-authored), never copied
7+
-- from the real scraped catalog — the real selectors, coupon rows and source
8+
-- lists stay owned by the pipeline and are never committed to this repo.
9+
--
10+
-- Determinism + idempotency (clean-volume-safe, re-runnable):
11+
-- * Coupon ids are an obviously-synthetic '9000000xx' range that does NOT
12+
-- overlap the trust-loop tests' '999000xxx' fixtures.
13+
-- * created_at / updated_at / last_time_used are FIXED past timestamps (no
14+
-- now()), so counts/ordering are identical on every fresh apply.
15+
-- * ON CONFLICT DO NOTHING on each primary key — re-applying is a no-op.
16+
-- * status values are real VISIBLE + non-visible statuses from
17+
-- src/lib/coupons.ts STATUS_TABLE (valid / valid_with_warning /
18+
-- product_restriction / category_restricted / seller_specific / pending /
19+
-- retry are visible; invalid / expired are not) so W4's visible-status
20+
-- filter, ranking (rating DESC, created_at DESC) and the verified census
21+
-- all have exercisable data — including expired=true rows and a
22+
-- null-discount_type / null-expiry pending slice.
23+
24+
-- 30 coupons across the 3 demo stores (ebay.com / amazon.com / codecademy.com)
25+
-- plus walmart.com + target.com.
26+
INSERT INTO "public"."coupons" (
27+
"id", "code", "site", "title", "description", "rating",
28+
"discount_type", "discount_amount", "expiry", "expired",
29+
"times_used", "last_time_used", "status", "verification_message",
30+
"created_at", "updated_at"
31+
) VALUES
32+
('900000001','SAVE10NOW','ebay.com','10% off electronics','Get 10% off select electronics at checkout.',4.6,'PERCENTAGE',10,'2026-12-31',false,152,'2026-07-10 14:22:00','valid','Verified working on 2026-07-10','2026-06-01 12:00:00','2026-07-10 14:22:00'),
33+
('900000002','FREESHIP','ebay.com','Free shipping over $25','Free standard shipping on orders over $25.',4.1,'CASH',5,'2026-11-15',false,89,'2026-07-08 09:10:00','valid_with_warning','Works on most sellers; some exclusions','2026-06-02 12:00:00','2026-07-08 09:10:00'),
34+
('900000003','EXTRA15','ebay.com','15% off with promo','Extra 15% off selected refurbished items.',3.8,'PERCENTAGE',15,NULL,false,34,NULL,'pending',NULL,'2026-06-03 12:00:00','2026-06-03 12:00:00'),
35+
('900000004','DEAL20','ebay.com','$20 off $100','Take $20 off when you spend $100 or more.',4.3,'fixed',20,'2026-09-30',false,210,'2026-07-11 18:05:00','product_restriction','Applies to eligible categories only','2026-06-04 12:00:00','2026-07-11 18:05:00'),
36+
('900000005','OLDCODE','ebay.com','Expired seasonal code','Past promotion, no longer active.',2.2,'PERCENTAGE',25,'2026-07-05',true,500,'2026-07-01 12:00:00','expired','Expired 2026-07-05','2026-06-05 12:00:00','2026-07-05 00:00:00'),
37+
('900000006','SELLERX','ebay.com','Seller-specific 12% off','12% off from a specific top-rated seller.',3.9,'PERCENTAGE',12,'2026-10-20',false,5,NULL,'seller_specific','Valid for one seller storefront','2026-06-06 12:00:00','2026-06-06 12:00:00'),
38+
('900000007','CATLIMIT','ebay.com','Category-limited discount','8% off home and garden category.',3.5,'PERCENTAGE',8,NULL,false,12,NULL,'category_restricted',NULL,'2026-06-07 12:00:00','2026-06-07 12:00:00'),
39+
('900000008','DEADCODE','ebay.com','Invalid test code','Reported not working by users.',1.1,'CASH',3,NULL,false,0,NULL,'invalid','Failed verification','2026-06-08 12:00:00','2026-06-08 12:00:00'),
40+
('900000009','PRIME5','amazon.com','$5 off first order','New customers save $5 on first eligible order.',4.8,'CASH',5,'2026-12-01',false,320,'2026-07-12 08:00:00','valid','Verified working on 2026-07-12','2026-06-09 12:00:00','2026-07-12 08:00:00'),
41+
('900000010','BOOKS10','amazon.com','10% off books','Save 10% on select print books.',4.0,'PERCENTAGE',10,'2026-08-31',false,74,'2026-07-05 16:40:00','valid','Verified working','2026-06-10 12:00:00','2026-07-05 16:40:00'),
42+
('900000011','HOME25','amazon.com','$25 off home goods','$25 off when you spend $150 on home goods.',4.4,'fixed',25,'2026-11-30',false,143,'2026-07-09 11:15:00','product_restriction','Home and kitchen only','2026-06-11 12:00:00','2026-07-09 11:15:00'),
43+
('900000012','TRYRETRY','amazon.com','Rechecking discount','Discount is being re-verified.',3.2,'PERCENTAGE',7,NULL,false,18,NULL,'retry',NULL,'2026-06-12 12:00:00','2026-06-12 12:00:00'),
44+
('900000013','WARN15','amazon.com','15% off - may vary','15% off, final amount may vary by item.',3.7,'PERCENTAGE',15,'2026-10-10',false,61,'2026-07-01 10:00:00','valid_with_warning','Cap of $30 discount','2026-06-13 12:00:00','2026-07-01 10:00:00'),
45+
('900000014','PENDINGA','amazon.com','Unverified 20% off','Newly scraped, not yet verified.',0,NULL,NULL,NULL,false,2,NULL,'pending',NULL,'2026-06-14 12:00:00','2026-06-14 12:00:00'),
46+
('900000015','EXPD30','amazon.com','Expired 30% code','Old holiday code, expired.',2.9,'PERCENTAGE',30,'2026-07-08',true,640,'2026-07-02 12:00:00','expired','Expired 2026-07-08','2026-06-15 12:00:00','2026-07-08 00:00:00'),
47+
('900000016','CASHBACK8','amazon.com','$8 cashback','Get $8 back on qualifying purchase.',4.2,'CASH',8,'2026-09-01',false,97,'2026-07-06 13:30:00','valid','Verified working','2026-06-16 12:00:00','2026-07-06 13:30:00'),
48+
('900000017','LEARN40','codecademy.com','40% off Pro annual','Save 40% on a Codecademy Pro annual plan.',4.9,'PERCENTAGE',40,'2026-12-31',false,415,'2026-07-13 07:45:00','valid','Verified working on 2026-07-13','2026-06-17 12:00:00','2026-07-13 07:45:00'),
49+
('900000018','STUDENT20','codecademy.com','20% student discount','Extra 20% off for verified students.',4.5,'PERCENTAGE',20,'2026-10-31',false,128,'2026-07-04 19:20:00','valid_with_warning','Requires student verification','2026-06-18 12:00:00','2026-07-04 19:20:00'),
50+
('900000019','PROMO7','codecademy.com','$7 off monthly','$7 off your first month of Pro.',3.6,'fixed',7,NULL,false,22,NULL,'pending',NULL,'2026-06-19 12:00:00','2026-06-19 12:00:00'),
51+
('900000020','CATLEARN','codecademy.com','Career path discount','10% off career path bundles only.',3.9,'PERCENTAGE',10,'2026-09-15',false,40,'2026-06-28 09:00:00','category_restricted','Career paths only','2026-06-20 12:00:00','2026-06-28 09:00:00'),
52+
('900000021','RETRYCC','codecademy.com','Re-checking Pro code','Being re-verified after a transient failure.',3.0,'PERCENTAGE',25,NULL,false,9,NULL,'retry',NULL,'2026-06-21 12:00:00','2026-06-21 12:00:00'),
53+
('900000022','DEADCC','codecademy.com','Invalid promo','No longer accepted at checkout.',1.4,'PERCENTAGE',50,NULL,false,3,NULL,'invalid','Failed verification','2026-06-22 12:00:00','2026-06-22 12:00:00'),
54+
('900000023','WALMART10','walmart.com','$10 off $50','$10 off orders of $50 or more.',4.3,'fixed',10,'2026-11-01',false,176,'2026-07-07 15:10:00','valid','Verified working','2026-06-23 12:00:00','2026-07-07 15:10:00'),
55+
('900000024','GROCERY5','walmart.com','5% off groceries','5% off select grocery items.',0,NULL,NULL,NULL,false,51,NULL,'pending',NULL,'2026-06-24 12:00:00','2026-06-24 12:00:00'),
56+
('900000025','WMWARN','walmart.com','$15 off - exclusions','$15 off, some items excluded.',3.5,'CASH',15,'2026-08-20',false,33,'2026-06-30 12:00:00','valid_with_warning','Grocery excluded','2026-06-25 12:00:00','2026-06-30 12:00:00'),
57+
('900000026','WMEXP','walmart.com','Expired rollback code','Past rollback event, expired.',2.5,'PERCENTAGE',12,'2026-07-10',true,300,'2026-07-03 12:00:00','expired','Expired 2026-07-10','2026-06-26 12:00:00','2026-07-10 00:00:00'),
58+
('900000027','TARGET20','target.com','20% off apparel','20% off select apparel and accessories.',4.6,'PERCENTAGE',20,'2026-10-05',false,205,'2026-07-11 09:30:00','valid','Verified working','2026-06-27 12:00:00','2026-07-11 09:30:00'),
59+
('900000028','CIRCLE5','target.com','$5 Target Circle bonus','$5 bonus with Target Circle offer.',4.0,'CASH',5,'2026-09-20',false,88,'2026-07-02 14:00:00','seller_specific','Requires Target Circle','2026-06-28 12:00:00','2026-07-02 14:00:00'),
60+
('900000029','REDCARD','target.com','Extra 5% off','Additional 5% off with RedCard.',3.7,'PERCENTAGE',5,NULL,false,27,NULL,'product_restriction','RedCard holders','2026-06-29 12:00:00','2026-06-29 12:00:00'),
61+
('900000030','TGTPEND','target.com','Unverified $18 off','Scraped offer awaiting verification.',0,'fixed',18,NULL,false,1,NULL,'pending',NULL,'2026-06-30 12:00:00','2026-06-30 12:00:00')
62+
ON CONFLICT ("id") DO NOTHING;
63+
64+
-- 3 flattened store_configs for the demo stores. XPaths are SYNTHETIC (invented
65+
-- selector shapes), never the real scraped selectors. codecademy.com leaves the
66+
-- two optional xpaths NULL to exercise the nullable columns.
67+
INSERT INTO "public"."store_configs" (
68+
"store_name", "show_input_xpath", "dismiss_button_xpath", "coupon_input_xpath",
69+
"apply_button_xpath", "price_container_xpath", "success_indicator_xpath",
70+
"error_indicator_xpath", "coupon_remove_xpath", "updated_at", "created_at"
71+
) VALUES
72+
('ebay.com','//button[@data-test="promo-toggle"]','//button[@aria-label="close-banner"]','//input[@id="promo-code"]','//button[@data-test="apply-promo"]','//span[@data-test="order-total"]','//div[@data-test="promo-success"]','//div[@data-test="promo-error"]','//button[@data-test="remove-promo"]','2026-06-10 12:00:00','2026-06-01 12:00:00'),
73+
('amazon.com','//div[@id="promo-toggle-link"]','//input[@id="promoCancel"]','//input[@id="promoCode"]','//span[@id="promoApply"]//input','//td[@class="grand-total-price"]','//div[@id="promo-applied-msg"]','//div[@id="promo-error-msg"]','//a[@id="promo-remove-link"]','2026-06-11 12:00:00','2026-06-02 12:00:00'),
74+
('codecademy.com','//button[@data-testid="add-coupon"]',NULL,'//input[@name="couponCode"]','//button[@data-testid="apply-coupon"]','//div[@data-testid="summary-total"]','//div[@data-testid="coupon-success"]','//div[@data-testid="coupon-error"]',NULL,'2026-06-12 12:00:00','2026-06-03 12:00:00')
75+
ON CONFLICT ("store_name") DO NOTHING;
76+
77+
-- 3 sources. websites[] overlaps the seeded coupons' `site` so W4's
78+
-- listActiveSources LEFT JOIN aggregates are non-zero. 2 ACTIVE + 1 REQUESTED.
79+
INSERT INTO "public"."sources" (
80+
"id", "source", "websites", "status", "created_at", "updated_at"
81+
) VALUES
82+
('seed-source-001','Caramel Sample Feed A',ARRAY['ebay.com','amazon.com'],'ACTIVE','2026-06-01 12:00:00','2026-06-05 12:00:00'),
83+
('seed-source-002','Caramel Sample Feed B',ARRAY['codecademy.com'],'ACTIVE','2026-06-02 12:00:00','2026-06-06 12:00:00'),
84+
('seed-source-003','Caramel Sample Feed C',ARRAY['walmart.com','target.com'],'REQUESTED','2026-06-03 12:00:00','2026-06-07 12:00:00')
85+
ON CONFLICT ("id") DO NOTHING;

apps/caramel-app/prisma/schema.prisma

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,103 @@ model CouponSignal {
100100
@@map("coupon_signals")
101101
}
102102

103+
// -----------------------------------------------------------------------------
104+
// App-owned PUBLISHED coupon catalog (W3a of the Coupons Ownership Inversion).
105+
//
106+
// W3a is PURELY ADDITIVE: it creates these tables + a synthetic seed and models
107+
// the PUBLISHED shape the app serves. It does NOT yet change any read path —
108+
// couponsRepo.ts / couponsDb.ts still read the external, Python-owned
109+
// `caramel_coupons` DB over the porsager client; W4 flips those reads onto
110+
// these models. Every field's type + nullability is reconciled 1:1 with
111+
// couponsDb.ts's zod row schemas (the published contract) and couponsRepo.ts's
112+
// real SQL — see the per-field notes. What stays SECRET and is NEVER modelled
113+
// here is the scraper's PIPELINE-INTERNAL machinery (verification_stores,
114+
// store_verification_configs, verification_configs/runs, agent_*/scraper_*
115+
// state) — enforced by tests/unit/prisma-schema-secrecy.test.ts.
116+
117+
/// The published coupon row = couponsDb.ts's CouponListRowSchema. Column names
118+
/// mirror the external catalog's real columns (couponsRepo's SELECT list) so
119+
/// the W4 read swap is a pure source change. `id` is the pipeline coupon id
120+
/// (int8 externally) stored as a STRING to match couponIdSchema +
121+
/// CouponSignal.couponId and the app's `id: string` everywhere — NO FK to
122+
/// CouponSignal (a signal may outlive a tombstoned coupon).
123+
model Coupon {
124+
id String @id
125+
code String
126+
// Nullable at the column level (SiteRowSchema.site is nullable); listCoupons'
127+
// projection still requires it non-null — that stays a query-level guarantee.
128+
site String?
129+
title String
130+
description String
131+
// numeric externally; the app coerces to number, so Float preserves the read
132+
// shape (Decimal would return a Decimal object and break it).
133+
rating Float @default(0)
134+
// OPEN strings, NOT enums: the Python producer extends this vocabulary
135+
// ('PERCENTAGE'/'CASH'/'fixed'/lowercase/null) without it being schema drift.
136+
discountType String? @map("discount_type")
137+
discountAmount Float? @map("discount_amount")
138+
// TEXT, not DateTime — zod reads it as a string and expireCoupons writes
139+
// NOW()::text into it.
140+
expiry String?
141+
expired Boolean @default(false)
142+
timesUsed Int @default(0) @map("times_used")
143+
lastTimeUsed DateTime? @map("last_time_used")
144+
status String
145+
// Catalog column — the ONE allowed `verification*` @map target on Coupon
146+
// (prisma-schema-secrecy.test.ts allowlists it alongside verification_tokens).
147+
verificationMessage String? @map("verification_message")
148+
createdAt DateTime @default(now()) @map("created_at")
149+
// Pipeline last-write timestamp (W3b's only-if-newer authority) + the app's
150+
// last-writer-wins stamp on expire. Deliberately NOT @updatedAt — Prisma's
151+
// auto-@updatedAt would clobber the pipeline timestamp on every app write.
152+
// @default(now()) is a PLAIN default (fills the column only when an insert
153+
// omits it: seed rows + insert safety), never rewritten on update.
154+
updatedAt DateTime @default(now()) @map("updated_at")
155+
156+
@@index([site])
157+
@@index([status, expired])
158+
@@map("coupons")
159+
}
160+
161+
/// The FLATTENED published apply-config = couponsDb.ts's StoreConfigRowSchema,
162+
/// one row per store (ingest upserts by store_name). This is NOT the scraper's
163+
/// verification_stores ⋈ store_verification_configs join that PRODUCES it —
164+
/// that join stays pipeline-internal and secret. All 8 xpaths are nullable: the
165+
/// schema describes the table shape; the extension route's "needs 5 non-null"
166+
/// is a business filter layered on top, not a column constraint.
167+
model StoreConfig {
168+
storeName String @id @map("store_name")
169+
showInputXpath String? @map("show_input_xpath")
170+
dismissButtonXpath String? @map("dismiss_button_xpath")
171+
couponInputXpath String? @map("coupon_input_xpath")
172+
applyButtonXpath String? @map("apply_button_xpath")
173+
priceContainerXpath String? @map("price_container_xpath")
174+
successIndicatorXpath String? @map("success_indicator_xpath")
175+
errorIndicatorXpath String? @map("error_indicator_xpath")
176+
couponRemoveXpath String? @map("coupon_remove_xpath")
177+
// Plain pipeline timestamp (only-if-newer), NOT @updatedAt — see Coupon.updatedAt.
178+
updatedAt DateTime @default(now()) @map("updated_at")
179+
createdAt DateTime @default(now()) @map("created_at")
180+
181+
@@map("store_configs")
182+
}
183+
184+
/// The STORED `sources` columns only = couponsDb.ts's SourceRowSchema minus its
185+
/// computed aggregates (total_coupons/total_used/total_expired are LEFT JOIN
186+
/// COUNT/SUMs in listActiveSources, never stored — W4 keeps computing them).
187+
/// `id` is TEXT (requestSource inserts gen_random_uuid()::text).
188+
model Source {
189+
id String @id
190+
source String
191+
websites String[] @default([])
192+
status String
193+
createdAt DateTime @default(now()) @map("created_at")
194+
// Plain pipeline timestamp, NOT @updatedAt — see Coupon.updatedAt.
195+
updatedAt DateTime @default(now()) @map("updated_at")
196+
197+
@@map("sources")
198+
}
199+
103200
enum UserStatus {
104201
ACTIVE_USER
105202
DELETE_REQUESTED_BY_USER

0 commit comments

Comments
 (0)