openvibers/openvibe.coupons Commits
Coupons moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.
Changes
@@@ -13,6 +13,14 @@ BASE_URL=http://localhost:4850TRUST_PROXY=2# SQLite database (production unit: /var/lib/openvibe-coupons/coupons.db)++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh coupons: the runtime role through PgBouncer,++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.COUPONS_DB_PATH=./data/coupons.db# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: coupons++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176@@@ -32,7 +32,7 @@ Four rules shape everything here:## Owns--The nine charter tables live in Coupons' own SQLite (`COUPONS_DB_PATH`):++The nine charter tables live in Coupons' own PostgreSQL database (`ov_coupons` on the host's data role, ADR-035; schema in [migrations/](migrations/)):| Charter table | What it is ||---|---|@@@ -296,9 +296,11 @@ What Coupons calls elsewhere, and with which grant, is under## Depends on--- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.4.0 (seo gate, ssr,-- index-hooks), `openvibe-contracts` v0.53.0, `openvibe-shared` v1.25.0 (chrome, app icon, footer,-- legal, release, metrics, ready, seo), `openvibe-sdk` v0.12.0 (events outbox, service tokens,++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through++ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (seo gate, ssr,++ index-hooks), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0 (Frame, app icon, footer,++ legal, release, metrics, ready, seo), `openvibe-sdk` v0.20.0 (events outbox, service tokens,per-actor limits).- **OpenVibe.Network:**- SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`@@@ -423,10 +425,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4850 (set OV_OAUTHProduction deploys with `sudo ovhost deploy coupons` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.coupons`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-coupons.service` on `127.0.0.1:4850`, the env file `/etc/openvibe/coupons.env`.++The unit is `openvibe-coupons.service` on `127.0.0.1:4850`, the env file `/etc/openvibe/coupons.env`. The database is++`ov_coupons` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh coupons` writes its settings); the++release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk++`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old++`/var/lib/openvibe-coupons/coupons.db` stays read-only for 7 days as the rollback.Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the--restart; afterwards `sudo ovhost rollback coupons --to <sha>`. Nothing blocks a rollback: the schema--code only adds tables and columns.++restart; afterwards `sudo ovhost rollback coupons --to <sha>`. Migrations only add tables and columns.First install (done once; kept for a rebuild):@@@ -4,13 +4,28 @@"code": true,"wave": "W18","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express 4, better-sqlite3 11",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4850,"domain": "openvibe.coupons",-- "contracts": "openvibe-contracts v0.53.0",-- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],-- "capabilities": ["coupons.coupon.submit", "coupons.report.create", "coupons.merchant.resolve", "coupons.coupon.lookup", "coupons.status.update", "coupons.merchant.manage"],-- "installScopes": ["coupons.lookup", "coupons.report"],++ "contracts": "openvibe-contracts v0.76.0",++ "packages": [++ "openvibe-publishing v1.0.0",++ "openvibe-contracts v0.76.0",++ "openvibe-shared v1.25.0",++ "openvibe-sdk v0.20.0"++ ],++ "capabilities": [++ "coupons.coupon.submit",++ "coupons.report.create",++ "coupons.merchant.resolve",++ "coupons.coupon.lookup",++ "coupons.status.update",++ "coupons.merchant.manage"++ ],++ "installScopes": [++ "coupons.lookup",++ "coupons.report"++ ],"capabilitiesRegistered": true,"serviceManifestRegistered": true,"seedCoupons": "none (production holds 0 coupons and 0 merchants)",@@@ -3,8 +3,8 @@ Description=OpenVibe.Coupons — coupon codes with merchant matching, honest exp# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be# up first, but Coupons degrades gracefully without them: pages, lookups and feeds serve; sign-in,# events and imports wait (see /api/ready).--After=network.target--Wants=openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simplennew file mode 100644@@@ -0,0 +1,209 @@++-- phase: expand++-- OpenVibe.Coupons on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.++++CREATE TABLE coupon_merchants (++ id text COLLATE "C" PRIMARY KEY, -- mer_<ULID>++ slug text COLLATE "C" NOT NULL UNIQUE, -- /m/:slug++ name text COLLATE "C" NOT NULL,++ homepage_url text COLLATE "C", -- https://<registrable domain>/ unless staff set another++ description text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),++ created_by text COLLATE "C", -- usr_… | svc:<id> | system++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++CREATE TABLE coupon_merchant_domains (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),++ host text COLLATE "C" NOT NULL, -- normalized (lowercase, punycode, no port, no trailing dot)++ registrable_domain text COLLATE "C" NOT NULL, -- eTLD+1 of host (bundled public-suffix subset)++ include_subdomains bigint NOT NULL DEFAULT 1,++ path_prefix text COLLATE "C" NOT NULL DEFAULT '', -- '' = the whole host; '/shop/x' matches only with a path++ created_by text COLLATE "C",++ created_at bigint NOT NULL,++ UNIQUE (host, path_prefix)++);++CREATE INDEX coupon_merchant_domains_registrable ON coupon_merchant_domains (registrable_domain);++CREATE INDEX coupon_merchant_domains_merchant ON coupon_merchant_domains (merchant_id);++++CREATE TABLE coupons (++ id text COLLATE "C" PRIMARY KEY, -- cpn_<ULID>++ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),++ code text COLLATE "C" NOT NULL, -- as the merchant writes it++ code_key text COLLATE "C" NOT NULL, -- uppercase, for duplicate detection++ title text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'unknown'++ CHECK (status IN ('unknown','reported_working','reported_failed','expired','disabled')),++ status_reason text COLLATE "C",++ confidence double precision, -- NULL: no report in the window (validity unknown)++ review_state text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (review_state IN ('pending','published')),++ origin text COLLATE "C" NOT NULL CHECK (origin IN ('member','staff','source','ai')),++ expires_at bigint, -- NULL: expiry unknown (never guessed)++ expires_precision text COLLATE "C" CHECK (expires_precision IN ('instant','date')),++ expiry_basis text COLLATE "C" CHECK (expiry_basis IN ('evidence','submitter','source','staff')),++ expired_at bigint, -- when it left active results as expired++ disabled_at bigint,++ last_report_at bigint,++ last_worked_at bigint,++ last_failed_at bigint,++ created_by text COLLATE "C", -- usr_… | svc:<id>; never returned by the API++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ CHECK (expires_at IS NULL OR expires_precision IS NOT NULL),++ UNIQUE (merchant_id, code_key)++);++CREATE INDEX coupons_active ON coupons (merchant_id, status, review_state, expires_at);++CREATE INDEX coupons_expiry ON coupons (expires_at) WHERE expires_at IS NOT NULL;++CREATE INDEX coupons_recent ON coupons (created_at);++++CREATE TABLE coupon_sources (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('member','staff','source','ai')),++ evidence_url text COLLATE "C", -- where the code was published (never fetched by Coupons)++ merchant_evidence bigint NOT NULL DEFAULT 0, -- evidence_url is on one of the merchant's own domains++ sources_item_id text COLLATE "C", -- itm_… (OpenVibe.Sources)++ sources_item_rev bigint,++ source_key text COLLATE "C",++ retrieved_at bigint, -- Sources' observation time++ ai_run_id text COLLATE "C", -- OpenVibe.AI run (coupons.extract_coupon), when origin is ai++ submitted_by text COLLATE "C", -- usr_… | svc:<id>; never returned by the API++ removed_at bigint,++ removed_reason text COLLATE "C",++ created_at bigint NOT NULL++);++CREATE INDEX coupon_sources_coupon ON coupon_sources (coupon_id);++CREATE UNIQUE INDEX coupon_sources_item ON coupon_sources (coupon_id, sources_item_id) WHERE sources_item_id IS NOT NULL;++CREATE INDEX coupon_sources_submitter ON coupon_sources (submitted_by, created_at);++++CREATE TABLE coupon_restrictions (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('min_spend','category','new_customers_only','region','other')),++ value text COLLATE "C", -- category name, ISO 3166-1 alpha-2 region, free text++ amount_minor bigint, -- min_spend in minor units++ currency text COLLATE "C", -- ISO 4217++ created_at bigint NOT NULL,++ CHECK (kind <> 'min_spend' OR (amount_minor IS NOT NULL AND currency IS NOT NULL))++);++CREATE INDEX coupon_restrictions_coupon ON coupon_restrictions (coupon_id);++++CREATE TABLE coupon_validation_reports (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),++ reporter_key text COLLATE "C" NOT NULL, -- HMAC(COUPONS_REPORTER_KEY_SECRET, subject)++ channel text COLLATE "C" NOT NULL CHECK (channel IN ('site','extension','api')),++ install_id text COLLATE "C", -- cpi_… when reported through the extension++ day text COLLATE "C" NOT NULL, -- UTC YYYY-MM-DD: one report per reporter, coupon and day++ outcome text COLLATE "C" NOT NULL CHECK (outcome IN ('worked','failed')),++ reason text COLLATE "C" CHECK (reason IN ('invalid','expired','min_spend_not_met','not_eligible','other')),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (coupon_id, reporter_key, day)++);++CREATE INDEX coupon_reports_coupon ON coupon_validation_reports (coupon_id, updated_at);++CREATE INDEX coupon_reports_reporter ON coupon_validation_reports (reporter_key, created_at);++++CREATE TABLE coupon_status_history (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),++ from_status text COLLATE "C",++ to_status text COLLATE "C" NOT NULL,++ confidence_before double precision,++ confidence_after double precision,++ reason text COLLATE "C" NOT NULL, -- created | report | decay | expiry | staff:<note> | service:<id>++ actor text COLLATE "C" NOT NULL, -- system | usr_… | svc:<id>++ created_at bigint NOT NULL++);++CREATE INDEX coupon_status_history_coupon ON coupon_status_history (coupon_id, id);++++CREATE TABLE coupon_application_hints (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),++ coupon_id text COLLATE "C" REFERENCES coupons(id), -- NULL: applies to every code of the merchant++ text text COLLATE "C" NOT NULL,++ created_by text COLLATE "C",++ created_at bigint NOT NULL++);++CREATE INDEX coupon_hints_merchant ON coupon_application_hints (merchant_id);++++CREATE TABLE coupon_watches (++ subject text COLLATE "C" NOT NULL, -- usr_…++ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),++ created_at bigint NOT NULL,++ PRIMARY KEY (subject, merchant_id)++);++++CREATE TABLE extension_installs (++ id text COLLATE "C" PRIMARY KEY, -- cpi_<ULID>++ subject text COLLATE "C" NOT NULL, -- usr_… who connected it++ token_hash text COLLATE "C" NOT NULL UNIQUE, -- sha256 of the cpx_ token (the token is shown once)++ token_hint text COLLATE "C" NOT NULL, -- first characters, to recognise it in the list++ label text COLLATE "C" NOT NULL,++ scopes text COLLATE "C" NOT NULL, -- JSON array: coupons.lookup, coupons.report++ created_at bigint NOT NULL,++ expires_at bigint NOT NULL,++ last_used_at bigint,++ revoked_at bigint++);++CREATE INDEX extension_installs_subject ON extension_installs (subject, revoked_at);++++CREATE TABLE import_state (++ key text COLLATE "C" PRIMARY KEY,++ cursor bigint NOT NULL DEFAULT 0,++ last_run_at bigint,++ last_ok_at bigint,++ last_error text COLLATE "C"++);++++CREATE TABLE coupon_import_holds (++ item_id text COLLATE "C" PRIMARY KEY, -- itm_… held, never silently dropped++ source_key text COLLATE "C",++ reason text COLLATE "C" NOT NULL, -- no_merchant | not_a_coupon | no_code | invalid++ detail text COLLATE "C",++ item text COLLATE "C" NOT NULL, -- the item as Sources returned it (JSON)++ attempts bigint NOT NULL DEFAULT 1,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ resolved_at bigint++);++++-- openvibe-publishing/index-hooks (prefix coupons)++CREATE TABLE IF NOT EXISTS coupons_index_revisions (++ owner text COLLATE "C" NOT NULL,++ type text COLLATE "C" NOT NULL,++ id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ hash text NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (owner, type, id)++);++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS idempotency_receipts (++ consumer text NOT NULL,++ event_id text NOT NULL,++ processed_at bigint NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;@@@ -15,18 +15,34 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",++ "pg": "^8.23.0"},"devDependencies": {++ "@electric-sql/pglite": "^0.5.8","jsonwebtoken": "^9.0.2"},"engines": {"node": ">=22.0.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -228,6 +244,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -310,6 +335,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -746,6 +780,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },"node_modules/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -810,12 +887,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1015,9 +1104,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.53.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",++ "version": "0.76.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1031,15 +1120,15 @@}},"node_modules/openvibe-publishing": {-- "version": "0.4.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==","license": "MIT","engines": {"node": ">=20"},"peerDependencies": {-- "better-sqlite3": ">=11",++ "openvibe-sdk": ">=0.15.0","openvibe-shared": ">=1.5.0"},"peerDependenciesMeta": {@@@ -1049,12 +1138,32 @@}},"node_modules/openvibe-sdk": {-- "version": "0.12.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",++ "version": "0.20.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",++ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {@@@ -1081,6 +1190,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1204,6 +1441,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1419,6 +1677,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1546,6 +1819,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -2,14 +2,20 @@"name": "openvibe-coupons","version": "0.1.0","private": true,-- "description": "OpenVibe.Coupons — coupon codes with merchant and domain resolution, restrictions, honest expiry, deduplicated and rate-limited people's validity reports, and the public lookup API the OpenVibe browser helper uses. Server-rendered and useful without JavaScript.",++ "description": "OpenVibe.Coupons \u2014 coupon codes with merchant and domain resolution, restrictions, honest expiry, deduplicated and rate-limited people's validity reports, and the public lookup API the OpenVibe browser helper uses. Server-rendered and useful without JavaScript.","main": "server/index.js","scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/run.js"++ "test": "node test/run.js",++ "test:pg": "COUPONS_TEST_STORE=pg node test/run.js"},-- "keywords": ["openvibe", "coupons", "merchants", "browser-extension"],++ "keywords": [++ "openvibe",++ "coupons",++ "merchants",++ "browser-extension"++ ],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.Coupons",@@@ -20,13 +26,16 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",++ "pg": "^8.23.0"},"devDependencies": {-- "jsonwebtoken": "^9.0.2"++ "jsonwebtoken": "^9.0.2",++ "@electric-sql/pglite": "^0.5.8"},"engines": {"node": ">=22.0.0"nnew file mode 100644@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Coupons's SQLite database (COUPONS_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is++ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts++ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its++ * name and columns.++ */++require('dotenv').config();++const { runSqliteMigration } = require('openvibe-sdk/db');++const configLib = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ const config = configLib.load();++ runSqliteMigration({ service: 'coupons', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });++}++++module.exports = { TABLES };@@@ -43,11 +43,12 @@ const VERSION = require('../package.json').version;* opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,* limitsNow (the per-actor limiter's clock, tests)*/--function createApp(opts = {}) {++async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const fetchImpl = opts.fetchImpl || globalThis.fetch;-- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.++ const store = opts.store || await openStore(config, { now: opts.now, log });const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });const publication = createPublication({ store, config, outbox });@@@ -73,7 +74,10 @@ function createApp(opts = {}) {app.locals.ctx = ctx;// Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router// resolved req.viewer; the lookup, per-address and per-person limits stay.-- ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });app.use(contracts.http.middleware());app.use(helmet({@@@ -105,7 +109,7 @@ function createApp(opts = {}) {app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-coupons', version: VERSION }));// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.release.mount(app, { registry: metrics.registry });-- const readiness = createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release: release.release });++ const readiness = createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release: release.release, valkey: ctx.valkey });app.get('/api/ready', readiness.handler);// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────@@@ -81,12 +81,12 @@ function createViewerResolver({ auth, config, installs }) {if (!header.startsWith('Bearer ')) throw new ApiError(401, 'auth.unsupported', 'use Authorization: Bearer <token>');const token = header.slice(7).trim();if (token.startsWith('cpx_')) {-- const v = installs.verify(token);++ const v = await installs.verify(token);return { kind: 'install', install: v.install, subject: v.subject, scopes: v.scopes, staff: false, origin: 'user' };}const payload = decodeJwtPayload(token);if (!payload) throw new ApiError(401, 'token.invalid', 'malformed bearer token');-- if (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return fromServiceToken(req, token);++ if (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return await fromServiceToken(req, token);const claims = await auth.verify(token);const user = userFromClaims(claims, token);if (!user) throw new ApiError(401, 'token.invalid', 'invalid or expired token');@@@ -33,19 +33,20 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =}) : null;const q = {state: db.prepare('SELECT * FROM import_state WHERE key = ?'),-- ensure: db.prepare('INSERT OR IGNORE INTO import_state (key, cursor) VALUES (?, 0)'),++ ensure: db.prepare('INSERT INTO import_state (key, cursor) VALUES (?, 0) ON CONFLICT DO NOTHING'),cursor: db.prepare('UPDATE import_state SET cursor = ?, last_run_at = ?, last_ok_at = ?, last_error = NULL WHERE key = ?'),failed: db.prepare('UPDATE import_state SET last_run_at = ?, last_error = ? WHERE key = ?'),hold: db.prepare(`INSERT INTO coupon_import_holds (item_id, source_key, reason, detail, item, attempts, created_at, updated_at)VALUES (@item_id, @source_key, @reason, @detail, @item, 1, @now, @now)ON CONFLICT (item_id) DO UPDATE SET reason = excluded.reason, detail = excluded.detail, item = excluded.item,-- attempts = attempts + 1, updated_at = excluded.updated_at, resolved_at = NULL`),++ attempts = coupon_import_holds.attempts + 1, updated_at = excluded.updated_at, resolved_at = NULL`),resolve: db.prepare('UPDATE coupon_import_holds SET resolved_at = ?, updated_at = ? WHERE item_id = ? AND resolved_at IS NULL'),retryable: db.prepare("SELECT item FROM coupon_import_holds WHERE resolved_at IS NULL AND reason = 'no_merchant' ORDER BY updated_at LIMIT 100"),open: db.prepare('SELECT item_id, source_key, reason, detail, attempts, created_at, updated_at FROM coupon_import_holds WHERE resolved_at IS NULL ORDER BY updated_at DESC LIMIT 200'),openCount: db.prepare('SELECT COUNT(*) AS n FROM coupon_import_holds WHERE resolved_at IS NULL'),};-- q.ensure.run(STATE_KEY);++ let ensured = false; // the state row, written on the first run (not at construction: the factory stays synchronous)++ const ensureState = async () => { if (!ensured) { await q.ensure.run(STATE_KEY); ensured = true; } };function parseItem(item) {const f = item.fields && typeof item.fields === 'object' ? item.fields : {};@@@ -61,30 +62,30 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =}/** Handle one item inside a transaction. → 'imported' | 'updated' | 'withdrawn' | 'held:<reason>' */-- function handle(item) {++ async function handle(item) {const now = store.now();-- const held = (reason, detail = null) => {-- q.hold.run({ item_id: item.id, source_key: item.source_key || null, reason, detail: detail ? String(detail).slice(0, 300) : null, item: JSON.stringify(item), now });++ const held = async (reason, detail = null) => {++ await q.hold.run({ item_id: item.id, source_key: item.source_key || null, reason, detail: detail ? String(detail).slice(0, 300) : null, item: JSON.stringify(item), now });return `held:${reason}`;};-- return store.tx(() => {++ return await store.tx(async () => {if (item.removed) {-- coupons.withdrawSourceItem(item.id, item.removed.reason);-- q.resolve.run(now, now, item.id);++ await coupons.withdrawSourceItem(item.id, item.removed.reason);++ await q.resolve.run(now, now, item.id);return 'withdrawn';}-- if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);-- if (item.kind !== 'coupon') return held('not_a_coupon', `kind ${item.kind}`);-- if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return held('no_code');++ if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return await held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);++ if (item.kind !== 'coupon') return await held('not_a_coupon', `kind ${item.kind}`);++ if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return await held('no_code');let parsed;try { parsed = parseItem(item); } catch (err) {-- return held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);++ return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);}const at = hosts.hostOfUrl(item.canonical_url);-- const found = at ? merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;-- if (!found) return held('no_merchant', at ? at.host : 'no canonical URL');-- const r = coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });-- q.resolve.run(now, now, item.id);++ const found = at ? await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;++ if (!found) return await held('no_merchant', at ? at.host : 'no canonical URL');++ const r = await coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });++ await q.resolve.run(now, now, item.id);return r.created ? 'imported' : 'updated';});}@@@ -100,23 +101,24 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =/** One import run. → { outcomes: {…}, cursor } or { error } */async function run({ maxPages = 10 } = {}) {++ await ensureState();if (!enabled) return { disabled: true };const outcomes = {};const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };-- let cursor = q.state.get(STATE_KEY).cursor;++ let cursor = (await q.state.get(STATE_KEY)).cursor;try {for (let i = 0; i < maxPages; i++) {const page = await getPage(cursor);-- for (const item of page.items) count(handle(item));++ for (const item of page.items) count(await handle(item));cursor = page.next_after != null ? Number(page.next_after) : cursor;-- q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);++ await q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);if (!page.more) break;}-- for (const { item } of q.retryable.all()) count(`retry:${handle(JSON.parse(item))}`);-- q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);++ for (const { item } of await q.retryable.all()) count(`retry:${await handle(JSON.parse(item))}`);++ await q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);return { outcomes, cursor };} catch (err) {-- q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);++ await q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);log.warn('[Coupons] Sources import failed (nothing changed):', err.message);return { error: err.message, outcomes, cursor };}@@@ -126,9 +128,9 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =enabled,run,handle,-- state: () => q.state.get(STATE_KEY),-- holds: () => q.open.all(),-- holdCount: () => q.openCount.get().n,++ state: async () => await q.state.get(STATE_KEY),++ holds: async () => await q.open.all(),++ holdCount: async () => (await q.openCount.get()).n,};}@@@ -35,6 +35,10 @@ function load(env = process.env) {hour: Math.max(1, int(env.COUPONS_LIMITS_HOUR, 3000)),},++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:coupons:' },++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.dbPath: env.COUPONS_DB_PATH || './data/coupons.db',// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.@@@ -1,7 +1,8 @@'use strict';/**-- * Coupons' own SQLite database, created on boot, idempotently. Nothing here is shared with another++ * Coupons' own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing here is shared with another* service.** The nine charter tables (roadmap §15.13):@@@ -24,203 +25,53 @@*/const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');const STATUSES = Object.freeze(['unknown', 'reported_working', 'reported_failed', 'expired', 'disabled']);--const SCHEMA = `--CREATE TABLE IF NOT EXISTS coupon_merchants (-- id TEXT PRIMARY KEY, -- mer_<ULID>-- slug TEXT NOT NULL UNIQUE, -- /m/:slug-- name TEXT NOT NULL,-- homepage_url TEXT, -- https://<registrable domain>/ unless staff set another-- description TEXT,-- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),-- created_by TEXT, -- usr_… | svc:<id> | system-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');--CREATE TABLE IF NOT EXISTS coupon_merchant_domains (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),-- host TEXT NOT NULL, -- normalized (lowercase, punycode, no port, no trailing dot)-- registrable_domain TEXT NOT NULL, -- eTLD+1 of host (bundled public-suffix subset)-- include_subdomains INTEGER NOT NULL DEFAULT 1,-- path_prefix TEXT NOT NULL DEFAULT '', -- '' = the whole host; '/shop/x' matches only with a path-- created_by TEXT,-- created_at INTEGER NOT NULL,-- UNIQUE (host, path_prefix)--);--CREATE INDEX IF NOT EXISTS coupon_merchant_domains_registrable ON coupon_merchant_domains (registrable_domain);--CREATE INDEX IF NOT EXISTS coupon_merchant_domains_merchant ON coupon_merchant_domains (merchant_id);----CREATE TABLE IF NOT EXISTS coupons (-- id TEXT PRIMARY KEY, -- cpn_<ULID>-- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),-- code TEXT NOT NULL, -- as the merchant writes it-- code_key TEXT NOT NULL, -- uppercase, for duplicate detection-- title TEXT NOT NULL,-- description TEXT,-- status TEXT NOT NULL DEFAULT 'unknown'-- CHECK (status IN ('unknown','reported_working','reported_failed','expired','disabled')),-- status_reason TEXT,-- confidence REAL, -- NULL: no report in the window (validity unknown)-- review_state TEXT NOT NULL DEFAULT 'published' CHECK (review_state IN ('pending','published')),-- origin TEXT NOT NULL CHECK (origin IN ('member','staff','source','ai')),-- expires_at INTEGER, -- NULL: expiry unknown (never guessed)-- expires_precision TEXT CHECK (expires_precision IN ('instant','date')),-- expiry_basis TEXT CHECK (expiry_basis IN ('evidence','submitter','source','staff')),-- expired_at INTEGER, -- when it left active results as expired-- disabled_at INTEGER,-- last_report_at INTEGER,-- last_worked_at INTEGER,-- last_failed_at INTEGER,-- created_by TEXT, -- usr_… | svc:<id>; never returned by the API-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- CHECK (expires_at IS NULL OR expires_precision IS NOT NULL),-- UNIQUE (merchant_id, code_key)--);--CREATE INDEX IF NOT EXISTS coupons_active ON coupons (merchant_id, status, review_state, expires_at);--CREATE INDEX IF NOT EXISTS coupons_expiry ON coupons (expires_at) WHERE expires_at IS NOT NULL;--CREATE INDEX IF NOT EXISTS coupons_recent ON coupons (created_at);----CREATE TABLE IF NOT EXISTS coupon_sources (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- coupon_id TEXT NOT NULL REFERENCES coupons(id),-- kind TEXT NOT NULL CHECK (kind IN ('member','staff','source','ai')),-- evidence_url TEXT, -- where the code was published (never fetched by Coupons)-- merchant_evidence INTEGER NOT NULL DEFAULT 0, -- evidence_url is on one of the merchant's own domains-- sources_item_id TEXT, -- itm_… (OpenVibe.Sources)-- sources_item_rev INTEGER,-- source_key TEXT,-- retrieved_at INTEGER, -- Sources' observation time-- ai_run_id TEXT, -- OpenVibe.AI run (coupons.extract_coupon), when origin is ai-- submitted_by TEXT, -- usr_… | svc:<id>; never returned by the API-- removed_at INTEGER,-- removed_reason TEXT,-- created_at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS coupon_sources_coupon ON coupon_sources (coupon_id);--CREATE UNIQUE INDEX IF NOT EXISTS coupon_sources_item ON coupon_sources (coupon_id, sources_item_id) WHERE sources_item_id IS NOT NULL;--CREATE INDEX IF NOT EXISTS coupon_sources_submitter ON coupon_sources (submitted_by, created_at);----CREATE TABLE IF NOT EXISTS coupon_restrictions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- coupon_id TEXT NOT NULL REFERENCES coupons(id),-- kind TEXT NOT NULL CHECK (kind IN ('min_spend','category','new_customers_only','region','other')),-- value TEXT, -- category name, ISO 3166-1 alpha-2 region, free text-- amount_minor INTEGER, -- min_spend in minor units-- currency TEXT, -- ISO 4217-- created_at INTEGER NOT NULL,-- CHECK (kind <> 'min_spend' OR (amount_minor IS NOT NULL AND currency IS NOT NULL))--);--CREATE INDEX IF NOT EXISTS coupon_restrictions_coupon ON coupon_restrictions (coupon_id);----CREATE TABLE IF NOT EXISTS coupon_validation_reports (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- coupon_id TEXT NOT NULL REFERENCES coupons(id),-- reporter_key TEXT NOT NULL, -- HMAC(COUPONS_REPORTER_KEY_SECRET, subject)-- channel TEXT NOT NULL CHECK (channel IN ('site','extension','api')),-- install_id TEXT, -- cpi_… when reported through the extension-- day TEXT NOT NULL, -- UTC YYYY-MM-DD: one report per reporter, coupon and day-- outcome TEXT NOT NULL CHECK (outcome IN ('worked','failed')),-- reason TEXT CHECK (reason IN ('invalid','expired','min_spend_not_met','not_eligible','other')),-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- UNIQUE (coupon_id, reporter_key, day)--);--CREATE INDEX IF NOT EXISTS coupon_reports_coupon ON coupon_validation_reports (coupon_id, updated_at);--CREATE INDEX IF NOT EXISTS coupon_reports_reporter ON coupon_validation_reports (reporter_key, created_at);----CREATE TABLE IF NOT EXISTS coupon_status_history (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- coupon_id TEXT NOT NULL REFERENCES coupons(id),-- from_status TEXT,-- to_status TEXT NOT NULL,-- confidence_before REAL,-- confidence_after REAL,-- reason TEXT NOT NULL, -- created | report | decay | expiry | staff:<note> | service:<id>-- actor TEXT NOT NULL, -- system | usr_… | svc:<id>-- created_at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS coupon_status_history_coupon ON coupon_status_history (coupon_id, id);----CREATE TABLE IF NOT EXISTS coupon_application_hints (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),-- coupon_id TEXT REFERENCES coupons(id), -- NULL: applies to every code of the merchant-- text TEXT NOT NULL,-- created_by TEXT,-- created_at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS coupon_hints_merchant ON coupon_application_hints (merchant_id);----CREATE TABLE IF NOT EXISTS coupon_watches (-- subject TEXT NOT NULL, -- usr_…-- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),-- created_at INTEGER NOT NULL,-- PRIMARY KEY (subject, merchant_id)--);----CREATE TABLE IF NOT EXISTS extension_installs (-- id TEXT PRIMARY KEY, -- cpi_<ULID>-- subject TEXT NOT NULL, -- usr_… who connected it-- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the cpx_ token (the token is shown once)-- token_hint TEXT NOT NULL, -- first characters, to recognise it in the list-- label TEXT NOT NULL,-- scopes TEXT NOT NULL, -- JSON array: coupons.lookup, coupons.report-- created_at INTEGER NOT NULL,-- expires_at INTEGER NOT NULL,-- last_used_at INTEGER,-- revoked_at INTEGER--);--CREATE INDEX IF NOT EXISTS extension_installs_subject ON extension_installs (subject, revoked_at);----CREATE TABLE IF NOT EXISTS import_state (-- key TEXT PRIMARY KEY,-- cursor INTEGER NOT NULL DEFAULT 0,-- last_run_at INTEGER,-- last_ok_at INTEGER,-- last_error TEXT--);----CREATE TABLE IF NOT EXISTS coupon_import_holds (-- item_id TEXT PRIMARY KEY, -- itm_… held, never silently dropped-- source_key TEXT,-- reason TEXT NOT NULL, -- no_merchant | not_a_coupon | no_code | invalid-- detail TEXT,-- item TEXT NOT NULL, -- the item as Sources returned it (JSON)-- attempts INTEGER NOT NULL DEFAULT 1,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- resolved_at INTEGER--);--`;++/**++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.++ */++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh coupons)');++ log.warn(`[Coupons] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ const db = createDb({ pglite: DEV_PGLITE, service: 'coupons', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'coupons-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'coupons', registry, log });++}/**-- * Open (or create) the database.-- * opts.now — injectable clock (epoch ms), so tests and replays are deterministic.++ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms), so tests and replays are++ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).*/--function openStore(dbPath, { now = () => Date.now() } = {}) {-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });-- const db = new Database(dbPath);-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');-- db.exec(SCHEMA);++function createStore(db, { now = () => Date.now() } = {}) {return {db,now,sequencer: createIndexSequencer(db, { prefix: 'coupons', now }),-- tx: (fn) => db.transaction(fn)(),++ tx: async (fn) => await db.tx(() => fn()),close: () => db.close(),};}++/** openDb + createStore. */++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++const CHARTER_TABLES = ['coupon_merchants', 'coupon_merchant_domains', 'coupons', 'coupon_sources', 'coupon_restrictions','coupon_validation_reports', 'coupon_status_history', 'coupon_application_hints', 'coupon_watches'];--module.exports = { openStore, CHARTER_TABLES, STATUSES };++module.exports = { openDb, openStore, createStore, MIGRATIONS, CHARTER_TABLES, STATUSES };@@@ -176,6 +176,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {active: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}ORDER BY CASE c.status WHEN 'reported_working' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, COALESCE(c.last_worked_at, 0) DESC, c.created_at DESC LIMIT 200`),activeCount: db.prepare(`SELECT COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}`),++ activeCounts: db.prepare(`SELECT c.merchant_id, COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = ANY(@merchants) AND ${ACTIVE_SQL} GROUP BY c.merchant_id`),recentlyEnded: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_idWHERE c.merchant_id = @merchant AND c.review_state = 'published' AND m.status = 'active'AND (c.status = 'expired' OR (c.expires_at IS NOT NULL AND c.expires_at <= @now)) AND c.status <> 'disabled'@@@ -189,16 +190,18 @@ function createCoupons({ store, merchants, publication, outbox = null }) {pendingReview: db.prepare(`SELECT c.* FROM coupons c WHERE c.review_state = 'pending' AND c.status <> 'disabled' ORDER BY c.created_at LIMIT 200`),setReview: db.prepare("UPDATE coupons SET review_state = 'published', updated_at = ? WHERE id = ? AND review_state = 'pending'"),setExpiry: db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ?, expiry_basis = ?, updated_at = ? WHERE id = ?'),++ // Each reporter's latest report counts once (DISTINCT ON: SQLite took the bare column from the MAX row).reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (-- SELECT reporter_key, outcome, MAX(updated_at) AS t FROM coupon_validation_reports WHERE coupon_id = ? AND updated_at > ? GROUP BY reporter_key)++ SELECT DISTINCT ON (reporter_key) reporter_key, outcome FROM coupon_validation_reports++ WHERE coupon_id = ? AND updated_at > ? ORDER BY reporter_key, updated_at DESC) latestGROUP BY outcome`),byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),};-- const get = (id) => q.byId.get(String(id || ''));++ const get = async (id) => await q.byId.get(String(id || ''));-- function restrictionsOf(id) { return q.restrictions.all(id); }++ async function restrictionsOf(id) { return await q.restrictions.all(id); }function restrictionsText(rows) {return rows.map((r) => {@@@ -210,30 +213,35 @@ function createCoupons({ store, merchants, publication, outbox = null }) {}).join('\n');}-- function sourceRefs(id) {-- return q.sources.all(id).filter((s) => s.sources_item_id && !s.removed_at)++ async function sourceRefs(id) {++ return (await q.sources.all(id)).filter((s) => s.sources_item_id && !s.removed_at).map((s) => ({ service: 'sources', type: 'item', id: s.sources_item_id, ...(Number.isInteger(s.sources_item_rev) ? { revision: s.sources_item_rev } : {}), ...(s.evidence_url ? { url: s.evidence_url } : {}), ...(s.retrieved_at ? { retrievedAt: s.retrieved_at } : {}) }));}-- function activeCount(merchantId) { return q.activeCount.get({ merchant: merchantId, now: store.now() }).n; }++ async function activeCount(merchantId) { return (await q.activeCount.get({ merchant: merchantId, now: store.now() })).n; }++ /** merchant id → active coupons, for many merchants in one query (missing = 0). */++ async function activeCounts(merchantIds) {++ const rows = merchantIds.length ? await q.activeCounts.all({ merchants: merchantIds, now: store.now() }) : [];++ return new Map(rows.map((r) => [r.merchant_id, r.n]));++ }/** Send the Search documents of a code and its merchant (inside a transaction). */-- function syncIndex(c, { traceparent } = {}) {-- const m = merchants.byId(c.merchant_id);-- publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });-- if (m) publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });++ async function syncIndex(c, { traceparent } = {}) {++ const m = await merchants.byId(c.merchant_id);++ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });++ if (m) await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });}-- function syncMerchant(m, { traceparent } = {}) {-- publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });-- for (const { id } of q.ofMerchant.all(m.id)) {-- const c = get(id);-- publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });++ async function syncMerchant(m, { traceparent } = {}) {++ await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });++ for (const { id } of await q.ofMerchant.all(m.id)) {++ const c = await get(id);++ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });}}-- function publiclyListed(c) {-- const m = merchants.byId(c.merchant_id);++ async function publiclyListed(c) {++ const m = await merchants.byId(c.merchant_id);return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));}@@@ -247,51 +255,51 @@ function createCoupons({ store, merchants, publication, outbox = null }) {* change in coupon_status_history, emit events and Search documents.* forceStatus: 'disabled' | 'expired' | 'unknown' (re-enable) for staff/service changes.*/-- function recompute(id, { reason, actor = 'system', forceStatus = null, traceparent } = {}) {-- const before = get(id);++ async function recompute(id, { reason, actor = 'system', forceStatus = null, traceparent } = {}) {++ const before = await get(id);if (!before) return null;const now = store.now();let base = before;if (forceStatus === 'disabled') base = { ...before, status: 'disabled' };else if (forceStatus === 'expired') base = { ...before, status: 'expired', expired_at: now };else if (forceStatus === 'unknown') base = { ...before, status: 'unknown', expired_at: null, disabled_at: null };-- const reports = q.reportsFor.all(id, now - confidence.WINDOW_DAYS * DAY);-- const ev = confidence.evaluate(base, reports, { merchantEvidence: Boolean(q.merchantEvidence.get(id)), now });++ const reports = await q.reportsFor.all(id, now - confidence.WINDOW_DAYS * DAY);++ const ev = confidence.evaluate(base, reports, { merchantEvidence: Boolean(await q.merchantEvidence.get(id)), now });const statusChanged = ev.status !== before.status;const confChanged = ev.confidence !== before.confidence;if (!statusChanged && !confChanged && !forceStatus) return before;let expiredAt = null;if (ev.status === 'expired') expiredAt = before.status === 'expired' && before.expired_at ? before.expired_at : (before.expires_at != null && before.expires_at <= now ? before.expires_at : now);-- q.update.run({++ await q.update.run({id, status: ev.status, confidence: ev.confidence, now,status_reason: statusChanged || forceStatus ? reason : before.status_reason,expired_at: expiredAt,disabled_at: ev.status === 'disabled' ? (before.disabled_at || now) : null,});-- const after = get(id);-- if (statusChanged || confChanged) q.history.run(id, before.status, after.status, before.confidence, after.confidence, reason, actor, now);++ const after = await get(id);++ if (statusChanged || confChanged) await q.history.run(id, before.status, after.status, before.confidence, after.confidence, reason, actor, now);if (statusChanged) {const type = after.status === 'expired' ? 'coupons.coupon.expired' : after.status === 'disabled' ? 'coupons.coupon.disabled' : 'coupons.coupon.updated';-- publication.emit(type, { type: 'coupon', id }, lifecyclePayload(after, { previous_status: before.status, reason: publicReason(reason) }), { isPublic: publiclyListed(after) || publiclyListed(before), traceparent });++ await publication.emit(type, { type: 'coupon', id }, lifecyclePayload(after, { previous_status: before.status, reason: publicReason(reason) }), { isPublic: await publiclyListed(after) || await publiclyListed(before), traceparent });}if (confChanged) {-- publication.emit('coupons.confidence.changed', { type: 'coupon', id }, { merchant_id: after.merchant_id, from: before.confidence, to: after.confidence, status: after.status }, { traceparent });++ await publication.emit('coupons.confidence.changed', { type: 'coupon', id }, { merchant_id: after.merchant_id, from: before.confidence, to: after.confidence, status: after.status }, { traceparent });}-- syncIndex(after, { traceparent });++ await syncIndex(after, { traceparent });return after;}-- function addSource(couponId, merchant, { kind, evidence_url = null, sources_item_id = null, sources_item_rev = null, source_key = null, retrieved_at = null, ai_run_id = null, submitted_by = null }) {-- q.sourceInsert.run({-- coupon_id: couponId, kind, evidence_url, merchant_evidence: evidence_url && merchants.ownsUrl(merchant, evidence_url) ? 1 : 0,++ async function addSource(couponId, merchant, { kind, evidence_url = null, sources_item_id = null, sources_item_rev = null, source_key = null, retrieved_at = null, ai_run_id = null, submitted_by = null }) {++ await q.sourceInsert.run({++ coupon_id: couponId, kind, evidence_url, merchant_evidence: evidence_url && await merchants.ownsUrl(merchant, evidence_url) ? 1 : 0,sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, now: store.now(),});}-- function checkSubmissionRate(actorId, limits) {++ async function checkSubmissionRate(actorId, limits) {const now = store.now();-- if (q.submittedSince.get(actorId, now - 60 * 60 * 1000).n >= limits.submissionsPerHour-- || q.submittedSince.get(actorId, now - DAY).n >= limits.submissionsPerDay) {++ if ((await q.submittedSince.get(actorId, now - 60 * 60 * 1000)).n >= limits.submissionsPerHour++ || (await q.submittedSince.get(actorId, now - DAY)).n >= limits.submissionsPerDay) {throw new ApiError(429, 'submission.rate_limited', 'too many submissions; try again later');}}@@@ -302,25 +310,25 @@ function createCoupons({ store, merchants, publication, outbox = null }) {* target: { merchant } (resolved by the caller)* → { coupon, merchant, duplicate, created }*/-- function submit(who, merchant, input, { limits, traceparent } = {}) {-- checkSubmissionRate(who.actor, limits);-- return store.tx(() => {-- const existing = q.byKey.get(merchant.id, input.code_key);++ async function submit(who, merchant, input, { limits, traceparent } = {}) {++ await checkSubmissionRate(who.actor, limits);++ return await store.tx(async () => {++ const existing = await q.byKey.get(merchant.id, input.code_key);if (existing) {if (existing.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'this code was taken down and cannot be resubmitted');if (existing.status === 'expired' || (existing.expires_at != null && existing.expires_at <= store.now())) {throw new ApiError(409, 'coupon.expired', 'this code is recorded as expired');}-- if (!input.evidence_url || !q.sameEvidence.get(existing.id, input.evidence_url)) {-- addSource(existing.id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });-- recompute(existing.id, { reason: 'evidence', actor: 'system', traceparent });++ if (!input.evidence_url || !await q.sameEvidence.get(existing.id, input.evidence_url)) {++ await addSource(existing.id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });++ await recompute(existing.id, { reason: 'evidence', actor: 'system', traceparent });}-- return { coupon: get(existing.id), merchant, duplicate: true, created: false };++ return { coupon: await get(existing.id), merchant, duplicate: true, created: false };}const now = store.now();const id = `cpn_${ids.ulid(now)}`;const review = who.kind === 'ai' || merchant.status !== 'active' ? 'pending' : 'published';-- q.insert.run({++ await q.insert.run({id, merchant_id: merchant.id, code: input.code, code_key: input.code_key, title: input.title, description: input.description,review_state: review, origin: who.kind,expires_at: input.expiry ? input.expiry.expires_at : null,@@@ -328,60 +336,60 @@ function createCoupons({ store, merchants, publication, outbox = null }) {expiry_basis: input.expiry ? (who.kind === 'staff' && input.expiry_basis === 'submitter' ? 'staff' : input.expiry_basis) : null,created_by: who.actor, now,});-- for (const r of input.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });-- if (input.hint) q.hintInsert.run(merchant.id, id, input.hint, who.actor, now);-- addSource(id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });-- q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);-- const c = get(id);-- publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: publiclyListed(c), traceparent });-- syncIndex(c, { traceparent });++ for (const r of input.restrictions) await q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });++ if (input.hint) await q.hintInsert.run(merchant.id, id, input.hint, who.actor, now);++ await addSource(id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });++ await q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);++ const c = await get(id);++ await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: await publiclyListed(c), traceparent });++ await syncIndex(c, { traceparent });return { coupon: c, merchant, duplicate: false, created: true };});}/** Import one OpenVibe.Sources item as a code (inside the importer's transaction). */-- function importFromSource(merchant, item, parsed, { autoPublish }) {-- const existing = q.byKey.get(merchant.id, parsed.code_key);++ async function importFromSource(merchant, item, parsed, { autoPublish }) {++ const existing = await q.byKey.get(merchant.id, parsed.code_key);const src = {kind: 'source', evidence_url: item.canonical_url || null, sources_item_id: item.id, sources_item_rev: item.revision,source_key: item.source_key, retrieved_at: Date.parse(item.provenance && item.provenance.retrieved_at) || null, submitted_by: 'svc:sources',};if (existing) {-- const known = q.sources.all(existing.id).find((s) => s.sources_item_id === item.id);-- if (known) db.prepare('UPDATE coupon_sources SET sources_item_rev = ?, retrieved_at = ?, removed_at = NULL, removed_reason = NULL WHERE id = ?').run(item.revision, src.retrieved_at, known.id);-- else addSource(existing.id, merchant, src);++ const known = (await q.sources.all(existing.id)).find((s) => s.sources_item_id === item.id);++ if (known) await db.prepare('UPDATE coupon_sources SET sources_item_rev = ?, retrieved_at = ?, removed_at = NULL, removed_reason = NULL WHERE id = ?').run(item.revision, src.retrieved_at, known.id);++ else await addSource(existing.id, merchant, src);if (existing.expires_at == null && parsed.expiry && existing.status !== 'expired') {-- q.setExpiry.run(parsed.expiry.expires_at, parsed.expiry.expires_precision, 'source', store.now(), existing.id);++ await q.setExpiry.run(parsed.expiry.expires_at, parsed.expiry.expires_precision, 'source', store.now(), existing.id);}-- recompute(existing.id, { reason: 'evidence', actor: 'svc:sources' });-- return { coupon: get(existing.id), created: false };++ await recompute(existing.id, { reason: 'evidence', actor: 'svc:sources' });++ return { coupon: await get(existing.id), created: false };}const now = store.now();const id = `cpn_${ids.ulid(now)}`;-- q.insert.run({++ await q.insert.run({id, merchant_id: merchant.id, code: parsed.code, code_key: parsed.code_key, title: parsed.title, description: parsed.description,review_state: autoPublish && merchant.status === 'active' ? 'published' : 'pending', origin: 'source',expires_at: parsed.expiry ? parsed.expiry.expires_at : null,expires_precision: parsed.expiry ? parsed.expiry.expires_precision : null,expiry_basis: parsed.expiry ? 'source' : null, created_by: 'svc:sources', now,});-- for (const r of parsed.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });-- addSource(id, merchant, src);-- q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);-- const c = get(id);-- publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: publiclyListed(c) });-- syncIndex(c);++ for (const r of parsed.restrictions) await q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });++ await addSource(id, merchant, src);++ await q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);++ const c = await get(id);++ await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: await publiclyListed(c) });++ await syncIndex(c);return { coupon: c, created: true };}/** A Sources item was removed: its evidence is withdrawn; a code with no other evidence is disabled. */-- function withdrawSourceItem(itemId, reason) {-- const rows = db.prepare('SELECT * FROM coupon_sources WHERE sources_item_id = ? AND removed_at IS NULL').all(itemId);++ async function withdrawSourceItem(itemId, reason) {++ const rows = await db.prepare('SELECT * FROM coupon_sources WHERE sources_item_id = ? AND removed_at IS NULL').all(itemId);for (const s of rows) {-- db.prepare('UPDATE coupon_sources SET removed_at = ?, removed_reason = ? WHERE id = ?').run(store.now(), String(reason || 'removed at the source').slice(0, 300), s.id);-- const remaining = db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE coupon_id = ? AND removed_at IS NULL').get(s.coupon_id).n;-- if (!remaining) recompute(s.coupon_id, { reason: 'source_removed', actor: 'svc:sources', forceStatus: 'disabled' });-- else recompute(s.coupon_id, { reason: 'evidence', actor: 'svc:sources' });++ await db.prepare('UPDATE coupon_sources SET removed_at = ?, removed_reason = ? WHERE id = ?').run(store.now(), String(reason || 'removed at the source').slice(0, 300), s.id);++ const remaining = (await db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE coupon_id = ? AND removed_at IS NULL').get(s.coupon_id)).n;++ if (!remaining) await recompute(s.coupon_id, { reason: 'source_removed', actor: 'svc:sources', forceStatus: 'disabled' });++ else await recompute(s.coupon_id, { reason: 'evidence', actor: 'svc:sources' });}return rows.length;}@@@ -390,93 +398,93 @@ function createCoupons({ store, merchants, publication, outbox = null }) {* Staff/service status change: 'disabled' | 'expired' | 'active' (re-enable: recomputed from* reports and time). reported_working / reported_failed cannot be set by anyone.*/-- function setStatus(c, target, { actor, note = '', traceparent } = {}) {++ async function setStatus(c, target, { actor, note = '', traceparent } = {}) {const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;const force = { disabled: 'disabled', expired: 'expired', active: 'unknown' }[target];if (!force) throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');if (target === 'expired' && c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');if (target === 'active' && c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');-- return store.tx(() => {-- const after = recompute(c.id, { reason: why, actor, forceStatus: force, traceparent });++ return await store.tx(async () => {++ const after = await recompute(c.id, { reason: why, actor, forceStatus: force, traceparent });// Staff or a staff-capable service acting on someone else's code: the moderation audit log (ADR-022).-- if (after && after.status !== c.status && actor !== c.created_by) moderated(`coupon.${target === 'active' ? 'enabled' : target}`, c, actor, { reason: note ? text(note, 200) : null, details: { previous: c.status, status: after.status }, traceparent });++ if (after && after.status !== c.status && actor !== c.created_by) await moderated(`coupon.${target === 'active' ? 'enabled' : target}`, c, actor, { reason: note ? text(note, 200) : null, details: { previous: c.status, status: after.status }, traceparent });return after;});}/** coupons.moderation.action for a staff or service action (never the submitter; see events/outbox.js). */-- function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {++ async function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {if (!outbox) return null;const person = /^usr_/.test(String(actor || '')) ? actor : null;-- return outbox.moderationAction({ action, target: { type: 'coupon', id: c.id }, actorSubject: person, reason, details: { merchant_id: c.merchant_id, origin: c.origin, ...details } }, { traceparent });++ return await outbox.moderationAction({ action, target: { type: 'coupon', id: c.id }, actorSubject: person, reason, details: { merchant_id: c.merchant_id, origin: c.origin, ...details } }, { traceparent });}/** Staff: publish a code that waits for review. */-- function approve(c, { actor, audit = true, traceparent } = {}) {-- return store.tx(() => {-- if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);-- const after = get(c.id);-- q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());-- if (audit && actor !== c.created_by) moderated('coupon.approved', c, actor, { traceparent });-- publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: publiclyListed(after) });-- syncIndex(after);++ async function approve(c, { actor, audit = true, traceparent } = {}) {++ return await store.tx(async () => {++ if ((await q.setReview.run(store.now(), c.id)).changes !== 1) return await get(c.id);++ const after = await get(c.id);++ await q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());++ if (audit && actor !== c.created_by) await moderated('coupon.approved', c, actor, { traceparent });++ await publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: await publiclyListed(after) });++ await syncIndex(after);return after;});}/** Staff: set or clear a code's expiry (clear = back to unknown). */-- function setExpiry(c, value, { actor }) {++ async function setExpiry(c, value, { actor }) {const expiry = value ? parseExpiry(value, store.now()) : null;-- return store.tx(() => {-- q.setExpiry.run(expiry ? expiry.expires_at : null, expiry ? expiry.expires_precision : null, expiry ? 'staff' : null, store.now(), c.id);-- const updated = get(c.id);-- publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(updated, { reason: 'expiry_changed' }), { isPublic: publiclyListed(updated) });-- recompute(c.id, { reason: 'staff:expiry', actor });-- syncIndex(get(c.id));-- return get(c.id);++ return await store.tx(async () => {++ await q.setExpiry.run(expiry ? expiry.expires_at : null, expiry ? expiry.expires_precision : null, expiry ? 'staff' : null, store.now(), c.id);++ const updated = await get(c.id);++ await publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(updated, { reason: 'expiry_changed' }), { isPublic: await publiclyListed(updated) });++ await recompute(c.id, { reason: 'staff:expiry', actor });++ await syncIndex(await get(c.id));++ return await get(c.id);});}-- function addMerchantHint(m, hint, { actor }) {++ async function addMerchantHint(m, hint, { actor }) {const t = text(hint, 300);if (!t) throw new ApiError(422, 'hint.empty', 'hint text is required');-- q.hintInsert.run(m.id, null, t, actor, store.now());++ await q.hintInsert.run(m.id, null, t, actor, store.now());}/*** The sweep: record expiries that have passed and let old reports decay. Idempotent.* → { expired: [ids], changed: [ids] }*/-- function sweep() {++ async function sweep() {const now = store.now();const expired = [];const changed = [];-- for (const { id } of q.dueExpiry.all(now)) {-- const after = store.tx(() => recompute(id, { reason: 'expiry', actor: 'system' }));++ for (const { id } of await q.dueExpiry.all(now)) {++ const after = await store.tx(async () => await recompute(id, { reason: 'expiry', actor: 'system' }));if (after && after.status === 'expired') expired.push(id);}-- for (const { id } of q.decaying.all(now - (confidence.WINDOW_DAYS + 1) * DAY)) {-- const before = get(id);-- const after = store.tx(() => recompute(id, { reason: 'decay', actor: 'system' }));++ for (const { id } of await q.decaying.all(now - (confidence.WINDOW_DAYS + 1) * DAY)) {++ const before = await get(id);++ const after = await store.tx(async () => await recompute(id, { reason: 'decay', actor: 'system' }));if (after && before && (after.status !== before.status || after.confidence !== before.confidence)) changed.push(id);}return { expired, changed };}/** Counted reports in the window: { worked, failed } (aggregates only, never who). */-- function reportCounts(id) {++ async function reportCounts(id) {const out = { worked: 0, failed: 0 };-- for (const r of q.reportCounts.all(id, store.now() - confidence.WINDOW_DAYS * DAY)) out[r.outcome] = r.n;++ for (const r of await q.reportCounts.all(id, store.now() - confidence.WINDOW_DAYS * DAY)) out[r.outcome] = r.n;return out;}/*** The public view of a code. Never includes who submitted or reported it.*/-- function view(c, { merchant = null } = {}) {++ async function view(c, { merchant = null } = {}) {const now = store.now();-- const m = merchant || merchants.byId(c.merchant_id);-- const sources = q.sources.all(c.id).filter((s) => !s.removed_at);++ const m = merchant || await merchants.byId(c.merchant_id);++ const sources = (await q.sources.all(c.id)).filter((s) => !s.removed_at);const iso = (t) => (t == null ? null : new Date(t).toISOString());// Report times are published to the hour: enough for "last report 3 hours ago", too coarse// to tie a report to the moment someone made it.@@@ -490,14 +498,14 @@ function createCoupons({ store, merchants, publication, outbox = null }) {status: c.status,active: Boolean(m && m.status === 'active' && confidence.isActive(c, now)),confidence: c.confidence,-- reports: { ...reportCounts(c.id), window_days: confidence.WINDOW_DAYS, last_report_at: hour(c.last_report_at), last_worked_at: hour(c.last_worked_at), last_failed_at: hour(c.last_failed_at) },++ reports: { ...await reportCounts(c.id), window_days: confidence.WINDOW_DAYS, last_report_at: hour(c.last_report_at), last_worked_at: hour(c.last_worked_at), last_failed_at: hour(c.last_failed_at) },expiry: c.expires_at == null? { known: false, expires_at: null, precision: null, basis: null }: { known: true, expires_at: iso(c.expires_at), precision: c.expires_precision, basis: c.expiry_basis },-- restrictions: restrictionsOf(c.id).map((r) => (r.kind === 'min_spend'++ restrictions: (await restrictionsOf(c.id)).map((r) => (r.kind === 'min_spend'? { kind: r.kind, amount: formatMinor(r.amount_minor, r.currency), amount_minor: r.amount_minor, currency: r.currency }: r.kind === 'new_customers_only' ? { kind: r.kind } : { kind: r.kind, value: r.value })),-- hints: m ? q.hints.all(m.id, c.id).map((h) => ({ text: h.text, scope: h.coupon_id ? 'code' : 'merchant' })) : [],++ hints: m ? (await q.hints.all(m.id, c.id)).map((h) => ({ text: h.text, scope: h.coupon_id ? 'code' : 'merchant' })) : [],evidence: sources.map((s) => ({kind: s.kind, url: s.evidence_url, merchant_page: Boolean(s.merchant_evidence),...(s.sources_item_id ? { sources_item: s.sources_item_id, retrieved_at: iso(s.retrieved_at) } : {}),@@@ -513,24 +521,24 @@ function createCoupons({ store, merchants, publication, outbox = null }) {return {ACTIVE_SQL, parseSubmission, parseExpiry, parseRestrictions, formatMinor, restrictionsText,get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,-- restrictionsOf, reportCounts, activeCount, syncIndex, syncMerchant,-- history: (id) => q.historyFor.all(id),-- active: (merchantId) => q.active.all({ merchant: merchantId, now: store.now() }),-- recentlyEnded: (merchantId) => q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),-- recentActive: (limit = 30) => q.recentActive.all({ now: store.now(), limit }),-- allActive: () => q.allActive.all({ now: store.now() }),-- pendingReview: () => q.pendingReview.all(),-- pendingOfMerchant: (merchantId) => q.byMerchantPending.all(merchantId).map((r) => r.id),++ restrictionsOf, reportCounts, activeCount, activeCounts, syncIndex, syncMerchant,++ history: async (id) => await q.historyFor.all(id),++ active: async (merchantId) => await q.active.all({ merchant: merchantId, now: store.now() }),++ recentlyEnded: async (merchantId) => await q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),++ recentActive: async (limit = 30) => await q.recentActive.all({ now: store.now(), limit }),++ allActive: async () => await q.allActive.all({ now: store.now() }),++ pendingReview: async () => await q.pendingReview.all(),++ pendingOfMerchant: async (merchantId) => (await q.byMerchantPending.all(merchantId)).map((r) => r.id),/** Publish the codes that waited only because their merchant was pending (not AI or source codes). */-- publishWaitingOn(merchant, { actor }) {++ async publishWaitingOn(merchant, { actor }) {const out = [];-- for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {-- const c = get(id);-- if (c.origin === 'member' || c.origin === 'staff') out.push(approve(c, { actor, audit: false })); // the shop's approval is the audited action++ for (const id of (await q.byMerchantPending.all(merchant.id)).map((r) => r.id)) {++ const c = await get(id);++ if (c.origin === 'member' || c.origin === 'staff') out.push(await approve(c, { actor, audit: false })); // the shop's approval is the audited action}return out;},-- merchantHints: (merchantId) => q.merchantHints.all(merchantId),++ merchantHints: async (merchantId) => await q.merchantHints.all(merchantId),};}@@@ -53,14 +53,14 @@ function createInstalls({ store, config }) {view,/** → { install (view), token } — the token exists only in this return value. */-- create(subject, { label, scopes } = {}) {++ async create(subject, { label, scopes } = {}) {if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'only a signed-in person can connect an extension');const name = String(label == null ? '' : label).trim().slice(0, 60) || 'Browser helper';const wanted = new Set(Array.isArray(scopes) ? scopes : scopes ? [scopes] : []);for (const s of wanted) if (!ALL_SCOPES.includes(s)) throw new ApiError(422, 'install.bad_scope', `unknown scope ${s}`);wanted.add(SCOPES.LOOKUP);const now = store.now();-- if (q.activeCount.get(subject, now).n >= config.limits.installsPerSubject) {++ if ((await q.activeCount.get(subject, now)).n >= config.limits.installsPerSubject) {throw new ApiError(409, 'install.limit', `at most ${config.limits.installsPerSubject} connected extensions; revoke one first`);}const token = `cpx_${crypto.randomBytes(32).toString('base64url')}`;@@@ -69,27 +69,27 @@ function createInstalls({ store, config }) {label: name, scopes: JSON.stringify(ALL_SCOPES.filter((s) => wanted.has(s))),created_at: now, expires_at: now + config.installTtlDays * DAY,};-- q.insert.run(row);-- return { install: view(q.byId.get(row.id)), token };++ await q.insert.run(row);++ return { install: view(await q.byId.get(row.id)), token };},-- list(subject) { return q.mine.all(subject).map(view); },++ async list(subject) { return (await q.mine.all(subject)).map(view); },/** Revoke one of `subject`'s installs. → true when it was active. */-- revoke(subject, id) { return q.revoke.run(store.now(), String(id), subject).changes === 1; },++ async revoke(subject, id) { return (await q.revoke.run(store.now(), String(id), subject)).changes === 1; },/*** Verify a presented token. → { install, subject, scopes } or throws 401 (malformed, unknown,* revoked, expired). Reads the row every time: revocation takes effect on the next request.*/-- verify(token) {++ async verify(token) {if (!TOKEN_RE.test(String(token))) throw new ApiError(401, 'token.invalid', 'malformed extension token');-- const row = q.byHash.get(hashToken(token));++ const row = await q.byHash.get(hashToken(token));if (!row) throw new ApiError(401, 'token.invalid', 'unknown extension token');const now = store.now();if (row.revoked_at) throw new ApiError(401, 'token.revoked', 'this extension was disconnected; connect it again on openvibe.coupons');if (row.expires_at <= now) throw new ApiError(401, 'token.expired', 'this extension token expired; connect it again on openvibe.coupons');-- q.touch.run(now, row.id, now - 60 * 1000);++ await q.touch.run(now, row.id, now - 60 * 1000);return { install: row.id, subject: row.subject, scopes: JSON.parse(row.scopes) };},};@@@ -37,43 +37,44 @@ function createMerchants({ store }) {domainDelete: db.prepare('DELETE FROM coupon_merchant_domains WHERE id = ? AND merchant_id = ?'),rulesFor: db.prepare(`SELECT d.*, m.status AS merchant_status FROM coupon_merchant_domains d JOIN coupon_merchants m ON m.id = d.merchant_idWHERE d.registrable_domain = ?`),-- listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY name COLLATE NOCASE LIMIT ? OFFSET ?`),++ listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY lower(name) LIMIT ? OFFSET ?`),countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),-- search: db.prepare(`SELECT DISTINCT m.* FROM coupon_merchants m LEFT JOIN coupon_merchant_domains d ON d.merchant_id = m.id-- WHERE m.status = 'active' AND (m.name LIKE @q ESCAPE '\\' OR d.host LIKE @q ESCAPE '\\')-- ORDER BY m.name COLLATE NOCASE LIMIT 50`),++ search: db.prepare(`SELECT m.* FROM coupon_merchants m++ WHERE m.status = 'active' AND (m.name ILIKE @q ESCAPE '\\'++ OR EXISTS (SELECT 1 FROM coupon_merchant_domains d WHERE d.merchant_id = m.id AND d.host ILIKE @q ESCAPE '\\'))++ ORDER BY lower(m.name) LIMIT 50`),pending: db.prepare("SELECT * FROM coupon_merchants WHERE status = 'pending' ORDER BY created_at LIMIT 200"),};-- function get(idOrSlug) {++ async function get(idOrSlug) {const s = String(idOrSlug || '');-- return s.startsWith('mer_') ? q.byId.get(s) : q.bySlug.get(s);++ return s.startsWith('mer_') ? await q.byId.get(s) : await q.bySlug.get(s);}-- function uniqueSlug(base) {++ async function uniqueSlug(base) {let slug = SLUG_RE.test(base) ? base : slugify(base) || 'merchant';-- if (!q.bySlug.get(slug)) return slug;-- for (let i = 2; i < 1000; i++) { const s = `${slug.slice(0, 58)}-${i}`; if (!q.bySlug.get(s)) return s; }++ if (!await q.bySlug.get(slug)) return slug;++ for (let i = 2; i < 1000; i++) { const s = `${slug.slice(0, 58)}-${i}`; if (!await q.bySlug.get(s)) return s; }throw new ApiError(409, 'merchant.slug_taken', 'could not find a free slug');}/** Add one domain rule (inside the caller's transaction). */-- function addDomain(merchant, rule, actor) {++ async function addDomain(merchant, rule, actor) {const r = hosts.checkRule(rule);-- const clash = q.domainByKey.get(r.host, r.path_prefix);++ const clash = await q.domainByKey.get(r.host, r.path_prefix);if (clash) {if (clash.merchant_id === merchant.id) return clash;throw new ApiError(409, 'merchant.domain_taken', `${r.host}${r.path_prefix} already belongs to another merchant`);}-- q.domainInsert.run({ merchant_id: merchant.id, ...r, created_by: actor, now: store.now() });-- return q.domainByKey.get(r.host, r.path_prefix);++ await q.domainInsert.run({ merchant_id: merchant.id, ...r, created_by: actor, now: store.now() });++ return await q.domainByKey.get(r.host, r.path_prefix);}/*** Create a merchant with at least one domain rule.* input: { name, slug?, homepage_url?, description?, domains: [{ host, include_subdomains?, path_prefix? }] }*/-- function create(input, { status = 'active', actor }) {++ async function create(input, { status = 'active', actor }) {const domains = Array.isArray(input.domains) ? input.domains : [];if (!domains.length) throw new ApiError(422, 'merchant.domain_required', 'a merchant needs at least one domain');const first = hosts.checkRule(domains[0]);@@@ -86,16 +87,16 @@ function createMerchants({ store }) {} else {homepage = `https://${first.host}/`;}-- return store.tx(() => {++ return await store.tx(async () => {const id = `mer_${ids.ulid(store.now())}`;-- const slug = uniqueSlug(input.slug ? String(input.slug) : slugify(name));-- q.insert.run({++ const slug = await uniqueSlug(input.slug ? String(input.slug) : slugify(name));++ await q.insert.run({id, slug, name, homepage_url: homepage,description: input.description ? String(input.description).trim().slice(0, 1000) : null,status, created_by: actor, now: store.now(),});-- const m = q.byId.get(id);-- for (const d of domains) addDomain(m, d, actor);++ const m = await q.byId.get(id);++ for (const d of domains) await addDomain(m, d, actor);return m;});}@@@ -104,46 +105,46 @@ function createMerchants({ store }) {* The merchant for a host (and optionally a path). `includeStatuses` limits which merchants count* (default: active only). → { merchant, rule, host, registrable } or null.*/-- function resolve(rawHost, { path = null, includeStatuses = ['active'] } = {}) {++ async function resolve(rawHost, { path = null, includeStatuses = ['active'] } = {}) {const host = hosts.normalizeHost(rawHost);const reg = hosts.registrable(host);if (!reg) return null;-- const rules = q.rulesFor.all(reg).filter((r) => includeStatuses.includes(r.merchant_status));++ const rules = (await q.rulesFor.all(reg)).filter((r) => includeStatuses.includes(r.merchant_status));const rule = hosts.bestRule(rules, host, path);if (!rule) return null;-- return { merchant: q.byId.get(rule.merchant_id), rule, host, registrable: reg };++ return { merchant: await q.byId.get(rule.merchant_id), rule, host, registrable: reg };}/** Is `url` on one of the merchant's own domains (merchant evidence)? */-- function ownsUrl(merchant, url) {++ async function ownsUrl(merchant, url) {const h = hosts.hostOfUrl(url);if (!h) return false;-- const rules = q.domains.all(merchant.id);++ const rules = await q.domains.all(merchant.id);return Boolean(hosts.bestRule(rules, h.host, h.path));}return {get,-- byId: (id) => q.byId.get(id),-- domains: (m) => q.domains.all(m.id),++ byId: async (id) => await q.byId.get(id),++ domains: async (m) => await q.domains.all(m.id),create,-- addDomain: (m, rule, actor) => store.tx(() => addDomain(m, rule, actor)),-- removeDomain: (m, domainId) => q.domainDelete.run(Number(domainId), m.id).changes === 1,-- setStatus(m, status) {++ addDomain: async (m, rule, actor) => await store.tx(async () => await addDomain(m, rule, actor)),++ removeDomain: async (m, domainId) => (await q.domainDelete.run(Number(domainId), m.id)).changes === 1,++ async setStatus(m, status) {if (!['pending', 'active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be pending, active or disabled');-- q.setStatus.run(status, store.now(), m.id);-- return q.byId.get(m.id);++ await q.setStatus.run(status, store.now(), m.id);++ return await q.byId.get(m.id);},resolve,ownsUrl,-- listActive: ({ limit = 100, offset = 0 } = {}) => q.listActive.all(limit, offset),-- countActive: () => q.countActive.get().n,-- search(text) {++ listActive: async ({ limit = 100, offset = 0 } = {}) => await q.listActive.all(limit, offset),++ countActive: async () => (await q.countActive.get()).n,++ async search(text) {const t = String(text || '').trim().toLowerCase().slice(0, 60);if (!t) return [];-- return q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });++ return await q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });},-- pending: () => q.pending.all(),++ pending: async () => await q.pending.all(),slugify,};}@@@ -15,16 +15,16 @@ const { ApiError } = require('../http/errors');function createModeration({ store, merchants, coupons, outbox = null }) {return {-- setMerchantStatus(m, status, { actor = 'system', traceparent } = {}) {++ async setMerchantStatus(m, status, { actor = 'system', traceparent } = {}) {if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');-- return store.tx(() => {-- const after = merchants.setStatus(m, status);-- const published = status === 'active' ? coupons.publishWaitingOn(after, { actor }) : [];-- coupons.syncMerchant(after);++ return await store.tx(async () => {++ const after = await merchants.setStatus(m, status);++ const published = status === 'active' ? await coupons.publishWaitingOn(after, { actor }) : [];++ await coupons.syncMerchant(after);// The moderation audit log (ADR-022): who approved, disabled or re-enabled the shop, never who proposed it.if (outbox && m.status !== status && actor !== 'system') {const action = status === 'disabled' ? 'merchant.disabled' : (m.status === 'pending' ? 'merchant.approved' : 'merchant.enabled');-- outbox.moderationAction({++ await outbox.moderationAction({action, target: { type: 'merchant', id: m.id }, actorSubject: /^usr_/.test(String(actor)) ? actor : null,details: { previous: m.status, status, ...(published.length ? { codes_published: published.length } : {}) },}, { traceparent });@@@ -91,20 +91,20 @@ function createPublication({ store, config, outbox }) {}/** Stamp and enqueue a Search document (inside the caller's transaction). */-- function sendDocument(doc, { traceparent } = {}) {-- if (doc.deleted && store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed-- const current = store.sequencer.current(doc.owner, doc.type, doc.id);-- const stamped = store.sequencer.stamp(doc);++ async function sendDocument(doc, { traceparent } = {}) {++ if (doc.deleted && await store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed++ const current = await store.sequencer.current(doc.owner, doc.type, doc.id);++ const stamped = await store.sequencer.stamp(store.db, doc);if (current === stamped.revision) return null; // the same document again: nothing to send-- return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });}/*** A product event (inside the caller's transaction). Public only while the code is publicly* listed; the actor is always the service and the payload never names a person.*/-- function emit(eventType, subject, payload, { isPublic = false, traceparent } = {}) {-- return outbox.emit({++ async function emit(eventType, subject, payload, { isPublic = false, traceparent } = {}) {++ return await outbox.emit({event_type: eventType,actor: ACTOR,visibility: isPublic ? 'public' : 'internal',@@@ -58,44 +58,44 @@ function createReports({ store, config, coupons, merchants, publication }) {/*** → { accepted, deduplicated, corrected, coupon (public view) }*/-- function report(viewer, couponId, body, { channel = 'api', traceparent } = {}) {++ async function report(viewer, couponId, body, { channel = 'api', traceparent } = {}) {const outcome = body && body.outcome;if (!OUTCOMES.includes(outcome)) throw new ApiError(422, 'report.bad_outcome', 'outcome must be "worked" or "failed"');const reason = body.reason == null || body.reason === '' ? null : body.reason;if (reason !== null && !REASONS.includes(reason)) throw new ApiError(422, 'report.bad_reason', `reason must be one of ${REASONS.join(', ')}`);if (outcome === 'worked' && reason) throw new ApiError(422, 'report.bad_reason', 'a reason applies to failed reports only');const who = reporterOf(viewer, channel);-- const c = coupons.get(couponId);++ const c = await coupons.get(couponId);if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');-- const m = merchants.byId(c.merchant_id);++ const m = await merchants.byId(c.merchant_id);const now = store.now();const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');// The submitter is not a reporter: otherwise one account could submit a made-up code and// make it "reported working" with its own report.-- if (c.created_by === who.subject || q.submittedBy.get(c.id, who.subject)) {++ if (c.created_by === who.subject || await q.submittedBy.get(c.id, who.subject)) {throw new ApiError(403, 'report.own_submission', 'you submitted this code; its status comes from other people\'s reports');}const key = reporterKey(who.subject);const day = new Date(now).toISOString().slice(0, 10);-- return store.tx(() => {-- const existing = q.find.get(c.id, key, day);++ return await store.tx(async () => {++ const existing = await q.find.get(c.id, key, day);if (existing && existing.outcome === outcome && existing.reason === reason) {-- return { accepted: true, deduplicated: true, corrected: false, coupon: coupons.view(coupons.get(c.id), { merchant: m }) };++ return { accepted: true, deduplicated: true, corrected: false, coupon: await coupons.view(await coupons.get(c.id), { merchant: m }) };}if (!existing) {-- if (q.since.get(key, now - 60 * 60 * 1000).n >= config.limits.reportsPerHour || q.since.get(key, now - 24 * 60 * 60 * 1000).n >= config.limits.reportsPerDay) {++ if ((await q.since.get(key, now - 60 * 60 * 1000)).n >= config.limits.reportsPerHour || (await q.since.get(key, now - 24 * 60 * 60 * 1000)).n >= config.limits.reportsPerDay) {throw new ApiError(429, 'report.rate_limited', 'too many reports; try again later');}-- q.insert.run({ coupon_id: c.id, reporter_key: key, channel: who.channel, install_id: who.install, day, outcome, reason, now });-- publication.emit('coupons.report.created', { type: 'coupon', id: c.id }, { merchant_id: c.merchant_id, outcome, ...(reason && /^[a-z][a-z0-9_]{0,39}$/.test(reason) ? { reason } : {}), channel: who.channel, day }, { traceparent });++ await q.insert.run({ coupon_id: c.id, reporter_key: key, channel: who.channel, install_id: who.install, day, outcome, reason, now });++ await publication.emit('coupons.report.created', { type: 'coupon', id: c.id }, { merchant_id: c.merchant_id, outcome, ...(reason && /^[a-z][a-z0-9_]{0,39}$/.test(reason) ? { reason } : {}), channel: who.channel, day }, { traceparent });} else {-- q.correct.run({ id: existing.id, outcome, reason, channel: who.channel, install_id: who.install, now });++ await q.correct.run({ id: existing.id, outcome, reason, channel: who.channel, install_id: who.install, now });}-- q.stamp.run({ id: c.id, now, outcome });-- coupons.recompute(c.id, { reason: 'report', actor: 'system', traceparent });-- return { accepted: true, deduplicated: Boolean(existing), corrected: Boolean(existing), coupon: coupons.view(coupons.get(c.id), { merchant: m }) };++ await q.stamp.run({ id: c.id, now, outcome });++ await coupons.recompute(c.id, { reason: 'report', actor: 'system', traceparent });++ return { accepted: true, deduplicated: Boolean(existing), corrected: Boolean(existing), coupon: await coupons.view(await coupons.get(c.id), { merchant: m }) };});}@@@ -16,26 +16,26 @@ const MAX_WATCHES = 200;function createWatches({ store }) {const { db } = store;const q = {-- add: db.prepare('INSERT OR IGNORE INTO coupon_watches (subject, merchant_id, created_at) VALUES (?, ?, ?)'),++ add: db.prepare('INSERT INTO coupon_watches (subject, merchant_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING'),remove: db.prepare('DELETE FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),has: db.prepare('SELECT 1 FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),count: db.prepare('SELECT COUNT(*) AS n FROM coupon_watches WHERE subject = ?'),mine: db.prepare(`SELECT m.* FROM coupon_watches w JOIN coupon_merchants m ON m.id = w.merchant_id-- WHERE w.subject = ? AND m.status = 'active' ORDER BY m.name COLLATE NOCASE`),++ WHERE w.subject = ? AND m.status = 'active' ORDER BY lower(m.name)`),};const person = (subject) => {if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'sign in to watch a merchant');return subject;};return {-- watch(subject, merchant) {++ async watch(subject, merchant) {person(subject);-- if (!q.has.get(subject, merchant.id) && q.count.get(subject).n >= MAX_WATCHES) throw new ApiError(409, 'watch.limit', `at most ${MAX_WATCHES} watches`);-- q.add.run(subject, merchant.id, store.now());++ if (!await q.has.get(subject, merchant.id) && (await q.count.get(subject)).n >= MAX_WATCHES) throw new ApiError(409, 'watch.limit', `at most ${MAX_WATCHES} watches`);++ await q.add.run(subject, merchant.id, store.now());},-- unwatch(subject, merchant) { q.remove.run(person(subject), merchant.id); },-- isWatching: (subject, merchant) => Boolean(subject && q.has.get(subject, merchant.id)),-- list: (subject) => q.mine.all(person(subject)),++ async unwatch(subject, merchant) { await q.remove.run(person(subject), merchant.id); },++ isWatching: async (subject, merchant) => Boolean(subject && await q.has.get(subject, merchant.id)),++ list: async (subject) => await q.mine.all(person(subject)),};}@@@ -29,7 +29,7 @@*/const { createClient } = require('openvibe-sdk/core');const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {const enabled = Boolean(config.events.url && config.oauth.clientSecret);@@@ -45,7 +45,9 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {}const events = createEventsClient(createClient(clientOpts), { source: 'coupons' });let lastError = null;-- const outbox = createOutbox(db, {++ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient++ // transaction); several processes relay one table safely (leases).++ const outbox = createPgOutbox(db, {events,intervalMs: config.events.intervalMs,now,@@@ -55,20 +57,19 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {lastError = msg;},});-- outbox.ensureSchema();/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */-- function emit(envelope, { traceparent } = {}) {-- return outbox.enqueue(envelope, { traceparent });++ async function emit(envelope, { traceparent } = {}) {++ return await outbox.enqueue(db, envelope, { traceparent });}/*** coupons.moderation.action, inside the caller's transaction. actorSubject: the staff member (null* for a service); target: { type, id }, owner_subject always null. Never the code or its text.*/-- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {++ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: null };-- return emit({++ return await emit({event_type: 'coupons.moderation.action',actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'coupons' },subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },@@@ -85,7 +86,7 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {start() { if (enabled) outbox.start(); },stop: () => outbox.stop(),kick() { if (enabled) outbox.kick(); },-- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),};}@@@ -29,7 +29,7 @@* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, revoking a browser helper* (a person must always be able to cut one off), and the pages and feeds people read.*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const FIRST_PARTY = /^svc:/;const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;@@@ -79,7 +79,7 @@ const SERVICE_SUBMIT = { minute: 120, hour: 600 };* first-party service reading for itself not counted), limits.budget(name) (one of BUDGETS) and* limits.submit (the submission budget: a person's, or a service's).*/--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {const refused = registry? registry.counter({ name: 'coupons_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -87,6 +87,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },actor,now,++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),onLimited(e) {// The actor is a subject id, a principal or an address, never a token.log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -85,21 +85,21 @@ function createApi(ctx) {const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');-- function merchantView(m) {++ async function merchantView(m) {return {id: m.id,slug: m.slug,name: m.name,homepage_url: m.homepage_url,url: publication.merchantUrl(m),-- domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),-- active_codes: coupons.activeCount(m.id),-- hints: coupons.merchantHints(m.id).map((h) => h.text),++ domains: (await merchants.domains(m)).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),++ active_codes: await coupons.activeCount(m.id),++ hints: (await coupons.merchantHints(m.id)).map((h) => h.text),};}-- function activeMerchant(idOrSlug) {-- const m = merchants.get(idOrSlug);++ async function activeMerchant(idOrSlug) {++ const m = await merchants.get(idOrSlug);if (!m || m.status !== 'active') throw new ApiError(404, 'merchant.not_found', 'no such merchant');return m;}@@@ -117,9 +117,9 @@ function createApi(ctx) {// ── Lookup ──────────────────────────────────────────────router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);-- router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {++ router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');-- const found = merchants.resolve(req.query.host);++ const found = await merchants.resolve(req.query.host);publicCache(res);if (!found) {const host = hosts.normalizeHost(req.query.host);@@@ -129,38 +129,38 @@ function createApi(ctx) {host: found.host,registrable_domain: found.registrable,matched_rule: { host: found.rule.host, include_subdomains: Boolean(found.rule.include_subdomains), path_prefix: found.rule.path_prefix || null },-- merchant: merchantView(found.merchant),++ merchant: await merchantView(found.merchant),};}));-- router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {-- const m = activeMerchant(req.params.id);++ router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {++ const m = await activeMerchant(req.params.id);publicCache(res);-- return { merchant: merchantView(m), coupons: coupons.active(m.id).map((c) => coupons.view(c, { merchant: m })) };++ return { merchant: await merchantView(m), coupons: await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) };}));-- router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {-- const m = activeMerchant(req.params.id);++ router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {++ const m = await activeMerchant(req.params.id);publicCache(res);-- return { merchant: merchantView(m) };++ return { merchant: await merchantView(m) };}));-- router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {-- const c = coupons.get(req.params.id);-- const m = c && merchants.byId(c.merchant_id);++ router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {++ const c = await coupons.get(req.params.id);++ const m = c && await merchants.byId(c.merchant_id);if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');publicCache(res);-- return { coupon: coupons.view(c, { merchant: m }), merchant: merchantView(m) };++ return { coupon: await coupons.view(c, { merchant: m }), merchant: await merchantView(m) };}));// ── Reports ─────────────────────────────────────────────-- router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run((req, res) => {++ router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run(async (req, res) => {noStore(res);-- return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });++ return await reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });}, (out) => (out.deduplicated ? 200 : 201)));// ── Submissions ─────────────────────────────────────────-- router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), actorLimits.submit, run((req, res) => {++ router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), actorLimits.submit, run(async (req, res) => {noStore(res);const v = req.viewer;let who;@@@ -168,9 +168,9 @@ function createApi(ctx) {if (v.kind === 'service' && v.origin === 'ai') who = { actor: v.service, kind: 'ai', subject: null };else if (v.subject) who = { actor: v.subject, kind: v.kind === 'user' && v.staff ? 'staff' : 'member', subject: v.subject };else throw new ApiError(403, 'auth.person_required', 'a submission needs the person it is from (X-OV-Subject for services)');-- const out = submitCode(who, req.body || {}, { traceparent: req.get('traceparent'), limits: v.kind === 'service' ? scaled(config.limits, 10) : config.limits });++ const out = await submitCode(who, req.body || {}, { traceparent: req.get('traceparent'), limits: v.kind === 'service' ? scaled(config.limits, 10) : config.limits });return {-- coupon: coupons.view(out.coupon, { merchant: out.merchant }),++ coupon: await coupons.view(out.coupon, { merchant: out.merchant }),merchant: { id: out.merchant.id, slug: out.merchant.slug, name: out.merchant.name, status: out.merchant.status },duplicate: out.duplicate,review_state: out.coupon.review_state,@@@ -178,70 +178,70 @@ function createApi(ctx) {}, (out) => (out.duplicate ? 200 : 201)));// ── Staff and services ──────────────────────────────────-- router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), moderate, run((req, res) => {++ router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), moderate, run(async (req, res) => {noStore(res);const actor = requireStaffOrService(req);-- const c = coupons.get(req.params.id);++ const c = await coupons.get(req.params.id);if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');const body = req.body || {};-- const after = coupons.setStatus(c, body.status, { actor, note: body.note, traceparent: req.get('traceparent') });-- return { coupon: coupons.view(after) };++ const after = await coupons.setStatus(c, body.status, { actor, note: body.note, traceparent: req.get('traceparent') });++ return { coupon: await coupons.view(after) };}));-- router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {++ router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run(async (req, res) => {noStore(res);const actor = requireStaffOrService(req);const body = req.body || {};const status = body.status === 'pending' ? 'pending' : 'active';-- const m = merchants.create(body, { status, actor });-- store().tx(() => coupons.syncMerchant(m));-- return { merchant: { ...merchantView(m), status: m.status } };++ const m = await merchants.create(body, { status, actor });++ store().tx(async () => await coupons.syncMerchant(m));++ return { merchant: { ...await merchantView(m), status: m.status } };}, 201));-- router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {++ router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run(async (req, res) => {noStore(res);const actor = requireStaffOrService(req);-- const m = merchants.get(req.params.id);++ const m = await merchants.get(req.params.id);if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');-- merchants.addDomain(m, req.body || {}, actor);-- return { merchant: { ...merchantView(m), status: m.status } };++ await merchants.addDomain(m, req.body || {}, actor);++ return { merchant: { ...await merchantView(m), status: m.status } };}, 201));-- router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {++ router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run(async (req, res) => {noStore(res);const actor = requireStaffOrService(req);-- const m = merchants.get(req.params.id);++ const m = await merchants.get(req.params.id);if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');const status = (req.body || {}).status;if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');-- const after = ctx.moderation.setMerchantStatus(m, status, { actor, traceparent: req.get('traceparent') });-- return { merchant: { ...merchantView(after), status: after.status } };++ const after = await ctx.moderation.setMerchantStatus(m, status, { actor, traceparent: req.get('traceparent') });++ return { merchant: { ...await merchantView(after), status: after.status } };}));// ── The shared submission path (API and the /submit form) ──-- function submitCode(who, body, { traceparent, limits = config.limits } = {}) {++ async function submitCode(who, body, { traceparent, limits = config.limits } = {}) {const input = coupons.parseSubmission(body, ctx.store.now());let merchant = null;if (body.merchant_id) {-- merchant = merchants.get(String(body.merchant_id));++ merchant = await merchants.get(String(body.merchant_id));if (!merchant) throw new ApiError(404, 'merchant.not_found', 'no such merchant');} else if (body.host || body.url) {const at = body.url ? hosts.hostOfUrl(body.url) : { host: hosts.normalizeHost(String(body.host)), path: null };if (!at) throw new ApiError(422, 'host.invalid', 'url must be an http(s) URL of a public site');-- const any = merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending', 'disabled'] });++ const any = await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending', 'disabled'] });if (any) merchant = any.merchant;else {-- coupons.checkSubmissionRate(who.actor, limits);++ await coupons.checkSubmissionRate(who.actor, limits);const reg = hosts.registrable(at.host);if (!reg) throw new ApiError(422, 'host.invalid', `${at.host} is a public suffix, not a site`);// A site no merchant covers yet: proposed as a PENDING merchant, invisible until staff approve it.-- merchant = merchants.create({ name: reg, domains: [{ host: reg, include_subdomains: true }] }, { status: 'pending', actor: who.actor });++ merchant = await merchants.create({ name: reg, domains: [{ host: reg, include_subdomains: true }] }, { status: 'pending', actor: who.actor });}} else {throw new ApiError(422, 'merchant.required', 'name the merchant: merchant_id, host or url');}if (merchant.status === 'disabled') throw new ApiError(409, 'merchant.disabled', 'this merchant was taken down');-- return coupons.submit(who, merchant, input, { limits, traceparent });++ return await coupons.submit(who, merchant, input, { limits, traceparent });}const store = () => ctx.store;@@@ -25,12 +25,12 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publicationconst abs = (p) => seo.canonicalUrl(config.baseUrl, p);const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);-- function activeEntries() {++ async function activeEntries() {const byMerchant = new Map();const out = [];-- for (const c of coupons.allActive()) {++ for (const c of await coupons.allActive()) {let m = byMerchant.get(c.merchant_id);-- if (!m) { m = merchants.byId(c.merchant_id); byMerchant.set(c.merchant_id, m); }++ if (!m) { m = await merchants.byId(c.merchant_id); byMerchant.set(c.merchant_id, m); }out.push({ c, m, decision: publication.couponDecision(c, m) });}return out;@@@ -67,8 +67,8 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication}));});-- router.get('/sitemap.xml', (_req, res) => {-- const entries = activeEntries().filter((e) => e.decision.indexable);++ router.get('/sitemap.xml', async (_req, res) => {++ const entries = (await activeEntries()).filter((e) => e.decision.indexable);const newest = entries.length ? Math.max(...entries.map((e) => e.c.updated_at)) : null;xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/merchants.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },@@@ -76,13 +76,13 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication]));});-- router.get('/sitemaps/coupons.xml', (_req, res) => {-- xml(res, seo.sitemap(activeEntries().map((e) => ({ loc: publication.couponUrl(e.c), lastmod: e.c.updated_at, decision: e.decision }))).files[0]);++ router.get('/sitemaps/coupons.xml', async (_req, res) => {++ xml(res, seo.sitemap((await activeEntries()).map((e) => ({ loc: publication.couponUrl(e.c), lastmod: e.c.updated_at, decision: e.decision }))).files[0]);});-- router.get('/sitemaps/merchants.xml', (_req, res) => {++ router.get('/sitemaps/merchants.xml', async (_req, res) => {const byMerchant = new Map();-- for (const e of activeEntries()) {++ for (const e of await activeEntries()) {const cur = byMerchant.get(e.m.id);if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }@@@ -104,23 +104,23 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publicationdecision,}));}-- const recent = () => activeEntries().slice(0, 50);++ const recent = async () => (await activeEntries()).slice(0, 50);const channel = { title: 'OpenVibe.Coupons — new codes', link: abs('/'), description: 'Codes added to OpenVibe.Coupons, with their status and expiry.' };-- router.get('/feed.xml', (_req, res) => xml(res, seo.rssFeed({ ...channel, feedUrl: abs('/feed.xml'), language: 'en' }, feedItems(recent())), 'application/rss+xml'));-- router.get('/atom.xml', (_req, res) => {-- const items = feedItems(recent());++ router.get('/feed.xml', async (_req, res) => xml(res, seo.rssFeed({ ...channel, feedUrl: abs('/feed.xml'), language: 'en' }, feedItems(await recent())), 'application/rss+xml'));++ router.get('/atom.xml', async (_req, res) => {++ const items = feedItems(await recent());// Atom needs an <updated>; an empty feed has been empty since this process started, so that// instant is its last change (never the request time).xml(res, seo.atomFeed({ ...channel, feedUrl: abs('/atom.xml'), ...(items.length ? {} : { updated: bootTime }) }, items), 'application/atom+xml');});-- router.get('/feed.json', (_req, res) => {-- res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(recent()))));++ router.get('/feed.json', async (_req, res) => {++ res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(await recent()))));});-- router.get('/m/:slug/feed.xml', (req, res, next) => {-- const m = merchants.get(req.params.slug);++ router.get('/m/:slug/feed.xml', async (req, res, next) => {++ const m = await merchants.get(req.params.slug);if (!m || m.status !== 'active' || m.slug !== req.params.slug) return next();-- const list = coupons.active(m.id).map((c) => ({ c, m, decision: publication.couponDecision(c, m) }));++ const list = (await coupons.active(m.id)).map((c) => ({ c, m, decision: publication.couponDecision(c, m) }));xml(res, seo.rssFeed({ title: `${m.name} codes — OpenVibe.Coupons`, link: publication.merchantUrl(m), description: `Active codes for ${m.name}.`, feedUrl: abs(`/m/${m.slug}/feed.xml`), language: 'en' }, feedItems(list)), 'application/rss+xml');});@@@ -105,16 +105,18 @@ function createPublicRoutes(ctx) {decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,body: frame.updatesBody({ service: 'coupons', siteName: 'OpenVibe.Coupons' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}, { cacheable: true }));-- router.get('/', (req, res) => {++ router.get('/', async (req, res) => {const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';-- const recent = coupons.recentActive(12).map((c) => ({ coupon: c, merchant: merchants.byId(c.merchant_id) }));-- const list = merchants.listActive({ limit: 500 }).map((m) => ({ merchant: m, active: coupons.activeCount(m.id) }));++ const recent = await Promise.all((await coupons.recentActive(12)).map(async (c) => ({ coupon: c, merchant: await merchants.byId(c.merchant_id) })));++ const shops = await merchants.listActive({ limit: 500 });++ const counts = await coupons.activeCounts(shops.map((m) => m.id));++ const list = shops.map((m) => ({ merchant: m, active: counts.get(m.id) || 0 }));const decision = q ? pageDecision('/', { indexable: false }) : pageDecision('/');send(req, res, 200, {title: q ? `Search: ${q}` : null, decision, canonical: `${config.baseUrl}/`,feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],jsonLd: [seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Coupons', inLanguage: 'en' })],-- body: pages.home({ merchants: list, recent, q, results: q ? merchants.search(q) : [], total: list.length }),++ body: pages.home({ merchants: list, recent, q, results: q ? await merchants.search(q) : [], total: list.length }),}, { cacheable: true });});@@@ -123,27 +125,27 @@ function createPublicRoutes(ctx) {});// ── Shops ───────────────────────────────────────────────-- function merchantData(m) {-- const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));++ async function merchantData(m) {++ const active = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));return {merchant: { id: m.id, slug: m.slug, name: m.name, homepage_url: m.homepage_url, url: publication.merchantUrl(m),-- domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),-- hints: coupons.merchantHints(m.id).map((x) => x.text) },++ domains: (await merchants.domains(m)).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),++ hints: (await coupons.merchantHints(m.id)).map((x) => x.text) },coupons: active,};}-- function visibleMerchant(req, res) {-- const m = merchants.get(req.params.slug);++ async function visibleMerchant(req, res) {++ const m = await merchants.get(req.params.slug);if (!m || m.slug !== req.params.slug || m.status === 'pending') { notFound(req, res); return null; }if (m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This shop is no longer listed.', { href: '/', label: 'All shops' }); return null; }return m;}-- router.get('/m/:slug.json', (req, res) => {-- const m = visibleMerchant(req, res);++ router.get('/m/:slug.json', async (req, res) => {++ const m = await visibleMerchant(req, res);if (!m) return;-- const data = merchantData(m);++ const data = await merchantData(m);const decision = publication.merchantDecision(m, data.coupons.length);res.vary('Cookie'); res.vary('Authorization');res.set('Cache-Control', 'public, max-age=60');@@@ -151,11 +153,11 @@ function createPublicRoutes(ctx) {res.json({ ...data, indexability: { indexable: decision.indexable, reasons: decision.codes } });});-- router.get('/m/:slug', (req, res) => {-- const m = visibleMerchant(req, res);++ router.get('/m/:slug', async (req, res) => {++ const m = await visibleMerchant(req, res);if (!m) return;-- const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));-- const ended = coupons.recentlyEnded(m.id).map((c) => coupons.view(c, { merchant: m }));++ const active = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));++ const ended = await Promise.all((await coupons.recentlyEnded(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));const decision = publication.merchantDecision(m, active.length);const flashMsg = FLASH[req.query.done] || null;send(req, res, 200, {@@@ -164,60 +166,60 @@ function createPublicRoutes(ctx) {decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],body: pages.merchant({-- m, domains: merchants.domains(m), hints: coupons.merchantHints(m.id), active, ended,++ m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,-- watching: watches.isWatching(req.viewer.subject, m),++ watching: await watches.isWatching(req.viewer.subject, m),}),}, { cacheable: !flashMsg });});-- router.post('/m/:slug/watch', B('coupons.watch'), form, (req, res) => {++ router.post('/m/:slug/watch', B('coupons.watch'), form, async (req, res) => {if (!requireForm(req, res)) return;-- const m = merchants.get(req.params.slug);++ const m = await merchants.get(req.params.slug);if (!m || m.status !== 'active') return notFound(req, res);-- if (req.body.action === 'unwatch') watches.unwatch(req.viewer.subject, m); else watches.watch(req.viewer.subject, m);++ if (req.body.action === 'unwatch') await watches.unwatch(req.viewer.subject, m); else await watches.watch(req.viewer.subject, m);res.redirect(303, withFlash(`/m/${m.slug}`, req.body.action === 'unwatch' ? 'unwatched' : 'watching'));});// ── Codes ───────────────────────────────────────────────-- function visibleCoupon(req, res) {++ async function visibleCoupon(req, res) {const id = String(req.params.id || '');-- const c = COUPON_ID_RE.test(id) ? coupons.get(id) : null;-- const m = c && merchants.byId(c.merchant_id);++ const c = COUPON_ID_RE.test(id) ? await coupons.get(id) : null;++ const m = c && await merchants.byId(c.merchant_id);if (!c || !m || m.status === 'pending' || c.review_state !== 'published') { notFound(req, res); return null; }if (c.status === 'disabled' || m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This code was taken down.', { href: '/', label: 'All shops' }); return null; }return { c, m };}-- router.get('/c/:id.json', (req, res) => {-- const found = visibleCoupon(req, res);++ router.get('/c/:id.json', async (req, res) => {++ const found = await visibleCoupon(req, res);if (!found) return;const decision = publication.couponDecision(found.c, found.m);res.vary('Cookie'); res.vary('Authorization');res.set('Cache-Control', 'public, max-age=60');if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);-- res.json({ coupon: coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });++ res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });});-- router.get('/c/:id', (req, res) => {-- const found = visibleCoupon(req, res);++ router.get('/c/:id', async (req, res) => {++ const found = await visibleCoupon(req, res);if (!found) return;const { c, m } = found;-- const v = coupons.view(c, { merchant: m });++ const v = await coupons.view(c, { merchant: m });const flashMsg = FLASH[req.query.done] || null;send(req, res, 200, {title: `${v.code} — ${v.title} at ${m.name}`,description: `${pages.STATUS_LABEL[v.status]}. ${v.expiry.known ? `Expires ${v.expiry.expires_at.slice(0, 10)}.` : 'Expiry unknown.'}`,decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),-- body: pages.couponPage({ v, m, history: coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),++ body: pages.couponPage({ v, m, history: await coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),}, { cacheable: !flashMsg });});-- router.post('/c/:id/report', B('coupons.report.create'), form, (req, res) => {++ router.post('/c/:id/report', B('coupons.report.create'), form, async (req, res) => {if (!requireForm(req, res)) return;const back = safeBack(req.body.back, `/c/${req.params.id}`);try {-- const out = reports.report(req.viewer, String(req.params.id), { outcome: req.body.outcome, reason: req.body.reason || null }, { channel: 'site' });++ const out = await reports.report(req.viewer, String(req.params.id), { outcome: req.body.outcome, reason: req.body.reason || null }, { channel: 'site' });const key = !out.deduplicated ? 'reported' : out.corrected ? 'report_corrected' : 'report_same';res.redirect(303, withFlash(back, key));} catch (err) {@@@ -227,8 +229,8 @@ function createPublicRoutes(ctx) {});// ── Submit ──────────────────────────────────────────────-- router.get('/submit', (req, res) => {-- const m = typeof req.query.merchant === 'string' ? merchants.get(req.query.merchant) : null;++ router.get('/submit', async (req, res) => {++ const m = typeof req.query.merchant === 'string' ? await merchants.get(req.query.merchant) : null;const values = m && m.status === 'active' ? { merchant: m.slug } : {};send(req, res, 200, {title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),@@@ -236,10 +238,10 @@ function createPublicRoutes(ctx) {});});-- router.post('/submit', B('coupons.coupon.submit'), form, (req, res) => {++ router.post('/submit', B('coupons.coupon.submit'), form, async (req, res) => {if (!requireForm(req, res)) return;const b = req.body || {};-- const m = b.merchant ? merchants.get(String(b.merchant)) : null;++ const m = b.merchant ? await merchants.get(String(b.merchant)) : null;const body = {...(m ? { merchant_id: m.id } : { url: /^https?:\/\//i.test(String(b.url || '')) ? b.url : undefined, host: /^https?:\/\//i.test(String(b.url || '')) ? undefined : b.url }),code: b.code, title: b.title, description: b.description, evidence_url: b.evidence_url || null,@@@ -252,7 +254,7 @@ function createPublicRoutes(ctx) {};const who = { actor: req.viewer.subject, kind: req.viewer.staff ? 'staff' : 'member', subject: req.viewer.subject };try {-- const out = ctx.submitCode(who, body, {});++ const out = await ctx.submitCode(who, body, {});send(req, res, out.duplicate ? 200 : 201, { title: 'Submitted', decision: pageDecision('/submit', { indexable: false }), body: pages.submitted(out) });} catch (err) {if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;@@@ -264,33 +266,33 @@ function createPublicRoutes(ctx) {});// ── Browser helper tokens ───────────────────────────────-- function connectPage(req, res, status, extra = {}) {-- const list = req.viewer.kind === 'user' && req.viewer.subject ? installs.list(req.viewer.subject) : [];++ async function connectPage(req, res, status, extra = {}) {++ const list = req.viewer.kind === 'user' && req.viewer.subject ? await installs.list(req.viewer.subject) : [];send(req, res, status, {title: 'Connect the browser helper', decision: pageDecision('/connect-extension', { indexable: req.viewer.kind === 'anonymous' }),body: pages.connect({ viewer: req.viewer, installs: list, csrf: csrf(req), ...extra }),}, { cacheable: req.viewer.kind === 'anonymous' });}-- router.get('/connect-extension', (req, res) => connectPage(req, res, 200));-- router.post('/connect-extension', B('coupons.install.create'), form, (req, res) => {++ router.get('/connect-extension', async (req, res) => await connectPage(req, res, 200));++ router.post('/connect-extension', B('coupons.install.create'), form, async (req, res) => {if (!requireForm(req, res)) return;try {-- const created = installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });-- connectPage(req, res, 201, { created });++ const created = await installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });++ await connectPage(req, res, 201, { created });} catch (err) {if (!(err instanceof ApiError)) throw err;-- connectPage(req, res, err.status, { errs: [err.message] });++ await connectPage(req, res, err.status, { errs: [err.message] });}});-- router.post('/connect-extension/:id/revoke', form, (req, res) => {++ router.post('/connect-extension/:id/revoke', form, async (req, res) => {if (!requireForm(req, res)) return;-- installs.revoke(req.viewer.subject, String(req.params.id));++ await installs.revoke(req.viewer.subject, String(req.params.id));res.redirect(303, '/connect-extension');});-- router.get('/watching', (req, res) => {++ router.get('/watching', async (req, res) => {if (!requireUser(req, res)) return;-- send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: watches.list(req.viewer.subject) }) });++ send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: await watches.list(req.viewer.subject) }) });});// ── Staff ───────────────────────────────────────────────@@@ -299,56 +301,56 @@ function createPublicRoutes(ctx) {if (!req.viewer.staff) { messagePage(req, res, 403, 'Staff only', 'This page is for OpenVibe staff.'); return false; }return true;}-- function staffPage(req, res, status = 200, extra = {}) {-- const pendingMerchants = merchants.pending().map((m) => ({ ...m, domains: merchants.domains(m).map((d) => d.host + (d.path_prefix || '')), codes: coupons.pendingOfMerchant(m.id).length }));-- const pendingCoupons = coupons.pendingReview().map((c) => {-- const m = merchants.byId(c.merchant_id);-- const v = coupons.view(c, { merchant: m });++ async function staffPage(req, res, status = 200, extra = {}) {++ const pendingMerchants = await Promise.all((await merchants.pending()).map(async (m) => ({ ...m, domains: (await merchants.domains(m)).map((d) => d.host + (d.path_prefix || '')), codes: (await coupons.pendingOfMerchant(m.id)).length })));++ const pendingCoupons = await Promise.all((await coupons.pendingReview()).map(async (c) => {++ const m = await merchants.byId(c.merchant_id);++ const v = await coupons.view(c, { merchant: m });return { id: c.id, code: c.code, title: c.title, origin: c.origin, merchant: m ? m.name : '?', evidence: (v.evidence.find((e) => e.url) || {}).url || null };-- });++ }));send(req, res, status, {title: 'Staff', decision: pageDecision('/staff', { indexable: false }),-- body: pages.staff({ pendingMerchants, pendingCoupons, holds: ctx.importer.holds(), csrf: csrf(req), flashMsg: FLASH[req.query.done] || extra.flashMsg || null, errs: extra.errs }),++ body: pages.staff({ pendingMerchants, pendingCoupons, holds: await ctx.importer.holds(), csrf: csrf(req), flashMsg: FLASH[req.query.done] || extra.flashMsg || null, errs: extra.errs }),});}function staffAction(fn) {-- return (req, res) => {++ return async (req, res) => {if (!requireStaff(req, res)) return;if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');try {-- const msg = fn(req);-- staffPage(req, res, 200, { flashMsg: msg });++ const msg = await fn(req);++ await staffPage(req, res, 200, { flashMsg: msg });} catch (err) {if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;-- staffPage(req, res, err.status || 422, { errs: [err.message] });++ await staffPage(req, res, err.status || 422, { errs: [err.message] });}};}-- router.get('/staff', (req, res) => { if (requireStaff(req, res)) staffPage(req, res); });-- router.post('/staff/merchants', B('coupons.moderate'), form, staffAction((req) => {++ router.get('/staff', async (req, res) => { if (requireStaff(req, res)) await staffPage(req, res); });++ router.post('/staff/merchants', B('coupons.moderate'), form, staffAction(async (req) => {const b = req.body;-- const m = merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });-- store.tx(() => coupons.syncMerchant(m));++ const m = await merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });++ await store.tx(async () => await coupons.syncMerchant(m));return `Added ${m.name}.`;}));-- router.post('/staff/merchants/:id/status', B('coupons.moderate'), form, staffAction((req) => {-- const m = merchants.get(req.params.id);++ router.post('/staff/merchants/:id/status', B('coupons.moderate'), form, staffAction(async (req) => {++ const m = await merchants.get(req.params.id);if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');-- const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });++ const after = await moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });return `${after.name} is now ${after.status}.`;}));-- router.post('/staff/coupons/:id/approve', B('coupons.moderate'), form, staffAction((req) => {-- const c = coupons.get(req.params.id);++ router.post('/staff/coupons/:id/approve', B('coupons.moderate'), form, staffAction(async (req) => {++ const c = await coupons.get(req.params.id);if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');-- const m = merchants.byId(c.merchant_id);++ const m = await merchants.byId(c.merchant_id);if (m.status !== 'active') throw new ApiError(409, 'merchant.not_active', 'approve the shop first');-- coupons.approve(c, { actor: req.viewer.subject });++ await coupons.approve(c, { actor: req.viewer.subject });return `Published ${c.code}.`;}));-- router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], B('coupons.moderate'), form, staffAction((req) => {-- const c = coupons.get(req.params.id || req.body.id);++ router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], B('coupons.moderate'), form, staffAction(async (req) => {++ const c = await coupons.get(req.params.id || req.body.id);if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');-- const after = coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });++ const after = await coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });return `${c.code} is now ${pages.STATUS_LABEL[after.status].toLowerCase()}.`;}));@@@ -7,11 +7,12 @@*/const { createApp } = require('./app');--const { app, ctx } = createApp();++(async () => {++const { app, ctx } = await createApp();const { config } = ctx;const server = app.listen(config.port, config.host, () => {-- console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);});server.keepAliveTimeout = 65_000;@@@ -23,10 +24,11 @@ function shutdown(signal) {ctx.worker.stop();server.close(async () => {try { await ctx.outbox.stop(); } catch { /* best effort */ }-- try { ctx.store.close(); } catch { /* already closed */ }++ try { await ctx.store.close(); } catch { /* already closed */ }process.exit(0);});setTimeout(() => process.exit(0), 5000).unref();}process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));++})().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });@@@ -14,7 +14,7 @@const { createReadiness } = require('openvibe-shared/ready');const { CHARTER_TABLES } = require('./db');--function createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release = null }) {++function createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, valkey = null, release = null }) {const { db } = store;return createReadiness({service: 'coupons',@@@ -22,12 +22,16 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reportschecks: [{name: 'db', required: true,-- check: () => {-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ check: async () => {++ // A real round trip that names the store (postgresql / pglite), and the charter tables present.++ const r = await db.ready();++ if (!r.ok) return r.error;++ const names = new Set((await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((x) => x.name));const missing = CHARTER_TABLES.filter((t) => !names.has(t));-- return missing.length ? `missing ${missing.join(', ')}` : true;++ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };},},++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{name: 'network_jwks', required: false,check: () => {@@@ -38,8 +42,8 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports},{name: 'events_relay', required: false,-- check: () => {-- const s = outbox.status();++ check: async () => {++ const s = await outbox.status();if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;return { ok: true, detail: { pending: s.pending } };@@@ -58,11 +62,11 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports},{name: 'sources_import', required: false,-- check: () => {++ check: async () => {if (!importer.enabled) return 'off (OV_SOURCES_INTERNAL_URL or OV_OAUTH_CLIENT_SECRET unset)';-- const st = importer.state();++ const st = await importer.state();if (st && st.last_error) return `last run failed: ${st.last_error}`;-- return { ok: true, detail: { cursor: st ? st.cursor : 0, held: importer.holdCount() } };++ return { ok: true, detail: { cursor: st ? st.cursor : 0, held: await importer.holdCount() } };},},{@@@ -15,11 +15,11 @@ function createWorker({ config, coupons, importer, outbox, log = console }) {let importing = false;let lastSweep = null;-- function sweep() {++ async function sweep() {if (sweeping) return null;sweeping = true;try {-- const r = coupons.sweep();++ const r = await coupons.sweep();lastSweep = { at: Date.now(), ...r, error: null };if (r.expired.length || r.changed.length) outbox.kick();return r;@@@ -57,21 +57,21 @@ const { actor, serviceItself } = require('../server/http/actor-limits');await check('reports have their own budget (30 a minute) for the person, whichever browser or form sends them', async () => {clock = Date.UTC(2026, 8, 27, 12, 5, 0);-- const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n, MAX(updated_at) AS at FROM coupon_validation_reports').get();++ const stored = async () => await t.ctx.store.db.prepare('SELECT COUNT(*) AS n, MAX(updated_at) AS at FROM coupon_validation_reports').get();for (let i = 0; i < 30; i++) {const r = await t.get(`/api/v1/coupons/${code.id}/report`, { as: i % 2 ? laptop : phone, json: { outcome: i % 3 ? 'worked' : 'failed', reason: i % 3 ? null : 'expired' } });assert.ok(r.status === 200 || r.status === 201, `report ${i + 1}: ${r.text}`);}-- const before = JSON.stringify(stored());++ const before = JSON.stringify(await stored());const r = await t.get(`/c/${code.id}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'failed', reason: 'expired' } });assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the site form shares the budget');assert.ok(r.json().detail.includes('coupons.report.create'), r.json().detail);-- assert.strictEqual(JSON.stringify(stored()), before, 'nothing recorded');++ assert.strictEqual(JSON.stringify(await stored()), before, 'nothing recorded');assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: carols, json: { outcome: 'worked' } })).status, 201, 'another person still reports');});await check('revoking a browser helper is never limited', async () => {-- const installs = t.ctx.installs.list(bob.subject);++ const installs = await t.ctx.installs.list(bob.subject);assert.ok(installs.length >= 2);for (let i = 0; i < 6; i++) {const r = await t.get(`/connect-extension/${installs[0].id}/revoke`, { as: bob, form: { csrf: t.csrf(bob) } });@@@ -20,7 +20,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';const caps = ['coupons.report.create', 'coupons.coupon.submit'];const appToken = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.coupons'], cap: caps, extra });const report = (token, headers) => t.get(`/api/v1/coupons/${code.id}/report`, { as: token, headers, json: { outcome: 'failed', reason: 'invalid' } });-- const reportsOf = (subject) => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ?').get(t.ctx.reports.reporterKey(subject)).n;++ const reportsOf = async (subject) => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ?').get(t.ctx.reports.reporterKey(subject))).n;await check('an app token cannot act for someone else by naming them in X-OV-Subject', async () => {for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {@@@ -32,14 +32,14 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';}const sub = await t.get('/api/v1/coupons/submit', { as: appToken(APP, 'app', {}), headers: { 'x-ov-subject': victim.subject }, json: { host: 'tinrobot.shop', code: 'FAKE99', title: 'Made up' } });assert.strictEqual(sub.status, 403);-- assert.strictEqual(reportsOf(victim.subject), 0);++ assert.strictEqual(await reportsOf(victim.subject), 0);});await check('an app token acts for its on_behalf_of person (header optional, must match)', async () => {const token = appToken(APP, 'app', { on_behalf_of: appUser.subject });assert.strictEqual((await report(token)).status, 201);assert.strictEqual((await report(token, { 'x-ov-subject': appUser.subject })).status, 200, 'same person, same day: deduplicated');-- assert.strictEqual(reportsOf(appUser.subject), 1);++ assert.strictEqual(await reportsOf(appUser.subject), 1);});await check('sandbox app tokens are refused', async () => {@@@ -30,7 +30,7 @@ const DAY = 24 * 3600 * 1000;aliceToken = await t.connect(alice, { label: 'Firefox' });bobToken = await t.connect(bob, { label: 'Chrome', report: false });assert.match(aliceToken, /^cpx_[A-Za-z0-9_-]{43}$/);-- const rows = t.ctx.store.db.prepare('SELECT * FROM extension_installs').all();++ const rows = await t.ctx.store.db.prepare('SELECT * FROM extension_installs').all();assert.strictEqual(rows.length, 2);const dump = JSON.stringify(rows);assert.ok(!dump.includes(aliceToken) && !dump.includes(bobToken), 'the token itself is never stored');@@@ -43,10 +43,10 @@ const DAY = 24 * 3600 * 1000;await check('connect and revoke forms need the form token (CSRF)', async () => {const r = await t.get('/connect-extension', { as: alice, form: { label: 'x', scope_report: '1' } });assert.strictEqual(r.status, 403);-- const install = t.ctx.installs.list(alice.subject)[0];++ const install = (await t.ctx.installs.list(alice.subject))[0];const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: 'forged' } });assert.strictEqual(rv.status, 403);-- assert.strictEqual(t.ctx.installs.list(alice.subject)[0].active, true);++ assert.strictEqual((await t.ctx.installs.list(alice.subject))[0].active, true);});await check('scopes: lookup always; report only when granted; nothing else (no submit, no staff)', async () => {@@@ -64,7 +64,7 @@ const DAY = 24 * 3600 * 1000;await check('revocation takes effect on the very next request', async () => {assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken })).status, 200);-- const install = t.ctx.installs.list(alice.subject).find((i) => i.label === 'Firefox');++ const install = (await t.ctx.installs.list(alice.subject)).find((i) => i.label === 'Firefox');const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });assert.strictEqual(rv.status, 303);const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken });@@@ -72,7 +72,7 @@ const DAY = 24 * 3600 * 1000;assert.strictEqual(r.json().code, 'token.revoked');assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'failed' } })).status, 401);// Someone else cannot revoke bob's install.-- const bobInstall = t.ctx.installs.list(bob.subject)[0];++ const bobInstall = (await t.ctx.installs.list(bob.subject))[0];await t.get(`/connect-extension/${bobInstall.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: bobToken })).status, 200);});@@@ -41,11 +41,14 @@ async function boot(opts = {}) {const { createApp } = require('../../server/app');const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };++ const { createStore } = require('../../server/db');++ // One database per boot (PGlite, or COUPONS_TEST_STORE=pg: the containers); a restart keeps it, like a file did.++ const testdb = await require('./db').testDb();let server = null;let built = null;async function start() {const config = configLib.load(env);-- built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });await built.ctx.auth.ensureKey();server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;@@@ -54,7 +57,7 @@ async function boot(opts = {}) {}async function stop() {if (server) await new Promise((r) => server.close(r));-- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }server = null; built = null;}@@@ -72,8 +75,8 @@ async function boot(opts = {}) {}/** Rows of event_outbox as parsed envelopes. */-- function events(type = null) {-- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))++ async function events(type = null) {++ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));}@@@ -86,7 +89,7 @@ async function boot(opts = {}) {}async function submit(user, body) {-- return get('/api/v1/coupons/submit', { as: network.userToken(user), json: body });++ return await get('/api/v1/coupons/submit', { as: network.userToken(user), json: body });}async function connect(user, { report = true, label = 'test browser' } = {}) {@@@ -101,7 +104,7 @@ async function boot(opts = {}) {network, sources, clock, dbPath, staff, get, events, csrf, merchant, submit, connect,staffToken: () => network.userToken(staff),async restart() { await stop(); await start(); },-- async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ async close() { await stop(); await testdb.close(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },};await start();return t;nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * COUPONS_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.COUPONS_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'coupons', max });++++module.exports = { testDb, pgAvailable };@@@ -46,7 +46,7 @@ const DAY = 24 * HOUR;await check('the unknown code stays unknown across time and sweeps while nobody reports', async () => {t.clock.advance(40 * DAY);-- t.ctx.worker.sweep();++ await t.ctx.worker.sweep();const c = (await t.get(`/api/v1/coupons/${unknownCode.id}`)).json().coupon;assert.strictEqual(c.status, 'unknown');assert.strictEqual(c.confidence, null);@@@ -153,21 +153,21 @@ const DAY = 24 * HOUR;});await check('the sweep records the expiry: status, history, coupons.coupon.expired, Search tombstone — once', async () => {-- const before = t.events().length;-- const r = t.ctx.worker.sweep();++ const before = (await t.events()).length;++ const r = await t.ctx.worker.sweep();assert.deepStrictEqual(r.expired, [dated.id]);-- const row = t.ctx.coupons.get(dated.id);++ const row = await t.ctx.coupons.get(dated.id);assert.strictEqual(row.status, 'expired');assert.strictEqual(row.expired_at, expiresAt, 'expired at the stated instant, not at sweep time');-- const hist = t.ctx.coupons.history(dated.id);++ const hist = await t.ctx.coupons.history(dated.id);assert.strictEqual(hist[0].to_status, 'expired');assert.strictEqual(hist[0].reason, 'expiry');-- const evs = t.events().slice(before);++ const evs = (await t.events()).slice(before);const mine = evs.filter((e) => e.subject.id === dated.id).map((e) => e.event_type).sort();assert.deepStrictEqual(mine, ['coupons.coupon.expired', 'coupons.index_document.deleted']);assert.ok(evs.some((e) => e.event_type === 'coupons.index_document.upserted' && e.subject.id === shop.id), 'the shop document is re-sent with one active code fewer');-- assert.deepStrictEqual(t.ctx.worker.sweep().expired, [], 'idempotent');-- assert.strictEqual(t.events().length, before + evs.length);++ assert.deepStrictEqual((await t.ctx.worker.sweep()).expired, [], 'idempotent');++ assert.strictEqual((await t.events()).length, before + evs.length);});await check('a date-only expiry means the end of that day in UTC, and the page says the time zone is not stated', async () => {@@@ -216,7 +216,7 @@ const DAY = 24 * HOUR;});await check('every event is a valid envelope from the service, and none names a person', async () => {-- const all = t.events();++ const all = await t.events();assert.ok(all.length > 10);for (const e of all) {const v = contracts.validate('events.event-envelope@1', e);@@@ -12,7 +12,7 @@ const { boot, check, done } = require('./helpers/boot');const t = await boot();const alice = t.network.addUser('alice');await t.merchant({ name: 'Acme Outdoor', host: 'acme-outdoor.com' });-- const moderation = () => t.events('coupons.moderation.action');++ const moderation = async () => await t.events('coupons.moderation.action');const setStatus = (id, body) => t.get(`/api/v1/coupons/${id}/status`, { as: t.staffToken(), json: body });const valid = (e) => {assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));@@@ -30,7 +30,7 @@ const { boot, check, done } = require('./helpers/boot');const r = await t.submit(t.staff, { host: 'acme-outdoor.com', code: 'STAFFOWN', title: 'Staff code' });assert.strictEqual(r.status, 201, r.text);assert.strictEqual((await setStatus(r.json().coupon.id, { status: 'disabled' })).status, 200);-- assert.strictEqual(moderation().length, 0);++ assert.strictEqual((await moderation()).length, 0);});await check('staff disabling someone else\'s code: exactly one valid event', async () => {@@@ -39,7 +39,7 @@ const { boot, check, done } = require('./helpers/boot');const id = r.json().coupon.id;const off = await setStatus(id, { status: 'disabled', note: 'fabricated code' });assert.strictEqual(off.status, 200, off.text);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, 1);valid(ev[0]);assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `coupon:${id}` });@@@ -53,10 +53,10 @@ const { boot, check, done } = require('./helpers/boot');await check('approving a shop members proposed: one merchant.approved, not one per code it publishes', async () => {const r = await t.submit(alice, { url: 'https://www.new-gadgets.net/cart', code: 'GADGET5', title: 'Five off gadgets' });assert.strictEqual(r.json().merchant.status, 'pending');-- const n = moderation().length;++ const n = (await moderation()).length;const ok = await t.get(`/staff/merchants/${r.json().merchant.id}/status`, { as: t.staff, form: { csrf: t.csrf(t.staff), status: 'active' } });assert.strictEqual(ok.status, 200);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, n + 1);valid(ev[n]);assert.strictEqual(ev[n].payload.action, 'merchant.approved');@@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/boot');const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });assert.strictEqual(r.status, 201, r.text.slice(0, 300));assert.match(r.text, /your code is listed/);-- codeId = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'VEG5'").get().id;++ codeId = (await t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'VEG5'").get()).id;const m = await t.get(`/m/${shop.slug}`);assert.match(m.text, /VEG5/);assert.match(m.text, /Minimum spend £30\.00/);@@@ -24,7 +24,7 @@ const DAY = 24 * HOUR;assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { json: { outcome: 'worked' } })).status, 401);const cookieOnly = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bob, json: { outcome: 'worked' } });assert.strictEqual(cookieOnly.status, 401, 'a signed-in cookie does not authenticate the API');-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get()).n, 0);});await check('input is validated: outcome worked|failed, reasons only for failed ones', async () => {@@@ -50,12 +50,12 @@ const DAY = 24 * HOUR;const tokenB = await t.connect(bob, { label: 'phone' });assert.strictEqual((await report(tokenA, code.id, { outcome: 'worked' })).json().deduplicated, true);assert.strictEqual((await report(tokenB, code.id, { outcome: 'worked' })).json().deduplicated, true);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get()).n, 1);const c = await report(bob, code.id, { outcome: 'failed', reason: 'min_spend_not_met' });assert.strictEqual(c.json().corrected, true);assert.strictEqual(c.json().coupon.status, 'reported_failed');-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);-- assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get()).n, 1);++ assert.strictEqual((await t.events('coupons.report.created')).length, 1, 'one report, one event');});await check('the submitter (or anyone who added evidence) cannot report on the code: one account cannot make its own code "working"', async () => {@@@ -70,8 +70,8 @@ const DAY = 24 * HOUR;const hank = t.network.addUser('hank');assert.strictEqual((await t.submit(hank, { host: 'bluekettle.shop', code: 'MADEUP50', title: 'Code MADEUP50', evidence_url: 'https://bluekettle.shop/deals' })).status, 200, 'duplicate: evidence added');assert.strictEqual((await report(hank, fake.id, { outcome: 'worked' })).status, 403, 'evidence submitters are submitters too');-- assert.strictEqual(t.ctx.coupons.get(fake.id).status, 'unknown');-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id).n, 0);++ assert.strictEqual((await t.ctx.coupons.get(fake.id)).status, 'unknown');++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id)).n, 0);});await check('the next day is a new report, but only the latest per person counts', async () => {@@@ -95,11 +95,11 @@ const DAY = 24 * HOUR;assert.doesNotMatch(body, /reporter|install_id|cpi_/);const last = r.json().coupon.reports.last_report_at;assert.match(last, /T\d{2}:00:00\.000Z$/);-- for (const e of t.events('coupons.report.created')) {++ for (const e of await t.events('coupons.report.created')) {assert.deepStrictEqual(Object.keys(e.payload).sort(), ['channel', 'day', 'merchant_id', 'outcome', ...(e.payload.reason ? ['reason'] : [])].sort());assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });}-- const keys = t.ctx.store.db.prepare('SELECT reporter_key FROM coupon_validation_reports').all().map((x) => x.reporter_key);++ const keys = (await t.ctx.store.db.prepare('SELECT reporter_key FROM coupon_validation_reports').all()).map((x) => x.reporter_key);for (const k of keys) { assert.match(k, /^[0-9a-f]{64}$/); assert.ok(!k.includes('usr_')); }});@@@ -125,20 +125,20 @@ const DAY = 24 * HOUR;const fresh = await mk('DECAY1');const frank = t.network.addUser('frank');await report(frank, fresh.id, { outcome: 'worked' });-- assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');++ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).status, 'reported_working');t.clock.advance(6 * DAY);-- t.ctx.worker.sweep();-- assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');++ await t.ctx.worker.sweep();++ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).status, 'reported_working');t.clock.advance(2 * DAY);-- t.ctx.worker.sweep();-- assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'unknown');-- assert.ok(t.ctx.coupons.get(fresh.id).confidence > 0.5);++ await t.ctx.worker.sweep();++ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).status, 'unknown');++ assert.ok((await t.ctx.coupons.get(fresh.id)).confidence > 0.5);t.clock.advance(23 * DAY);-- t.ctx.worker.sweep();-- assert.strictEqual(t.ctx.coupons.get(fresh.id).confidence, null);-- const hist = t.ctx.coupons.history(fresh.id).map((h) => h.reason);++ await t.ctx.worker.sweep();++ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).confidence, null);++ const hist = (await t.ctx.coupons.history(fresh.id)).map((h) => h.reason);assert.ok(hist.includes('decay'));-- assert.ok(t.events('coupons.confidence.changed').some((e) => e.subject.id === fresh.id && e.payload.to === null));++ assert.ok((await t.events('coupons.confidence.changed')).some((e) => e.subject.id === fresh.id && e.payload.to === null));});await t.close();@@@ -20,13 +20,13 @@ const { boot, check, done } = require('./helpers/boot');t.sources.put(item('itm_01K0000000000000000000000A'));const r = await t.ctx.importer.run();assert.deepStrictEqual(r.outcomes, { imported: 1 });-- const c = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").get();++ const c = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").get();assert.strictEqual(c.status, 'unknown');assert.strictEqual(c.confidence, null);assert.strictEqual(c.expires_at, null);assert.strictEqual(c.origin, 'source');assert.strictEqual(c.review_state, 'pending');-- const v = t.ctx.coupons.view(c);++ const v = await t.ctx.coupons.view(c);assert.deepStrictEqual(v.evidence, [{ kind: 'source', url: 'https://riverbooks.com/promo', merchant_page: true, sources_item: 'itm_01K0000000000000000000000A', retrieved_at: '2026-09-22T10:00:00.000Z' }]);assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.length, 0, 'not listed before review');assert.strictEqual(t.sources.calls.length, 1);@@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/boot');t.sources.put(item('itm_01K0000000000000000000000E', { fields: { code: 'OLD', expires: '2026-01-01' } }));const r = await t.ctx.importer.run();assert.deepStrictEqual(r.outcomes, { 'held:no_merchant': 1, 'held:not_a_coupon': 1, 'held:no_code': 1, 'held:expired': 1, 'retry:held:no_merchant': 1 });-- const holds = t.ctx.importer.holds().map((h) => h.reason).sort();++ const holds = (await t.ctx.importer.holds()).map((h) => h.reason).sort();assert.deepStrictEqual(holds, ['expired', 'no_code', 'no_merchant', 'not_a_coupon']);const staff = await t.get('/staff', { as: t.staff });assert.match(staff.text, /itm_01K0000000000000000000000B from staff-coupon-codes: no_merchant/);@@@ -56,18 +56,18 @@ const { boot, check, done } = require('./helpers/boot');await t.merchant({ name: 'Unknown Shop', host: 'unknown-shop.com' });const r = await t.ctx.importer.run();assert.deepStrictEqual(r.outcomes, { 'retry:imported': 1 });-- assert.ok(t.ctx.store.db.prepare("SELECT 1 FROM coupons WHERE code = 'NOPE'").get());-- assert.ok(!t.ctx.importer.holds().some((h) => h.reason === 'no_merchant'));++ assert.ok(await t.ctx.store.db.prepare("SELECT 1 FROM coupons WHERE code = 'NOPE'").get());++ assert.ok(!(await t.ctx.importer.holds()).some((h) => h.reason === 'no_merchant'));});await check('a stated expiry is kept with basis "source"; a new revision of the same item updates the evidence, not a second code', async () => {t.sources.put(item('itm_01K0000000000000000000000A', { revision: 2, fields: { code: 'READ20', expires: '2026-12-31', min_spend: 25, currency: 'EUR' } }));await t.ctx.importer.run();-- const rows = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").all();++ const rows = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").all();assert.strictEqual(rows.length, 1);assert.strictEqual(rows[0].expiry_basis, 'source');assert.strictEqual(new Date(rows[0].expires_at).toISOString(), '2026-12-31T23:59:59.999Z');-- const src = t.ctx.store.db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ?').all(rows[0].id);++ const src = await t.ctx.store.db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ?').all(rows[0].id);assert.strictEqual(src.length, 1);assert.strictEqual(src[0].sources_item_rev, 2);});@@@ -83,24 +83,24 @@ const { boot, check, done } = require('./helpers/boot');});await check('a removed item withdraws its evidence; a code with no evidence left is taken down', async () => {-- const id = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'READ20'").get().id;++ const id = (await t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'READ20'").get()).id;t.sources.put(item('itm_01K0000000000000000000000A', { revision: 3, removed: { at: '2026-09-23T00:00:00.000Z', reason: 'merchant asked' } }));const r = await t.ctx.importer.run();assert.strictEqual(r.outcomes.withdrawn, 1);-- const c = t.ctx.coupons.get(id);++ const c = await t.ctx.coupons.get(id);assert.strictEqual(c.status, 'disabled');assert.strictEqual(c.status_reason, 'source_removed');});await check('a failed fetch changes nothing and is recorded', async () => {-- const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n;-- const cursor = t.ctx.importer.state().cursor;++ const before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n;++ const cursor = (await t.ctx.importer.state()).cursor;t.sources.setDown(true);const r = await t.ctx.importer.run();assert.match(r.error, /503/);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n, before);-- assert.strictEqual(t.ctx.importer.state().cursor, cursor);-- assert.match(t.ctx.importer.state().last_error, /503/);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n, before);++ assert.strictEqual((await t.ctx.importer.state()).cursor, cursor);++ assert.match((await t.ctx.importer.state()).last_error, /503/);const ready = (await t.get('/api/ready')).json();assert.match(JSON.stringify(ready), /last run failed/);t.sources.setDown(false);
@@ -13,6 +13,14 @@ BASE_URL=http://localhost:4850
@@ -13,6 +13,14 @@ BASE_URL=http://localhost:4850
TRUST_PROXY=2
TRUST_PROXY=2
# SQLite database (production unit: /var/lib/openvibe-coupons/coupons.db)
# SQLite database (production unit: /var/lib/openvibe-coupons/coupons.db)
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh coupons: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
COUPONS_DB_PATH=./data/coupons.db
COUPONS_DB_PATH=./data/coupons.db
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: coupons
contracts-service: coupons
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -32,7 +32,7 @@ Four rules shape everything here:
@@ -32,7 +32,7 @@ Four rules shape everything here:
## Owns
## Owns
-The nine charter tables live in Coupons' own SQLite (`COUPONS_DB_PATH`):
+The nine charter tables live in Coupons' own PostgreSQL database (`ov_coupons` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
| Charter table | What it is |
| Charter table | What it is |
|---|---|
|---|---|
@@ -296,9 +296,11 @@ What Coupons calls elsewhere, and with which grant, is under
@@ -296,9 +296,11 @@ What Coupons calls elsewhere, and with which grant, is under
## Depends on
## Depends on
-- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.4.0 (seo gate, ssr,
- index-hooks), `openvibe-contracts` v0.53.0, `openvibe-shared` v1.25.0 (chrome, app icon, footer,
- legal, release, metrics, ready, seo), `openvibe-sdk` v0.12.0 (events outbox, service tokens,
+- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
+ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
+- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (seo gate, ssr,
+ index-hooks), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0 (Frame, app icon, footer,
+ legal, release, metrics, ready, seo), `openvibe-sdk` v0.20.0 (events outbox, service tokens,
per-actor limits).
per-actor limits).
- **OpenVibe.Network:**
- **OpenVibe.Network:**
- SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`
- SSO: an OAuth client `coupons` with redirect `https://openvibe.coupons/auth/callback`
@@ -423,10 +425,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4850 (set OV_OAUTH
@@ -423,10 +425,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4850 (set OV_OAUTH
Production deploys with `sudo ovhost deploy coupons` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy coupons` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.coupons`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.coupons`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-coupons.service` on `127.0.0.1:4850`, the env file `/etc/openvibe/coupons.env`.
+The unit is `openvibe-coupons.service` on `127.0.0.1:4850`, the env file `/etc/openvibe/coupons.env`. The database is
+`ov_coupons` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh coupons` writes its settings); the
+release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
+`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old
+`/var/lib/openvibe-coupons/coupons.db` stays read-only for 7 days as the rollback.
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
-restart; afterwards `sudo ovhost rollback coupons --to <sha>`. Nothing blocks a rollback: the schema
-code only adds tables and columns.
+restart; afterwards `sudo ovhost rollback coupons --to <sha>`. Migrations only add tables and columns.
First install (done once; kept for a rebuild):
First install (done once; kept for a rebuild):
@@ -4,13 +4,28 @@
@@ -4,13 +4,28 @@
"code": true,
"code": true,
"wave": "W18",
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express 4, better-sqlite3 11",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4850,
"port": 4850,
"domain": "openvibe.coupons",
"domain": "openvibe.coupons",
- "contracts": "openvibe-contracts v0.53.0",
- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
- "capabilities": ["coupons.coupon.submit", "coupons.report.create", "coupons.merchant.resolve", "coupons.coupon.lookup", "coupons.status.update", "coupons.merchant.manage"],
- "installScopes": ["coupons.lookup", "coupons.report"],
+ "contracts": "openvibe-contracts v0.76.0",
+ "packages": [
+ "openvibe-publishing v1.0.0",
+ "openvibe-contracts v0.76.0",
+ "openvibe-shared v1.25.0",
+ "openvibe-sdk v0.20.0"
+ ],
+ "capabilities": [
+ "coupons.coupon.submit",
+ "coupons.report.create",
+ "coupons.merchant.resolve",
+ "coupons.coupon.lookup",
+ "coupons.status.update",
+ "coupons.merchant.manage"
+ ],
+ "installScopes": [
+ "coupons.lookup",
+ "coupons.report"
+ ],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
"seedCoupons": "none (production holds 0 coupons and 0 merchants)",
"seedCoupons": "none (production holds 0 coupons and 0 merchants)",
@@ -3,8 +3,8 @@ Description=OpenVibe.Coupons — coupon codes with merchant matching, honest exp
@@ -3,8 +3,8 @@ Description=OpenVibe.Coupons — coupon codes with merchant matching, honest exp
# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be
# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be
# up first, but Coupons degrades gracefully without them: pages, lookups and feeds serve; sign-in,
# up first, but Coupons degrades gracefully without them: pages, lookups and feeds serve; sign-in,
# events and imports wait (see /api/ready).
# events and imports wait (see /api/ready).
-After=network.target
-Wants=openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+-- phase: expand
+-- OpenVibe.Coupons on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
+-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
+-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
+
+CREATE TABLE coupon_merchants (
+ id text COLLATE "C" PRIMARY KEY, -- mer_<ULID>
+ slug text COLLATE "C" NOT NULL UNIQUE, -- /m/:slug
+ name text COLLATE "C" NOT NULL,
+ homepage_url text COLLATE "C", -- https://<registrable domain>/ unless staff set another
+ description text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),
+ created_by text COLLATE "C", -- usr_… | svc:<id> | system
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE coupon_merchant_domains (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),
+ host text COLLATE "C" NOT NULL, -- normalized (lowercase, punycode, no port, no trailing dot)
+ registrable_domain text COLLATE "C" NOT NULL, -- eTLD+1 of host (bundled public-suffix subset)
+ include_subdomains bigint NOT NULL DEFAULT 1,
+ path_prefix text COLLATE "C" NOT NULL DEFAULT '', -- '' = the whole host; '/shop/x' matches only with a path
+ created_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ UNIQUE (host, path_prefix)
+);
+CREATE INDEX coupon_merchant_domains_registrable ON coupon_merchant_domains (registrable_domain);
+CREATE INDEX coupon_merchant_domains_merchant ON coupon_merchant_domains (merchant_id);
+
+CREATE TABLE coupons (
+ id text COLLATE "C" PRIMARY KEY, -- cpn_<ULID>
+ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),
+ code text COLLATE "C" NOT NULL, -- as the merchant writes it
+ code_key text COLLATE "C" NOT NULL, -- uppercase, for duplicate detection
+ title text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'unknown'
+ CHECK (status IN ('unknown','reported_working','reported_failed','expired','disabled')),
+ status_reason text COLLATE "C",
+ confidence double precision, -- NULL: no report in the window (validity unknown)
+ review_state text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (review_state IN ('pending','published')),
+ origin text COLLATE "C" NOT NULL CHECK (origin IN ('member','staff','source','ai')),
+ expires_at bigint, -- NULL: expiry unknown (never guessed)
+ expires_precision text COLLATE "C" CHECK (expires_precision IN ('instant','date')),
+ expiry_basis text COLLATE "C" CHECK (expiry_basis IN ('evidence','submitter','source','staff')),
+ expired_at bigint, -- when it left active results as expired
+ disabled_at bigint,
+ last_report_at bigint,
+ last_worked_at bigint,
+ last_failed_at bigint,
+ created_by text COLLATE "C", -- usr_… | svc:<id>; never returned by the API
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ CHECK (expires_at IS NULL OR expires_precision IS NOT NULL),
+ UNIQUE (merchant_id, code_key)
+);
+CREATE INDEX coupons_active ON coupons (merchant_id, status, review_state, expires_at);
+CREATE INDEX coupons_expiry ON coupons (expires_at) WHERE expires_at IS NOT NULL;
+CREATE INDEX coupons_recent ON coupons (created_at);
+
+CREATE TABLE coupon_sources (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('member','staff','source','ai')),
+ evidence_url text COLLATE "C", -- where the code was published (never fetched by Coupons)
+ merchant_evidence bigint NOT NULL DEFAULT 0, -- evidence_url is on one of the merchant's own domains
+ sources_item_id text COLLATE "C", -- itm_… (OpenVibe.Sources)
+ sources_item_rev bigint,
+ source_key text COLLATE "C",
+ retrieved_at bigint, -- Sources' observation time
+ ai_run_id text COLLATE "C", -- OpenVibe.AI run (coupons.extract_coupon), when origin is ai
+ submitted_by text COLLATE "C", -- usr_… | svc:<id>; never returned by the API
+ removed_at bigint,
+ removed_reason text COLLATE "C",
+ created_at bigint NOT NULL
+);
+CREATE INDEX coupon_sources_coupon ON coupon_sources (coupon_id);
+CREATE UNIQUE INDEX coupon_sources_item ON coupon_sources (coupon_id, sources_item_id) WHERE sources_item_id IS NOT NULL;
+CREATE INDEX coupon_sources_submitter ON coupon_sources (submitted_by, created_at);
+
+CREATE TABLE coupon_restrictions (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('min_spend','category','new_customers_only','region','other')),
+ value text COLLATE "C", -- category name, ISO 3166-1 alpha-2 region, free text
+ amount_minor bigint, -- min_spend in minor units
+ currency text COLLATE "C", -- ISO 4217
+ created_at bigint NOT NULL,
+ CHECK (kind <> 'min_spend' OR (amount_minor IS NOT NULL AND currency IS NOT NULL))
+);
+CREATE INDEX coupon_restrictions_coupon ON coupon_restrictions (coupon_id);
+
+CREATE TABLE coupon_validation_reports (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),
+ reporter_key text COLLATE "C" NOT NULL, -- HMAC(COUPONS_REPORTER_KEY_SECRET, subject)
+ channel text COLLATE "C" NOT NULL CHECK (channel IN ('site','extension','api')),
+ install_id text COLLATE "C", -- cpi_… when reported through the extension
+ day text COLLATE "C" NOT NULL, -- UTC YYYY-MM-DD: one report per reporter, coupon and day
+ outcome text COLLATE "C" NOT NULL CHECK (outcome IN ('worked','failed')),
+ reason text COLLATE "C" CHECK (reason IN ('invalid','expired','min_spend_not_met','not_eligible','other')),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (coupon_id, reporter_key, day)
+);
+CREATE INDEX coupon_reports_coupon ON coupon_validation_reports (coupon_id, updated_at);
+CREATE INDEX coupon_reports_reporter ON coupon_validation_reports (reporter_key, created_at);
+
+CREATE TABLE coupon_status_history (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ coupon_id text COLLATE "C" NOT NULL REFERENCES coupons(id),
+ from_status text COLLATE "C",
+ to_status text COLLATE "C" NOT NULL,
+ confidence_before double precision,
+ confidence_after double precision,
+ reason text COLLATE "C" NOT NULL, -- created | report | decay | expiry | staff:<note> | service:<id>
+ actor text COLLATE "C" NOT NULL, -- system | usr_… | svc:<id>
+ created_at bigint NOT NULL
+);
+CREATE INDEX coupon_status_history_coupon ON coupon_status_history (coupon_id, id);
+
+CREATE TABLE coupon_application_hints (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),
+ coupon_id text COLLATE "C" REFERENCES coupons(id), -- NULL: applies to every code of the merchant
+ text text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C",
+ created_at bigint NOT NULL
+);
+CREATE INDEX coupon_hints_merchant ON coupon_application_hints (merchant_id);
+
+CREATE TABLE coupon_watches (
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ merchant_id text COLLATE "C" NOT NULL REFERENCES coupon_merchants(id),
+ created_at bigint NOT NULL,
+ PRIMARY KEY (subject, merchant_id)
+);
+
+CREATE TABLE extension_installs (
+ id text COLLATE "C" PRIMARY KEY, -- cpi_<ULID>
+ subject text COLLATE "C" NOT NULL, -- usr_… who connected it
+ token_hash text COLLATE "C" NOT NULL UNIQUE, -- sha256 of the cpx_ token (the token is shown once)
+ token_hint text COLLATE "C" NOT NULL, -- first characters, to recognise it in the list
+ label text COLLATE "C" NOT NULL,
+ scopes text COLLATE "C" NOT NULL, -- JSON array: coupons.lookup, coupons.report
+ created_at bigint NOT NULL,
+ expires_at bigint NOT NULL,
+ last_used_at bigint,
+ revoked_at bigint
+);
+CREATE INDEX extension_installs_subject ON extension_installs (subject, revoked_at);
+
+CREATE TABLE import_state (
+ key text COLLATE "C" PRIMARY KEY,
+ cursor bigint NOT NULL DEFAULT 0,
+ last_run_at bigint,
+ last_ok_at bigint,
+ last_error text COLLATE "C"
+);
+
+CREATE TABLE coupon_import_holds (
+ item_id text COLLATE "C" PRIMARY KEY, -- itm_… held, never silently dropped
+ source_key text COLLATE "C",
+ reason text COLLATE "C" NOT NULL, -- no_merchant | not_a_coupon | no_code | invalid
+ detail text COLLATE "C",
+ item text COLLATE "C" NOT NULL, -- the item as Sources returned it (JSON)
+ attempts bigint NOT NULL DEFAULT 1,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ resolved_at bigint
+);
+
+-- openvibe-publishing/index-hooks (prefix coupons)
+CREATE TABLE IF NOT EXISTS coupons_index_revisions (
+ owner text COLLATE "C" NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ hash text NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (owner, type, id)
+);
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS idempotency_receipts (
+ consumer text NOT NULL,
+ event_id text NOT NULL,
+ processed_at bigint NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
@@ -15,18 +15,34 @@
@@ -15,18 +15,34 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
+ "pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -228,6 +244,15 @@
@@ -228,6 +244,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -310,6 +335,15 @@
@@ -310,6 +335,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -746,6 +780,49 @@
@@ -746,6 +780,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -810,12 +887,24 @@
@@ -810,12 +887,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1015,9 +1104,9 @@
@@ -1015,9 +1104,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.53.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
+ "version": "0.76.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1031,15 +1120,15 @@
@@ -1031,15 +1120,15 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"node_modules/openvibe-publishing": {
- "version": "0.4.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
},
},
"peerDependencies": {
"peerDependencies": {
- "better-sqlite3": ">=11",
+ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
"openvibe-shared": ">=1.5.0"
},
},
"peerDependenciesMeta": {
"peerDependenciesMeta": {
@@ -1049,12 +1138,32 @@
@@ -1049,12 +1138,32 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.12.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "version": "0.20.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
@@ -1081,6 +1190,134 @@
@@ -1081,6 +1190,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1204,6 +1441,27 @@
@@ -1204,6 +1441,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1419,6 +1677,21 @@
@@ -1419,6 +1677,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1546,6 +1819,15 @@
@@ -1546,6 +1819,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -2,14 +2,20 @@
@@ -2,14 +2,20 @@
"name": "openvibe-coupons",
"name": "openvibe-coupons",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Coupons — coupon codes with merchant and domain resolution, restrictions, honest expiry, deduplicated and rate-limited people's validity reports, and the public lookup API the OpenVibe browser helper uses. Server-rendered and useful without JavaScript.",
+ "description": "OpenVibe.Coupons \u2014 coupon codes with merchant and domain resolution, restrictions, honest expiry, deduplicated and rate-limited people's validity reports, and the public lookup API the OpenVibe browser helper uses. Server-rendered and useful without JavaScript.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/run.js"
+ "test": "node test/run.js",
+ "test:pg": "COUPONS_TEST_STORE=pg node test/run.js"
},
},
- "keywords": ["openvibe", "coupons", "merchants", "browser-extension"],
+ "keywords": [
+ "openvibe",
+ "coupons",
+ "merchants",
+ "browser-extension"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Coupons",
"repository": "github:OpenVibers/OpenVibe.Coupons",
@@ -20,13 +26,16 @@
@@ -20,13 +26,16 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
+ "pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
- "jsonwebtoken": "^9.0.2"
+ "jsonwebtoken": "^9.0.2",
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Coupons's SQLite database (COUPONS_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
+ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
+ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
+ * name and columns.
+ */
+require('dotenv').config();
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const configLib = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ const config = configLib.load();
+ runSqliteMigration({ service: 'coupons', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
+ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { TABLES };
@@ -43,11 +43,12 @@ const VERSION = require('../package.json').version;
@@ -43,11 +43,12 @@ const VERSION = require('../package.json').version;
* opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
* opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
* limitsNow (the per-actor limiter's clock, tests)
* limitsNow (the per-actor limiter's clock, tests)
*/
*/
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
+ const store = opts.store || await openStore(config, { now: opts.now, log });
const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const publication = createPublication({ store, config, outbox });
const publication = createPublication({ store, config, outbox });
@@ -73,7 +74,10 @@ function createApp(opts = {}) {
@@ -73,7 +74,10 @@ function createApp(opts = {}) {
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router
// Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router
// resolved req.viewer; the lookup, per-address and per-person limits stay.
// resolved req.viewer; the lookup, per-address and per-person limits stay.
- ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
+ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -105,7 +109,7 @@ function createApp(opts = {}) {
@@ -105,7 +109,7 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-coupons', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-coupons', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- const readiness = createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release: release.release });
+ const readiness = createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
@@ -81,12 +81,12 @@ function createViewerResolver({ auth, config, installs }) {
@@ -81,12 +81,12 @@ function createViewerResolver({ auth, config, installs }) {
if (!header.startsWith('Bearer ')) throw new ApiError(401, 'auth.unsupported', 'use Authorization: Bearer <token>');
if (!header.startsWith('Bearer ')) throw new ApiError(401, 'auth.unsupported', 'use Authorization: Bearer <token>');
const token = header.slice(7).trim();
const token = header.slice(7).trim();
if (token.startsWith('cpx_')) {
if (token.startsWith('cpx_')) {
- const v = installs.verify(token);
+ const v = await installs.verify(token);
return { kind: 'install', install: v.install, subject: v.subject, scopes: v.scopes, staff: false, origin: 'user' };
return { kind: 'install', install: v.install, subject: v.subject, scopes: v.scopes, staff: false, origin: 'user' };
}
}
const payload = decodeJwtPayload(token);
const payload = decodeJwtPayload(token);
if (!payload) throw new ApiError(401, 'token.invalid', 'malformed bearer token');
if (!payload) throw new ApiError(401, 'token.invalid', 'malformed bearer token');
- if (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return fromServiceToken(req, token);
+ if (typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) return await fromServiceToken(req, token);
const claims = await auth.verify(token);
const claims = await auth.verify(token);
const user = userFromClaims(claims, token);
const user = userFromClaims(claims, token);
if (!user) throw new ApiError(401, 'token.invalid', 'invalid or expired token');
if (!user) throw new ApiError(401, 'token.invalid', 'invalid or expired token');
@@ -33,19 +33,20 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -33,19 +33,20 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
}) : null;
}) : null;
const q = {
const q = {
state: db.prepare('SELECT * FROM import_state WHERE key = ?'),
state: db.prepare('SELECT * FROM import_state WHERE key = ?'),
- ensure: db.prepare('INSERT OR IGNORE INTO import_state (key, cursor) VALUES (?, 0)'),
+ ensure: db.prepare('INSERT INTO import_state (key, cursor) VALUES (?, 0) ON CONFLICT DO NOTHING'),
cursor: db.prepare('UPDATE import_state SET cursor = ?, last_run_at = ?, last_ok_at = ?, last_error = NULL WHERE key = ?'),
cursor: db.prepare('UPDATE import_state SET cursor = ?, last_run_at = ?, last_ok_at = ?, last_error = NULL WHERE key = ?'),
failed: db.prepare('UPDATE import_state SET last_run_at = ?, last_error = ? WHERE key = ?'),
failed: db.prepare('UPDATE import_state SET last_run_at = ?, last_error = ? WHERE key = ?'),
hold: db.prepare(`INSERT INTO coupon_import_holds (item_id, source_key, reason, detail, item, attempts, created_at, updated_at)
hold: db.prepare(`INSERT INTO coupon_import_holds (item_id, source_key, reason, detail, item, attempts, created_at, updated_at)
VALUES (@item_id, @source_key, @reason, @detail, @item, 1, @now, @now)
VALUES (@item_id, @source_key, @reason, @detail, @item, 1, @now, @now)
ON CONFLICT (item_id) DO UPDATE SET reason = excluded.reason, detail = excluded.detail, item = excluded.item,
ON CONFLICT (item_id) DO UPDATE SET reason = excluded.reason, detail = excluded.detail, item = excluded.item,
- attempts = attempts + 1, updated_at = excluded.updated_at, resolved_at = NULL`),
+ attempts = coupon_import_holds.attempts + 1, updated_at = excluded.updated_at, resolved_at = NULL`),
resolve: db.prepare('UPDATE coupon_import_holds SET resolved_at = ?, updated_at = ? WHERE item_id = ? AND resolved_at IS NULL'),
resolve: db.prepare('UPDATE coupon_import_holds SET resolved_at = ?, updated_at = ? WHERE item_id = ? AND resolved_at IS NULL'),
retryable: db.prepare("SELECT item FROM coupon_import_holds WHERE resolved_at IS NULL AND reason = 'no_merchant' ORDER BY updated_at LIMIT 100"),
retryable: db.prepare("SELECT item FROM coupon_import_holds WHERE resolved_at IS NULL AND reason = 'no_merchant' ORDER BY updated_at LIMIT 100"),
open: db.prepare('SELECT item_id, source_key, reason, detail, attempts, created_at, updated_at FROM coupon_import_holds WHERE resolved_at IS NULL ORDER BY updated_at DESC LIMIT 200'),
open: db.prepare('SELECT item_id, source_key, reason, detail, attempts, created_at, updated_at FROM coupon_import_holds WHERE resolved_at IS NULL ORDER BY updated_at DESC LIMIT 200'),
openCount: db.prepare('SELECT COUNT(*) AS n FROM coupon_import_holds WHERE resolved_at IS NULL'),
openCount: db.prepare('SELECT COUNT(*) AS n FROM coupon_import_holds WHERE resolved_at IS NULL'),
};
};
- q.ensure.run(STATE_KEY);
+ let ensured = false; // the state row, written on the first run (not at construction: the factory stays synchronous)
+ const ensureState = async () => { if (!ensured) { await q.ensure.run(STATE_KEY); ensured = true; } };
function parseItem(item) {
function parseItem(item) {
const f = item.fields && typeof item.fields === 'object' ? item.fields : {};
const f = item.fields && typeof item.fields === 'object' ? item.fields : {};
@@ -61,30 +62,30 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -61,30 +62,30 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
}
}
/** Handle one item inside a transaction. → 'imported' | 'updated' | 'withdrawn' | 'held:<reason>' */
/** Handle one item inside a transaction. → 'imported' | 'updated' | 'withdrawn' | 'held:<reason>' */
- function handle(item) {
+ async function handle(item) {
const now = store.now();
const now = store.now();
- const held = (reason, detail = null) => {
- q.hold.run({ item_id: item.id, source_key: item.source_key || null, reason, detail: detail ? String(detail).slice(0, 300) : null, item: JSON.stringify(item), now });
+ const held = async (reason, detail = null) => {
+ await q.hold.run({ item_id: item.id, source_key: item.source_key || null, reason, detail: detail ? String(detail).slice(0, 300) : null, item: JSON.stringify(item), now });
return `held:${reason}`;
return `held:${reason}`;
};
};
- return store.tx(() => {
+ return await store.tx(async () => {
if (item.removed) {
if (item.removed) {
- coupons.withdrawSourceItem(item.id, item.removed.reason);
- q.resolve.run(now, now, item.id);
+ await coupons.withdrawSourceItem(item.id, item.removed.reason);
+ await q.resolve.run(now, now, item.id);
return 'withdrawn';
return 'withdrawn';
}
}
- if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);
- if (item.kind !== 'coupon') return held('not_a_coupon', `kind ${item.kind}`);
- if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return held('no_code');
+ if (config.sources.keys.length && !config.sources.keys.includes(item.source_key)) return await held('not_selected', `source ${item.source_key} is not in COUPONS_SOURCES_KEYS`);
+ if (item.kind !== 'coupon') return await held('not_a_coupon', `kind ${item.kind}`);
+ if (!item.fields || typeof item.fields.code !== 'string' || !item.fields.code.trim()) return await held('no_code');
let parsed;
let parsed;
try { parsed = parseItem(item); } catch (err) {
try { parsed = parseItem(item); } catch (err) {
- return held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
+ return await held(err.code === 'coupon.already_expired' ? 'expired' : 'invalid', err.message);
}
}
const at = hosts.hostOfUrl(item.canonical_url);
const at = hosts.hostOfUrl(item.canonical_url);
- const found = at ? merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
- if (!found) return held('no_merchant', at ? at.host : 'no canonical URL');
- const r = coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
- q.resolve.run(now, now, item.id);
+ const found = at ? await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending'] }) : null;
+ if (!found) return await held('no_merchant', at ? at.host : 'no canonical URL');
+ const r = await coupons.importFromSource(found.merchant, item, parsed, { autoPublish: config.sources.autoPublish });
+ await q.resolve.run(now, now, item.id);
return r.created ? 'imported' : 'updated';
return r.created ? 'imported' : 'updated';
});
});
}
}
@@ -100,23 +101,24 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -100,23 +101,24 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
/** One import run. → { outcomes: {…}, cursor } or { error } */
/** One import run. → { outcomes: {…}, cursor } or { error } */
async function run({ maxPages = 10 } = {}) {
async function run({ maxPages = 10 } = {}) {
+ await ensureState();
if (!enabled) return { disabled: true };
if (!enabled) return { disabled: true };
const outcomes = {};
const outcomes = {};
const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };
const count = (o) => { outcomes[o] = (outcomes[o] || 0) + 1; };
- let cursor = q.state.get(STATE_KEY).cursor;
+ let cursor = (await q.state.get(STATE_KEY)).cursor;
try {
try {
for (let i = 0; i < maxPages; i++) {
for (let i = 0; i < maxPages; i++) {
const page = await getPage(cursor);
const page = await getPage(cursor);
- for (const item of page.items) count(handle(item));
+ for (const item of page.items) count(await handle(item));
cursor = page.next_after != null ? Number(page.next_after) : cursor;
cursor = page.next_after != null ? Number(page.next_after) : cursor;
- q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
+ await q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
if (!page.more) break;
if (!page.more) break;
}
}
- for (const { item } of q.retryable.all()) count(`retry:${handle(JSON.parse(item))}`);
- q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
+ for (const { item } of await q.retryable.all()) count(`retry:${await handle(JSON.parse(item))}`);
+ await q.cursor.run(cursor, store.now(), store.now(), STATE_KEY);
return { outcomes, cursor };
return { outcomes, cursor };
} catch (err) {
} catch (err) {
- q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);
+ await q.failed.run(store.now(), String(err.message).slice(0, 300), STATE_KEY);
log.warn('[Coupons] Sources import failed (nothing changed):', err.message);
log.warn('[Coupons] Sources import failed (nothing changed):', err.message);
return { error: err.message, outcomes, cursor };
return { error: err.message, outcomes, cursor };
}
}
@@ -126,9 +128,9 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
@@ -126,9 +128,9 @@ function createSourcesImporter({ store, config, merchants, coupons, fetchImpl =
enabled,
enabled,
run,
run,
handle,
handle,
- state: () => q.state.get(STATE_KEY),
- holds: () => q.open.all(),
- holdCount: () => q.openCount.get().n,
+ state: async () => await q.state.get(STATE_KEY),
+ holds: async () => await q.open.all(),
+ holdCount: async () => (await q.openCount.get()).n,
};
};
}
}
@@ -35,6 +35,10 @@ function load(env = process.env) {
@@ -35,6 +35,10 @@ function load(env = process.env) {
hour: Math.max(1, int(env.COUPONS_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(env.COUPONS_LIMITS_HOUR, 3000)),
},
},
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:coupons:' },
+ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.COUPONS_DB_PATH || './data/coupons.db',
dbPath: env.COUPONS_DB_PATH || './data/coupons.db',
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
/**
/**
- * Coupons' own SQLite database, created on boot, idempotently. Nothing here is shared with another
+ * Coupons' own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing here is shared with another
* service.
* service.
*
*
* The nine charter tables (roadmap §15.13):
* The nine charter tables (roadmap §15.13):
@@ -24,203 +25,53 @@
@@ -24,203 +25,53 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const STATUSES = Object.freeze(['unknown', 'reported_working', 'reported_failed', 'expired', 'disabled']);
const STATUSES = Object.freeze(['unknown', 'reported_working', 'reported_failed', 'expired', 'disabled']);
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS coupon_merchants (
- id TEXT PRIMARY KEY, -- mer_<ULID>
- slug TEXT NOT NULL UNIQUE, -- /m/:slug
- name TEXT NOT NULL,
- homepage_url TEXT, -- https://<registrable domain>/ unless staff set another
- description TEXT,
- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),
- created_by TEXT, -- usr_… | svc:<id> | system
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
-CREATE TABLE IF NOT EXISTS coupon_merchant_domains (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
- host TEXT NOT NULL, -- normalized (lowercase, punycode, no port, no trailing dot)
- registrable_domain TEXT NOT NULL, -- eTLD+1 of host (bundled public-suffix subset)
- include_subdomains INTEGER NOT NULL DEFAULT 1,
- path_prefix TEXT NOT NULL DEFAULT '', -- '' = the whole host; '/shop/x' matches only with a path
- created_by TEXT,
- created_at INTEGER NOT NULL,
- UNIQUE (host, path_prefix)
-);
-CREATE INDEX IF NOT EXISTS coupon_merchant_domains_registrable ON coupon_merchant_domains (registrable_domain);
-CREATE INDEX IF NOT EXISTS coupon_merchant_domains_merchant ON coupon_merchant_domains (merchant_id);
-
-CREATE TABLE IF NOT EXISTS coupons (
- id TEXT PRIMARY KEY, -- cpn_<ULID>
- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
- code TEXT NOT NULL, -- as the merchant writes it
- code_key TEXT NOT NULL, -- uppercase, for duplicate detection
- title TEXT NOT NULL,
- description TEXT,
- status TEXT NOT NULL DEFAULT 'unknown'
- CHECK (status IN ('unknown','reported_working','reported_failed','expired','disabled')),
- status_reason TEXT,
- confidence REAL, -- NULL: no report in the window (validity unknown)
- review_state TEXT NOT NULL DEFAULT 'published' CHECK (review_state IN ('pending','published')),
- origin TEXT NOT NULL CHECK (origin IN ('member','staff','source','ai')),
- expires_at INTEGER, -- NULL: expiry unknown (never guessed)
- expires_precision TEXT CHECK (expires_precision IN ('instant','date')),
- expiry_basis TEXT CHECK (expiry_basis IN ('evidence','submitter','source','staff')),
- expired_at INTEGER, -- when it left active results as expired
- disabled_at INTEGER,
- last_report_at INTEGER,
- last_worked_at INTEGER,
- last_failed_at INTEGER,
- created_by TEXT, -- usr_… | svc:<id>; never returned by the API
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- CHECK (expires_at IS NULL OR expires_precision IS NOT NULL),
- UNIQUE (merchant_id, code_key)
-);
-CREATE INDEX IF NOT EXISTS coupons_active ON coupons (merchant_id, status, review_state, expires_at);
-CREATE INDEX IF NOT EXISTS coupons_expiry ON coupons (expires_at) WHERE expires_at IS NOT NULL;
-CREATE INDEX IF NOT EXISTS coupons_recent ON coupons (created_at);
-
-CREATE TABLE IF NOT EXISTS coupon_sources (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- coupon_id TEXT NOT NULL REFERENCES coupons(id),
- kind TEXT NOT NULL CHECK (kind IN ('member','staff','source','ai')),
- evidence_url TEXT, -- where the code was published (never fetched by Coupons)
- merchant_evidence INTEGER NOT NULL DEFAULT 0, -- evidence_url is on one of the merchant's own domains
- sources_item_id TEXT, -- itm_… (OpenVibe.Sources)
- sources_item_rev INTEGER,
- source_key TEXT,
- retrieved_at INTEGER, -- Sources' observation time
- ai_run_id TEXT, -- OpenVibe.AI run (coupons.extract_coupon), when origin is ai
- submitted_by TEXT, -- usr_… | svc:<id>; never returned by the API
- removed_at INTEGER,
- removed_reason TEXT,
- created_at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS coupon_sources_coupon ON coupon_sources (coupon_id);
-CREATE UNIQUE INDEX IF NOT EXISTS coupon_sources_item ON coupon_sources (coupon_id, sources_item_id) WHERE sources_item_id IS NOT NULL;
-CREATE INDEX IF NOT EXISTS coupon_sources_submitter ON coupon_sources (submitted_by, created_at);
-
-CREATE TABLE IF NOT EXISTS coupon_restrictions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- coupon_id TEXT NOT NULL REFERENCES coupons(id),
- kind TEXT NOT NULL CHECK (kind IN ('min_spend','category','new_customers_only','region','other')),
- value TEXT, -- category name, ISO 3166-1 alpha-2 region, free text
- amount_minor INTEGER, -- min_spend in minor units
- currency TEXT, -- ISO 4217
- created_at INTEGER NOT NULL,
- CHECK (kind <> 'min_spend' OR (amount_minor IS NOT NULL AND currency IS NOT NULL))
-);
-CREATE INDEX IF NOT EXISTS coupon_restrictions_coupon ON coupon_restrictions (coupon_id);
-
-CREATE TABLE IF NOT EXISTS coupon_validation_reports (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- coupon_id TEXT NOT NULL REFERENCES coupons(id),
- reporter_key TEXT NOT NULL, -- HMAC(COUPONS_REPORTER_KEY_SECRET, subject)
- channel TEXT NOT NULL CHECK (channel IN ('site','extension','api')),
- install_id TEXT, -- cpi_… when reported through the extension
- day TEXT NOT NULL, -- UTC YYYY-MM-DD: one report per reporter, coupon and day
- outcome TEXT NOT NULL CHECK (outcome IN ('worked','failed')),
- reason TEXT CHECK (reason IN ('invalid','expired','min_spend_not_met','not_eligible','other')),
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- UNIQUE (coupon_id, reporter_key, day)
-);
-CREATE INDEX IF NOT EXISTS coupon_reports_coupon ON coupon_validation_reports (coupon_id, updated_at);
-CREATE INDEX IF NOT EXISTS coupon_reports_reporter ON coupon_validation_reports (reporter_key, created_at);
-
-CREATE TABLE IF NOT EXISTS coupon_status_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- coupon_id TEXT NOT NULL REFERENCES coupons(id),
- from_status TEXT,
- to_status TEXT NOT NULL,
- confidence_before REAL,
- confidence_after REAL,
- reason TEXT NOT NULL, -- created | report | decay | expiry | staff:<note> | service:<id>
- actor TEXT NOT NULL, -- system | usr_… | svc:<id>
- created_at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS coupon_status_history_coupon ON coupon_status_history (coupon_id, id);
-
-CREATE TABLE IF NOT EXISTS coupon_application_hints (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
- coupon_id TEXT REFERENCES coupons(id), -- NULL: applies to every code of the merchant
- text TEXT NOT NULL,
- created_by TEXT,
- created_at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS coupon_hints_merchant ON coupon_application_hints (merchant_id);
-
-CREATE TABLE IF NOT EXISTS coupon_watches (
- subject TEXT NOT NULL, -- usr_…
- merchant_id TEXT NOT NULL REFERENCES coupon_merchants(id),
- created_at INTEGER NOT NULL,
- PRIMARY KEY (subject, merchant_id)
-);
-
-CREATE TABLE IF NOT EXISTS extension_installs (
- id TEXT PRIMARY KEY, -- cpi_<ULID>
- subject TEXT NOT NULL, -- usr_… who connected it
- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the cpx_ token (the token is shown once)
- token_hint TEXT NOT NULL, -- first characters, to recognise it in the list
- label TEXT NOT NULL,
- scopes TEXT NOT NULL, -- JSON array: coupons.lookup, coupons.report
- created_at INTEGER NOT NULL,
- expires_at INTEGER NOT NULL,
- last_used_at INTEGER,
- revoked_at INTEGER
-);
-CREATE INDEX IF NOT EXISTS extension_installs_subject ON extension_installs (subject, revoked_at);
-
-CREATE TABLE IF NOT EXISTS import_state (
- key TEXT PRIMARY KEY,
- cursor INTEGER NOT NULL DEFAULT 0,
- last_run_at INTEGER,
- last_ok_at INTEGER,
- last_error TEXT
-);
-
-CREATE TABLE IF NOT EXISTS coupon_import_holds (
- item_id TEXT PRIMARY KEY, -- itm_… held, never silently dropped
- source_key TEXT,
- reason TEXT NOT NULL, -- no_merchant | not_a_coupon | no_code | invalid
- detail TEXT,
- item TEXT NOT NULL, -- the item as Sources returned it (JSON)
- attempts INTEGER NOT NULL DEFAULT 1,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- resolved_at INTEGER
-);
-`;
+/**
+ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
+ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
+ */
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh coupons)');
+ log.warn(`[Coupons] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ const db = createDb({ pglite: DEV_PGLITE, service: 'coupons', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'coupons-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'coupons', registry, log });
+}
/**
/**
- * Open (or create) the database.
- * opts.now — injectable clock (epoch ms), so tests and replays are deterministic.
+ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms), so tests and replays are
+ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).
*/
*/
-function openStore(dbPath, { now = () => Date.now() } = {}) {
- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
- const db = new Database(dbPath);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- db.exec(SCHEMA);
+function createStore(db, { now = () => Date.now() } = {}) {
return {
return {
db,
db,
now,
now,
sequencer: createIndexSequencer(db, { prefix: 'coupons', now }),
sequencer: createIndexSequencer(db, { prefix: 'coupons', now }),
- tx: (fn) => db.transaction(fn)(),
+ tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
close: () => db.close(),
};
};
}
}
+/** openDb + createStore. */
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
const CHARTER_TABLES = ['coupon_merchants', 'coupon_merchant_domains', 'coupons', 'coupon_sources', 'coupon_restrictions',
const CHARTER_TABLES = ['coupon_merchants', 'coupon_merchant_domains', 'coupons', 'coupon_sources', 'coupon_restrictions',
'coupon_validation_reports', 'coupon_status_history', 'coupon_application_hints', 'coupon_watches'];
'coupon_validation_reports', 'coupon_status_history', 'coupon_application_hints', 'coupon_watches'];
-module.exports = { openStore, CHARTER_TABLES, STATUSES };
+module.exports = { openDb, openStore, createStore, MIGRATIONS, CHARTER_TABLES, STATUSES };
@@ -176,6 +176,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -176,6 +176,7 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
active: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}
active: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}
ORDER BY CASE c.status WHEN 'reported_working' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, COALESCE(c.last_worked_at, 0) DESC, c.created_at DESC LIMIT 200`),
ORDER BY CASE c.status WHEN 'reported_working' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, COALESCE(c.last_worked_at, 0) DESC, c.created_at DESC LIMIT 200`),
activeCount: db.prepare(`SELECT COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}`),
activeCount: db.prepare(`SELECT COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = @merchant AND ${ACTIVE_SQL}`),
+ activeCounts: db.prepare(`SELECT c.merchant_id, COUNT(*) AS n FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE c.merchant_id = ANY(@merchants) AND ${ACTIVE_SQL} GROUP BY c.merchant_id`),
recentlyEnded: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id
recentlyEnded: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id
WHERE c.merchant_id = @merchant AND c.review_state = 'published' AND m.status = 'active'
WHERE c.merchant_id = @merchant AND c.review_state = 'published' AND m.status = 'active'
AND (c.status = 'expired' OR (c.expires_at IS NOT NULL AND c.expires_at <= @now)) AND c.status <> 'disabled'
AND (c.status = 'expired' OR (c.expires_at IS NOT NULL AND c.expires_at <= @now)) AND c.status <> 'disabled'
@@ -189,16 +190,18 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -189,16 +190,18 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
pendingReview: db.prepare(`SELECT c.* FROM coupons c WHERE c.review_state = 'pending' AND c.status <> 'disabled' ORDER BY c.created_at LIMIT 200`),
pendingReview: db.prepare(`SELECT c.* FROM coupons c WHERE c.review_state = 'pending' AND c.status <> 'disabled' ORDER BY c.created_at LIMIT 200`),
setReview: db.prepare("UPDATE coupons SET review_state = 'published', updated_at = ? WHERE id = ? AND review_state = 'pending'"),
setReview: db.prepare("UPDATE coupons SET review_state = 'published', updated_at = ? WHERE id = ? AND review_state = 'pending'"),
setExpiry: db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ?, expiry_basis = ?, updated_at = ? WHERE id = ?'),
setExpiry: db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ?, expiry_basis = ?, updated_at = ? WHERE id = ?'),
+ // Each reporter's latest report counts once (DISTINCT ON: SQLite took the bare column from the MAX row).
reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (
reportCounts: db.prepare(`SELECT outcome, COUNT(*) AS n FROM (
- SELECT reporter_key, outcome, MAX(updated_at) AS t FROM coupon_validation_reports WHERE coupon_id = ? AND updated_at > ? GROUP BY reporter_key)
+ SELECT DISTINCT ON (reporter_key) reporter_key, outcome FROM coupon_validation_reports
+ WHERE coupon_id = ? AND updated_at > ? ORDER BY reporter_key, updated_at DESC) latest
GROUP BY outcome`),
GROUP BY outcome`),
byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),
byMerchantPending: db.prepare("SELECT id FROM coupons WHERE merchant_id = ? AND review_state = 'pending'"),
ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),
ofMerchant: db.prepare('SELECT id FROM coupons WHERE merchant_id = ?'),
};
};
- const get = (id) => q.byId.get(String(id || ''));
+ const get = async (id) => await q.byId.get(String(id || ''));
- function restrictionsOf(id) { return q.restrictions.all(id); }
+ async function restrictionsOf(id) { return await q.restrictions.all(id); }
function restrictionsText(rows) {
function restrictionsText(rows) {
return rows.map((r) => {
return rows.map((r) => {
@@ -210,30 +213,35 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -210,30 +213,35 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
}).join('\n');
}).join('\n');
}
}
- function sourceRefs(id) {
- return q.sources.all(id).filter((s) => s.sources_item_id && !s.removed_at)
+ async function sourceRefs(id) {
+ return (await q.sources.all(id)).filter((s) => s.sources_item_id && !s.removed_at)
.map((s) => ({ service: 'sources', type: 'item', id: s.sources_item_id, ...(Number.isInteger(s.sources_item_rev) ? { revision: s.sources_item_rev } : {}), ...(s.evidence_url ? { url: s.evidence_url } : {}), ...(s.retrieved_at ? { retrievedAt: s.retrieved_at } : {}) }));
.map((s) => ({ service: 'sources', type: 'item', id: s.sources_item_id, ...(Number.isInteger(s.sources_item_rev) ? { revision: s.sources_item_rev } : {}), ...(s.evidence_url ? { url: s.evidence_url } : {}), ...(s.retrieved_at ? { retrievedAt: s.retrieved_at } : {}) }));
}
}
- function activeCount(merchantId) { return q.activeCount.get({ merchant: merchantId, now: store.now() }).n; }
+ async function activeCount(merchantId) { return (await q.activeCount.get({ merchant: merchantId, now: store.now() })).n; }
+ /** merchant id → active coupons, for many merchants in one query (missing = 0). */
+ async function activeCounts(merchantIds) {
+ const rows = merchantIds.length ? await q.activeCounts.all({ merchants: merchantIds, now: store.now() }) : [];
+ return new Map(rows.map((r) => [r.merchant_id, r.n]));
+ }
/** Send the Search documents of a code and its merchant (inside a transaction). */
/** Send the Search documents of a code and its merchant (inside a transaction). */
- function syncIndex(c, { traceparent } = {}) {
- const m = merchants.byId(c.merchant_id);
- publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });
- if (m) publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });
+ async function syncIndex(c, { traceparent } = {}) {
+ const m = await merchants.byId(c.merchant_id);
+ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });
+ if (m) await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });
}
}
- function syncMerchant(m, { traceparent } = {}) {
- publication.sendDocument(publication.merchantDocument(m, activeCount(m.id)), { traceparent });
- for (const { id } of q.ofMerchant.all(m.id)) {
- const c = get(id);
- publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(restrictionsOf(c.id)), sourceRefs: sourceRefs(c.id) }), { traceparent });
+ async function syncMerchant(m, { traceparent } = {}) {
+ await publication.sendDocument(publication.merchantDocument(m, await activeCount(m.id)), { traceparent });
+ for (const { id } of await q.ofMerchant.all(m.id)) {
+ const c = await get(id);
+ await publication.sendDocument(publication.couponDocument(c, m, { restrictionsText: restrictionsText(await restrictionsOf(c.id)), sourceRefs: await sourceRefs(c.id) }), { traceparent });
}
}
}
}
- function publiclyListed(c) {
- const m = merchants.byId(c.merchant_id);
+ async function publiclyListed(c) {
+ const m = await merchants.byId(c.merchant_id);
return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));
return Boolean(m && m.status === 'active' && confidence.isActive(c, store.now()));
}
}
@@ -247,51 +255,51 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -247,51 +255,51 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
* change in coupon_status_history, emit events and Search documents.
* change in coupon_status_history, emit events and Search documents.
* forceStatus: 'disabled' | 'expired' | 'unknown' (re-enable) for staff/service changes.
* forceStatus: 'disabled' | 'expired' | 'unknown' (re-enable) for staff/service changes.
*/
*/
- function recompute(id, { reason, actor = 'system', forceStatus = null, traceparent } = {}) {
- const before = get(id);
+ async function recompute(id, { reason, actor = 'system', forceStatus = null, traceparent } = {}) {
+ const before = await get(id);
if (!before) return null;
if (!before) return null;
const now = store.now();
const now = store.now();
let base = before;
let base = before;
if (forceStatus === 'disabled') base = { ...before, status: 'disabled' };
if (forceStatus === 'disabled') base = { ...before, status: 'disabled' };
else if (forceStatus === 'expired') base = { ...before, status: 'expired', expired_at: now };
else if (forceStatus === 'expired') base = { ...before, status: 'expired', expired_at: now };
else if (forceStatus === 'unknown') base = { ...before, status: 'unknown', expired_at: null, disabled_at: null };
else if (forceStatus === 'unknown') base = { ...before, status: 'unknown', expired_at: null, disabled_at: null };
- const reports = q.reportsFor.all(id, now - confidence.WINDOW_DAYS * DAY);
- const ev = confidence.evaluate(base, reports, { merchantEvidence: Boolean(q.merchantEvidence.get(id)), now });
+ const reports = await q.reportsFor.all(id, now - confidence.WINDOW_DAYS * DAY);
+ const ev = confidence.evaluate(base, reports, { merchantEvidence: Boolean(await q.merchantEvidence.get(id)), now });
const statusChanged = ev.status !== before.status;
const statusChanged = ev.status !== before.status;
const confChanged = ev.confidence !== before.confidence;
const confChanged = ev.confidence !== before.confidence;
if (!statusChanged && !confChanged && !forceStatus) return before;
if (!statusChanged && !confChanged && !forceStatus) return before;
let expiredAt = null;
let expiredAt = null;
if (ev.status === 'expired') expiredAt = before.status === 'expired' && before.expired_at ? before.expired_at : (before.expires_at != null && before.expires_at <= now ? before.expires_at : now);
if (ev.status === 'expired') expiredAt = before.status === 'expired' && before.expired_at ? before.expired_at : (before.expires_at != null && before.expires_at <= now ? before.expires_at : now);
- q.update.run({
+ await q.update.run({
id, status: ev.status, confidence: ev.confidence, now,
id, status: ev.status, confidence: ev.confidence, now,
status_reason: statusChanged || forceStatus ? reason : before.status_reason,
status_reason: statusChanged || forceStatus ? reason : before.status_reason,
expired_at: expiredAt,
expired_at: expiredAt,
disabled_at: ev.status === 'disabled' ? (before.disabled_at || now) : null,
disabled_at: ev.status === 'disabled' ? (before.disabled_at || now) : null,
});
});
- const after = get(id);
- if (statusChanged || confChanged) q.history.run(id, before.status, after.status, before.confidence, after.confidence, reason, actor, now);
+ const after = await get(id);
+ if (statusChanged || confChanged) await q.history.run(id, before.status, after.status, before.confidence, after.confidence, reason, actor, now);
if (statusChanged) {
if (statusChanged) {
const type = after.status === 'expired' ? 'coupons.coupon.expired' : after.status === 'disabled' ? 'coupons.coupon.disabled' : 'coupons.coupon.updated';
const type = after.status === 'expired' ? 'coupons.coupon.expired' : after.status === 'disabled' ? 'coupons.coupon.disabled' : 'coupons.coupon.updated';
- publication.emit(type, { type: 'coupon', id }, lifecyclePayload(after, { previous_status: before.status, reason: publicReason(reason) }), { isPublic: publiclyListed(after) || publiclyListed(before), traceparent });
+ await publication.emit(type, { type: 'coupon', id }, lifecyclePayload(after, { previous_status: before.status, reason: publicReason(reason) }), { isPublic: await publiclyListed(after) || await publiclyListed(before), traceparent });
}
}
if (confChanged) {
if (confChanged) {
- publication.emit('coupons.confidence.changed', { type: 'coupon', id }, { merchant_id: after.merchant_id, from: before.confidence, to: after.confidence, status: after.status }, { traceparent });
+ await publication.emit('coupons.confidence.changed', { type: 'coupon', id }, { merchant_id: after.merchant_id, from: before.confidence, to: after.confidence, status: after.status }, { traceparent });
}
}
- syncIndex(after, { traceparent });
+ await syncIndex(after, { traceparent });
return after;
return after;
}
}
- function addSource(couponId, merchant, { kind, evidence_url = null, sources_item_id = null, sources_item_rev = null, source_key = null, retrieved_at = null, ai_run_id = null, submitted_by = null }) {
- q.sourceInsert.run({
- coupon_id: couponId, kind, evidence_url, merchant_evidence: evidence_url && merchants.ownsUrl(merchant, evidence_url) ? 1 : 0,
+ async function addSource(couponId, merchant, { kind, evidence_url = null, sources_item_id = null, sources_item_rev = null, source_key = null, retrieved_at = null, ai_run_id = null, submitted_by = null }) {
+ await q.sourceInsert.run({
+ coupon_id: couponId, kind, evidence_url, merchant_evidence: evidence_url && await merchants.ownsUrl(merchant, evidence_url) ? 1 : 0,
sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, now: store.now(),
sources_item_id, sources_item_rev, source_key, retrieved_at, ai_run_id, submitted_by, now: store.now(),
});
});
}
}
- function checkSubmissionRate(actorId, limits) {
+ async function checkSubmissionRate(actorId, limits) {
const now = store.now();
const now = store.now();
- if (q.submittedSince.get(actorId, now - 60 * 60 * 1000).n >= limits.submissionsPerHour
- || q.submittedSince.get(actorId, now - DAY).n >= limits.submissionsPerDay) {
+ if ((await q.submittedSince.get(actorId, now - 60 * 60 * 1000)).n >= limits.submissionsPerHour
+ || (await q.submittedSince.get(actorId, now - DAY)).n >= limits.submissionsPerDay) {
throw new ApiError(429, 'submission.rate_limited', 'too many submissions; try again later');
throw new ApiError(429, 'submission.rate_limited', 'too many submissions; try again later');
}
}
}
}
@@ -302,25 +310,25 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -302,25 +310,25 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
* target: { merchant } (resolved by the caller)
* target: { merchant } (resolved by the caller)
* → { coupon, merchant, duplicate, created }
* → { coupon, merchant, duplicate, created }
*/
*/
- function submit(who, merchant, input, { limits, traceparent } = {}) {
- checkSubmissionRate(who.actor, limits);
- return store.tx(() => {
- const existing = q.byKey.get(merchant.id, input.code_key);
+ async function submit(who, merchant, input, { limits, traceparent } = {}) {
+ await checkSubmissionRate(who.actor, limits);
+ return await store.tx(async () => {
+ const existing = await q.byKey.get(merchant.id, input.code_key);
if (existing) {
if (existing) {
if (existing.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'this code was taken down and cannot be resubmitted');
if (existing.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'this code was taken down and cannot be resubmitted');
if (existing.status === 'expired' || (existing.expires_at != null && existing.expires_at <= store.now())) {
if (existing.status === 'expired' || (existing.expires_at != null && existing.expires_at <= store.now())) {
throw new ApiError(409, 'coupon.expired', 'this code is recorded as expired');
throw new ApiError(409, 'coupon.expired', 'this code is recorded as expired');
}
}
- if (!input.evidence_url || !q.sameEvidence.get(existing.id, input.evidence_url)) {
- addSource(existing.id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });
- recompute(existing.id, { reason: 'evidence', actor: 'system', traceparent });
+ if (!input.evidence_url || !await q.sameEvidence.get(existing.id, input.evidence_url)) {
+ await addSource(existing.id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });
+ await recompute(existing.id, { reason: 'evidence', actor: 'system', traceparent });
}
}
- return { coupon: get(existing.id), merchant, duplicate: true, created: false };
+ return { coupon: await get(existing.id), merchant, duplicate: true, created: false };
}
}
const now = store.now();
const now = store.now();
const id = `cpn_${ids.ulid(now)}`;
const id = `cpn_${ids.ulid(now)}`;
const review = who.kind === 'ai' || merchant.status !== 'active' ? 'pending' : 'published';
const review = who.kind === 'ai' || merchant.status !== 'active' ? 'pending' : 'published';
- q.insert.run({
+ await q.insert.run({
id, merchant_id: merchant.id, code: input.code, code_key: input.code_key, title: input.title, description: input.description,
id, merchant_id: merchant.id, code: input.code, code_key: input.code_key, title: input.title, description: input.description,
review_state: review, origin: who.kind,
review_state: review, origin: who.kind,
expires_at: input.expiry ? input.expiry.expires_at : null,
expires_at: input.expiry ? input.expiry.expires_at : null,
@@ -328,60 +336,60 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -328,60 +336,60 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
expiry_basis: input.expiry ? (who.kind === 'staff' && input.expiry_basis === 'submitter' ? 'staff' : input.expiry_basis) : null,
expiry_basis: input.expiry ? (who.kind === 'staff' && input.expiry_basis === 'submitter' ? 'staff' : input.expiry_basis) : null,
created_by: who.actor, now,
created_by: who.actor, now,
});
});
- for (const r of input.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });
- if (input.hint) q.hintInsert.run(merchant.id, id, input.hint, who.actor, now);
- addSource(id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });
- q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);
- const c = get(id);
- publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: publiclyListed(c), traceparent });
- syncIndex(c, { traceparent });
+ for (const r of input.restrictions) await q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });
+ if (input.hint) await q.hintInsert.run(merchant.id, id, input.hint, who.actor, now);
+ await addSource(id, merchant, { kind: who.kind, evidence_url: input.evidence_url, ai_run_id: input.ai_run_id, submitted_by: who.actor });
+ await q.history.run(id, null, 'unknown', null, null, 'created', who.actor, now);
+ const c = await get(id);
+ await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: c.origin }), { isPublic: await publiclyListed(c), traceparent });
+ await syncIndex(c, { traceparent });
return { coupon: c, merchant, duplicate: false, created: true };
return { coupon: c, merchant, duplicate: false, created: true };
});
});
}
}
/** Import one OpenVibe.Sources item as a code (inside the importer's transaction). */
/** Import one OpenVibe.Sources item as a code (inside the importer's transaction). */
- function importFromSource(merchant, item, parsed, { autoPublish }) {
- const existing = q.byKey.get(merchant.id, parsed.code_key);
+ async function importFromSource(merchant, item, parsed, { autoPublish }) {
+ const existing = await q.byKey.get(merchant.id, parsed.code_key);
const src = {
const src = {
kind: 'source', evidence_url: item.canonical_url || null, sources_item_id: item.id, sources_item_rev: item.revision,
kind: 'source', evidence_url: item.canonical_url || null, sources_item_id: item.id, sources_item_rev: item.revision,
source_key: item.source_key, retrieved_at: Date.parse(item.provenance && item.provenance.retrieved_at) || null, submitted_by: 'svc:sources',
source_key: item.source_key, retrieved_at: Date.parse(item.provenance && item.provenance.retrieved_at) || null, submitted_by: 'svc:sources',
};
};
if (existing) {
if (existing) {
- const known = q.sources.all(existing.id).find((s) => s.sources_item_id === item.id);
- if (known) db.prepare('UPDATE coupon_sources SET sources_item_rev = ?, retrieved_at = ?, removed_at = NULL, removed_reason = NULL WHERE id = ?').run(item.revision, src.retrieved_at, known.id);
- else addSource(existing.id, merchant, src);
+ const known = (await q.sources.all(existing.id)).find((s) => s.sources_item_id === item.id);
+ if (known) await db.prepare('UPDATE coupon_sources SET sources_item_rev = ?, retrieved_at = ?, removed_at = NULL, removed_reason = NULL WHERE id = ?').run(item.revision, src.retrieved_at, known.id);
+ else await addSource(existing.id, merchant, src);
if (existing.expires_at == null && parsed.expiry && existing.status !== 'expired') {
if (existing.expires_at == null && parsed.expiry && existing.status !== 'expired') {
- q.setExpiry.run(parsed.expiry.expires_at, parsed.expiry.expires_precision, 'source', store.now(), existing.id);
+ await q.setExpiry.run(parsed.expiry.expires_at, parsed.expiry.expires_precision, 'source', store.now(), existing.id);
}
}
- recompute(existing.id, { reason: 'evidence', actor: 'svc:sources' });
- return { coupon: get(existing.id), created: false };
+ await recompute(existing.id, { reason: 'evidence', actor: 'svc:sources' });
+ return { coupon: await get(existing.id), created: false };
}
}
const now = store.now();
const now = store.now();
const id = `cpn_${ids.ulid(now)}`;
const id = `cpn_${ids.ulid(now)}`;
- q.insert.run({
+ await q.insert.run({
id, merchant_id: merchant.id, code: parsed.code, code_key: parsed.code_key, title: parsed.title, description: parsed.description,
id, merchant_id: merchant.id, code: parsed.code, code_key: parsed.code_key, title: parsed.title, description: parsed.description,
review_state: autoPublish && merchant.status === 'active' ? 'published' : 'pending', origin: 'source',
review_state: autoPublish && merchant.status === 'active' ? 'published' : 'pending', origin: 'source',
expires_at: parsed.expiry ? parsed.expiry.expires_at : null,
expires_at: parsed.expiry ? parsed.expiry.expires_at : null,
expires_precision: parsed.expiry ? parsed.expiry.expires_precision : null,
expires_precision: parsed.expiry ? parsed.expiry.expires_precision : null,
expiry_basis: parsed.expiry ? 'source' : null, created_by: 'svc:sources', now,
expiry_basis: parsed.expiry ? 'source' : null, created_by: 'svc:sources', now,
});
});
- for (const r of parsed.restrictions) q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });
- addSource(id, merchant, src);
- q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);
- const c = get(id);
- publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: publiclyListed(c) });
- syncIndex(c);
+ for (const r of parsed.restrictions) await q.restrictionInsert.run({ coupon_id: id, kind: r.kind, value: r.value ?? null, amount_minor: r.amount_minor ?? null, currency: r.currency ?? null, now });
+ await addSource(id, merchant, src);
+ await q.history.run(id, null, 'unknown', null, null, 'created', 'svc:sources', now);
+ const c = await get(id);
+ await publication.emit('coupons.coupon.created', { type: 'coupon', id }, lifecyclePayload(c, { origin: 'source' }), { isPublic: await publiclyListed(c) });
+ await syncIndex(c);
return { coupon: c, created: true };
return { coupon: c, created: true };
}
}
/** A Sources item was removed: its evidence is withdrawn; a code with no other evidence is disabled. */
/** A Sources item was removed: its evidence is withdrawn; a code with no other evidence is disabled. */
- function withdrawSourceItem(itemId, reason) {
- const rows = db.prepare('SELECT * FROM coupon_sources WHERE sources_item_id = ? AND removed_at IS NULL').all(itemId);
+ async function withdrawSourceItem(itemId, reason) {
+ const rows = await db.prepare('SELECT * FROM coupon_sources WHERE sources_item_id = ? AND removed_at IS NULL').all(itemId);
for (const s of rows) {
for (const s of rows) {
- db.prepare('UPDATE coupon_sources SET removed_at = ?, removed_reason = ? WHERE id = ?').run(store.now(), String(reason || 'removed at the source').slice(0, 300), s.id);
- const remaining = db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE coupon_id = ? AND removed_at IS NULL').get(s.coupon_id).n;
- if (!remaining) recompute(s.coupon_id, { reason: 'source_removed', actor: 'svc:sources', forceStatus: 'disabled' });
- else recompute(s.coupon_id, { reason: 'evidence', actor: 'svc:sources' });
+ await db.prepare('UPDATE coupon_sources SET removed_at = ?, removed_reason = ? WHERE id = ?').run(store.now(), String(reason || 'removed at the source').slice(0, 300), s.id);
+ const remaining = (await db.prepare('SELECT COUNT(*) AS n FROM coupon_sources WHERE coupon_id = ? AND removed_at IS NULL').get(s.coupon_id)).n;
+ if (!remaining) await recompute(s.coupon_id, { reason: 'source_removed', actor: 'svc:sources', forceStatus: 'disabled' });
+ else await recompute(s.coupon_id, { reason: 'evidence', actor: 'svc:sources' });
}
}
return rows.length;
return rows.length;
}
}
@@ -390,93 +398,93 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -390,93 +398,93 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
* Staff/service status change: 'disabled' | 'expired' | 'active' (re-enable: recomputed from
* Staff/service status change: 'disabled' | 'expired' | 'active' (re-enable: recomputed from
* reports and time). reported_working / reported_failed cannot be set by anyone.
* reports and time). reported_working / reported_failed cannot be set by anyone.
*/
*/
- function setStatus(c, target, { actor, note = '', traceparent } = {}) {
+ async function setStatus(c, target, { actor, note = '', traceparent } = {}) {
const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;
const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;
const force = { disabled: 'disabled', expired: 'expired', active: 'unknown' }[target];
const force = { disabled: 'disabled', expired: 'expired', active: 'unknown' }[target];
if (!force) throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');
if (!force) throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');
if (target === 'expired' && c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');
if (target === 'expired' && c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');
if (target === 'active' && c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');
if (target === 'active' && c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');
- return store.tx(() => {
- const after = recompute(c.id, { reason: why, actor, forceStatus: force, traceparent });
+ return await store.tx(async () => {
+ const after = await recompute(c.id, { reason: why, actor, forceStatus: force, traceparent });
// Staff or a staff-capable service acting on someone else's code: the moderation audit log (ADR-022).
// Staff or a staff-capable service acting on someone else's code: the moderation audit log (ADR-022).
- if (after && after.status !== c.status && actor !== c.created_by) moderated(`coupon.${target === 'active' ? 'enabled' : target}`, c, actor, { reason: note ? text(note, 200) : null, details: { previous: c.status, status: after.status }, traceparent });
+ if (after && after.status !== c.status && actor !== c.created_by) await moderated(`coupon.${target === 'active' ? 'enabled' : target}`, c, actor, { reason: note ? text(note, 200) : null, details: { previous: c.status, status: after.status }, traceparent });
return after;
return after;
});
});
}
}
/** coupons.moderation.action for a staff or service action (never the submitter; see events/outbox.js). */
/** coupons.moderation.action for a staff or service action (never the submitter; see events/outbox.js). */
- function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {
+ async function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {
if (!outbox) return null;
if (!outbox) return null;
const person = /^usr_/.test(String(actor || '')) ? actor : null;
const person = /^usr_/.test(String(actor || '')) ? actor : null;
- return outbox.moderationAction({ action, target: { type: 'coupon', id: c.id }, actorSubject: person, reason, details: { merchant_id: c.merchant_id, origin: c.origin, ...details } }, { traceparent });
+ return await outbox.moderationAction({ action, target: { type: 'coupon', id: c.id }, actorSubject: person, reason, details: { merchant_id: c.merchant_id, origin: c.origin, ...details } }, { traceparent });
}
}
/** Staff: publish a code that waits for review. */
/** Staff: publish a code that waits for review. */
- function approve(c, { actor, audit = true, traceparent } = {}) {
- return store.tx(() => {
- if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);
- const after = get(c.id);
- q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());
- if (audit && actor !== c.created_by) moderated('coupon.approved', c, actor, { traceparent });
- publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: publiclyListed(after) });
- syncIndex(after);
+ async function approve(c, { actor, audit = true, traceparent } = {}) {
+ return await store.tx(async () => {
+ if ((await q.setReview.run(store.now(), c.id)).changes !== 1) return await get(c.id);
+ const after = await get(c.id);
+ await q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());
+ if (audit && actor !== c.created_by) await moderated('coupon.approved', c, actor, { traceparent });
+ await publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: await publiclyListed(after) });
+ await syncIndex(after);
return after;
return after;
});
});
}
}
/** Staff: set or clear a code's expiry (clear = back to unknown). */
/** Staff: set or clear a code's expiry (clear = back to unknown). */
- function setExpiry(c, value, { actor }) {
+ async function setExpiry(c, value, { actor }) {
const expiry = value ? parseExpiry(value, store.now()) : null;
const expiry = value ? parseExpiry(value, store.now()) : null;
- return store.tx(() => {
- q.setExpiry.run(expiry ? expiry.expires_at : null, expiry ? expiry.expires_precision : null, expiry ? 'staff' : null, store.now(), c.id);
- const updated = get(c.id);
- publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(updated, { reason: 'expiry_changed' }), { isPublic: publiclyListed(updated) });
- recompute(c.id, { reason: 'staff:expiry', actor });
- syncIndex(get(c.id));
- return get(c.id);
+ return await store.tx(async () => {
+ await q.setExpiry.run(expiry ? expiry.expires_at : null, expiry ? expiry.expires_precision : null, expiry ? 'staff' : null, store.now(), c.id);
+ const updated = await get(c.id);
+ await publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(updated, { reason: 'expiry_changed' }), { isPublic: await publiclyListed(updated) });
+ await recompute(c.id, { reason: 'staff:expiry', actor });
+ await syncIndex(await get(c.id));
+ return await get(c.id);
});
});
}
}
- function addMerchantHint(m, hint, { actor }) {
+ async function addMerchantHint(m, hint, { actor }) {
const t = text(hint, 300);
const t = text(hint, 300);
if (!t) throw new ApiError(422, 'hint.empty', 'hint text is required');
if (!t) throw new ApiError(422, 'hint.empty', 'hint text is required');
- q.hintInsert.run(m.id, null, t, actor, store.now());
+ await q.hintInsert.run(m.id, null, t, actor, store.now());
}
}
/**
/**
* The sweep: record expiries that have passed and let old reports decay. Idempotent.
* The sweep: record expiries that have passed and let old reports decay. Idempotent.
* → { expired: [ids], changed: [ids] }
* → { expired: [ids], changed: [ids] }
*/
*/
- function sweep() {
+ async function sweep() {
const now = store.now();
const now = store.now();
const expired = [];
const expired = [];
const changed = [];
const changed = [];
- for (const { id } of q.dueExpiry.all(now)) {
- const after = store.tx(() => recompute(id, { reason: 'expiry', actor: 'system' }));
+ for (const { id } of await q.dueExpiry.all(now)) {
+ const after = await store.tx(async () => await recompute(id, { reason: 'expiry', actor: 'system' }));
if (after && after.status === 'expired') expired.push(id);
if (after && after.status === 'expired') expired.push(id);
}
}
- for (const { id } of q.decaying.all(now - (confidence.WINDOW_DAYS + 1) * DAY)) {
- const before = get(id);
- const after = store.tx(() => recompute(id, { reason: 'decay', actor: 'system' }));
+ for (const { id } of await q.decaying.all(now - (confidence.WINDOW_DAYS + 1) * DAY)) {
+ const before = await get(id);
+ const after = await store.tx(async () => await recompute(id, { reason: 'decay', actor: 'system' }));
if (after && before && (after.status !== before.status || after.confidence !== before.confidence)) changed.push(id);
if (after && before && (after.status !== before.status || after.confidence !== before.confidence)) changed.push(id);
}
}
return { expired, changed };
return { expired, changed };
}
}
/** Counted reports in the window: { worked, failed } (aggregates only, never who). */
/** Counted reports in the window: { worked, failed } (aggregates only, never who). */
- function reportCounts(id) {
+ async function reportCounts(id) {
const out = { worked: 0, failed: 0 };
const out = { worked: 0, failed: 0 };
- for (const r of q.reportCounts.all(id, store.now() - confidence.WINDOW_DAYS * DAY)) out[r.outcome] = r.n;
+ for (const r of await q.reportCounts.all(id, store.now() - confidence.WINDOW_DAYS * DAY)) out[r.outcome] = r.n;
return out;
return out;
}
}
/**
/**
* The public view of a code. Never includes who submitted or reported it.
* The public view of a code. Never includes who submitted or reported it.
*/
*/
- function view(c, { merchant = null } = {}) {
+ async function view(c, { merchant = null } = {}) {
const now = store.now();
const now = store.now();
- const m = merchant || merchants.byId(c.merchant_id);
- const sources = q.sources.all(c.id).filter((s) => !s.removed_at);
+ const m = merchant || await merchants.byId(c.merchant_id);
+ const sources = (await q.sources.all(c.id)).filter((s) => !s.removed_at);
const iso = (t) => (t == null ? null : new Date(t).toISOString());
const iso = (t) => (t == null ? null : new Date(t).toISOString());
// Report times are published to the hour: enough for "last report 3 hours ago", too coarse
// Report times are published to the hour: enough for "last report 3 hours ago", too coarse
// to tie a report to the moment someone made it.
// to tie a report to the moment someone made it.
@@ -490,14 +498,14 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -490,14 +498,14 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
status: c.status,
status: c.status,
active: Boolean(m && m.status === 'active' && confidence.isActive(c, now)),
active: Boolean(m && m.status === 'active' && confidence.isActive(c, now)),
confidence: c.confidence,
confidence: c.confidence,
- reports: { ...reportCounts(c.id), window_days: confidence.WINDOW_DAYS, last_report_at: hour(c.last_report_at), last_worked_at: hour(c.last_worked_at), last_failed_at: hour(c.last_failed_at) },
+ reports: { ...await reportCounts(c.id), window_days: confidence.WINDOW_DAYS, last_report_at: hour(c.last_report_at), last_worked_at: hour(c.last_worked_at), last_failed_at: hour(c.last_failed_at) },
expiry: c.expires_at == null
expiry: c.expires_at == null
? { known: false, expires_at: null, precision: null, basis: null }
? { known: false, expires_at: null, precision: null, basis: null }
: { known: true, expires_at: iso(c.expires_at), precision: c.expires_precision, basis: c.expiry_basis },
: { known: true, expires_at: iso(c.expires_at), precision: c.expires_precision, basis: c.expiry_basis },
- restrictions: restrictionsOf(c.id).map((r) => (r.kind === 'min_spend'
+ restrictions: (await restrictionsOf(c.id)).map((r) => (r.kind === 'min_spend'
? { kind: r.kind, amount: formatMinor(r.amount_minor, r.currency), amount_minor: r.amount_minor, currency: r.currency }
? { kind: r.kind, amount: formatMinor(r.amount_minor, r.currency), amount_minor: r.amount_minor, currency: r.currency }
: r.kind === 'new_customers_only' ? { kind: r.kind } : { kind: r.kind, value: r.value })),
: r.kind === 'new_customers_only' ? { kind: r.kind } : { kind: r.kind, value: r.value })),
- hints: m ? q.hints.all(m.id, c.id).map((h) => ({ text: h.text, scope: h.coupon_id ? 'code' : 'merchant' })) : [],
+ hints: m ? (await q.hints.all(m.id, c.id)).map((h) => ({ text: h.text, scope: h.coupon_id ? 'code' : 'merchant' })) : [],
evidence: sources.map((s) => ({
evidence: sources.map((s) => ({
kind: s.kind, url: s.evidence_url, merchant_page: Boolean(s.merchant_evidence),
kind: s.kind, url: s.evidence_url, merchant_page: Boolean(s.merchant_evidence),
...(s.sources_item_id ? { sources_item: s.sources_item_id, retrieved_at: iso(s.retrieved_at) } : {}),
...(s.sources_item_id ? { sources_item: s.sources_item_id, retrieved_at: iso(s.retrieved_at) } : {}),
@@ -513,24 +521,24 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -513,24 +521,24 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
return {
return {
ACTIVE_SQL, parseSubmission, parseExpiry, parseRestrictions, formatMinor, restrictionsText,
ACTIVE_SQL, parseSubmission, parseExpiry, parseRestrictions, formatMinor, restrictionsText,
get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,
get, submit, checkSubmissionRate, importFromSource, withdrawSourceItem, recompute, setStatus, approve, setExpiry, addMerchantHint, sweep, view,
- restrictionsOf, reportCounts, activeCount, syncIndex, syncMerchant,
- history: (id) => q.historyFor.all(id),
- active: (merchantId) => q.active.all({ merchant: merchantId, now: store.now() }),
- recentlyEnded: (merchantId) => q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),
- recentActive: (limit = 30) => q.recentActive.all({ now: store.now(), limit }),
- allActive: () => q.allActive.all({ now: store.now() }),
- pendingReview: () => q.pendingReview.all(),
- pendingOfMerchant: (merchantId) => q.byMerchantPending.all(merchantId).map((r) => r.id),
+ restrictionsOf, reportCounts, activeCount, activeCounts, syncIndex, syncMerchant,
+ history: async (id) => await q.historyFor.all(id),
+ active: async (merchantId) => await q.active.all({ merchant: merchantId, now: store.now() }),
+ recentlyEnded: async (merchantId) => await q.recentlyEnded.all({ merchant: merchantId, now: store.now() }),
+ recentActive: async (limit = 30) => await q.recentActive.all({ now: store.now(), limit }),
+ allActive: async () => await q.allActive.all({ now: store.now() }),
+ pendingReview: async () => await q.pendingReview.all(),
+ pendingOfMerchant: async (merchantId) => (await q.byMerchantPending.all(merchantId)).map((r) => r.id),
/** Publish the codes that waited only because their merchant was pending (not AI or source codes). */
/** Publish the codes that waited only because their merchant was pending (not AI or source codes). */
- publishWaitingOn(merchant, { actor }) {
+ async publishWaitingOn(merchant, { actor }) {
const out = [];
const out = [];
- for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {
- const c = get(id);
- if (c.origin === 'member' || c.origin === 'staff') out.push(approve(c, { actor, audit: false })); // the shop's approval is the audited action
+ for (const id of (await q.byMerchantPending.all(merchant.id)).map((r) => r.id)) {
+ const c = await get(id);
+ if (c.origin === 'member' || c.origin === 'staff') out.push(await approve(c, { actor, audit: false })); // the shop's approval is the audited action
}
}
return out;
return out;
},
},
- merchantHints: (merchantId) => q.merchantHints.all(merchantId),
+ merchantHints: async (merchantId) => await q.merchantHints.all(merchantId),
};
};
}
}
@@ -53,14 +53,14 @@ function createInstalls({ store, config }) {
@@ -53,14 +53,14 @@ function createInstalls({ store, config }) {
view,
view,
/** → { install (view), token } — the token exists only in this return value. */
/** → { install (view), token } — the token exists only in this return value. */
- create(subject, { label, scopes } = {}) {
+ async create(subject, { label, scopes } = {}) {
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'only a signed-in person can connect an extension');
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'only a signed-in person can connect an extension');
const name = String(label == null ? '' : label).trim().slice(0, 60) || 'Browser helper';
const name = String(label == null ? '' : label).trim().slice(0, 60) || 'Browser helper';
const wanted = new Set(Array.isArray(scopes) ? scopes : scopes ? [scopes] : []);
const wanted = new Set(Array.isArray(scopes) ? scopes : scopes ? [scopes] : []);
for (const s of wanted) if (!ALL_SCOPES.includes(s)) throw new ApiError(422, 'install.bad_scope', `unknown scope ${s}`);
for (const s of wanted) if (!ALL_SCOPES.includes(s)) throw new ApiError(422, 'install.bad_scope', `unknown scope ${s}`);
wanted.add(SCOPES.LOOKUP);
wanted.add(SCOPES.LOOKUP);
const now = store.now();
const now = store.now();
- if (q.activeCount.get(subject, now).n >= config.limits.installsPerSubject) {
+ if ((await q.activeCount.get(subject, now)).n >= config.limits.installsPerSubject) {
throw new ApiError(409, 'install.limit', `at most ${config.limits.installsPerSubject} connected extensions; revoke one first`);
throw new ApiError(409, 'install.limit', `at most ${config.limits.installsPerSubject} connected extensions; revoke one first`);
}
}
const token = `cpx_${crypto.randomBytes(32).toString('base64url')}`;
const token = `cpx_${crypto.randomBytes(32).toString('base64url')}`;
@@ -69,27 +69,27 @@ function createInstalls({ store, config }) {
@@ -69,27 +69,27 @@ function createInstalls({ store, config }) {
label: name, scopes: JSON.stringify(ALL_SCOPES.filter((s) => wanted.has(s))),
label: name, scopes: JSON.stringify(ALL_SCOPES.filter((s) => wanted.has(s))),
created_at: now, expires_at: now + config.installTtlDays * DAY,
created_at: now, expires_at: now + config.installTtlDays * DAY,
};
};
- q.insert.run(row);
- return { install: view(q.byId.get(row.id)), token };
+ await q.insert.run(row);
+ return { install: view(await q.byId.get(row.id)), token };
},
},
- list(subject) { return q.mine.all(subject).map(view); },
+ async list(subject) { return (await q.mine.all(subject)).map(view); },
/** Revoke one of `subject`'s installs. → true when it was active. */
/** Revoke one of `subject`'s installs. → true when it was active. */
- revoke(subject, id) { return q.revoke.run(store.now(), String(id), subject).changes === 1; },
+ async revoke(subject, id) { return (await q.revoke.run(store.now(), String(id), subject)).changes === 1; },
/**
/**
* Verify a presented token. → { install, subject, scopes } or throws 401 (malformed, unknown,
* Verify a presented token. → { install, subject, scopes } or throws 401 (malformed, unknown,
* revoked, expired). Reads the row every time: revocation takes effect on the next request.
* revoked, expired). Reads the row every time: revocation takes effect on the next request.
*/
*/
- verify(token) {
+ async verify(token) {
if (!TOKEN_RE.test(String(token))) throw new ApiError(401, 'token.invalid', 'malformed extension token');
if (!TOKEN_RE.test(String(token))) throw new ApiError(401, 'token.invalid', 'malformed extension token');
- const row = q.byHash.get(hashToken(token));
+ const row = await q.byHash.get(hashToken(token));
if (!row) throw new ApiError(401, 'token.invalid', 'unknown extension token');
if (!row) throw new ApiError(401, 'token.invalid', 'unknown extension token');
const now = store.now();
const now = store.now();
if (row.revoked_at) throw new ApiError(401, 'token.revoked', 'this extension was disconnected; connect it again on openvibe.coupons');
if (row.revoked_at) throw new ApiError(401, 'token.revoked', 'this extension was disconnected; connect it again on openvibe.coupons');
if (row.expires_at <= now) throw new ApiError(401, 'token.expired', 'this extension token expired; connect it again on openvibe.coupons');
if (row.expires_at <= now) throw new ApiError(401, 'token.expired', 'this extension token expired; connect it again on openvibe.coupons');
- q.touch.run(now, row.id, now - 60 * 1000);
+ await q.touch.run(now, row.id, now - 60 * 1000);
return { install: row.id, subject: row.subject, scopes: JSON.parse(row.scopes) };
return { install: row.id, subject: row.subject, scopes: JSON.parse(row.scopes) };
},
},
};
};
@@ -37,43 +37,44 @@ function createMerchants({ store }) {
@@ -37,43 +37,44 @@ function createMerchants({ store }) {
domainDelete: db.prepare('DELETE FROM coupon_merchant_domains WHERE id = ? AND merchant_id = ?'),
domainDelete: db.prepare('DELETE FROM coupon_merchant_domains WHERE id = ? AND merchant_id = ?'),
rulesFor: db.prepare(`SELECT d.*, m.status AS merchant_status FROM coupon_merchant_domains d JOIN coupon_merchants m ON m.id = d.merchant_id
rulesFor: db.prepare(`SELECT d.*, m.status AS merchant_status FROM coupon_merchant_domains d JOIN coupon_merchants m ON m.id = d.merchant_id
WHERE d.registrable_domain = ?`),
WHERE d.registrable_domain = ?`),
- listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY name COLLATE NOCASE LIMIT ? OFFSET ?`),
+ listActive: db.prepare(`SELECT * FROM coupon_merchants WHERE status = 'active' ORDER BY lower(name) LIMIT ? OFFSET ?`),
countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),
countActive: db.prepare("SELECT COUNT(*) AS n FROM coupon_merchants WHERE status = 'active'"),
- search: db.prepare(`SELECT DISTINCT m.* FROM coupon_merchants m LEFT JOIN coupon_merchant_domains d ON d.merchant_id = m.id
- WHERE m.status = 'active' AND (m.name LIKE @q ESCAPE '\\' OR d.host LIKE @q ESCAPE '\\')
- ORDER BY m.name COLLATE NOCASE LIMIT 50`),
+ search: db.prepare(`SELECT m.* FROM coupon_merchants m
+ WHERE m.status = 'active' AND (m.name ILIKE @q ESCAPE '\\'
+ OR EXISTS (SELECT 1 FROM coupon_merchant_domains d WHERE d.merchant_id = m.id AND d.host ILIKE @q ESCAPE '\\'))
+ ORDER BY lower(m.name) LIMIT 50`),
pending: db.prepare("SELECT * FROM coupon_merchants WHERE status = 'pending' ORDER BY created_at LIMIT 200"),
pending: db.prepare("SELECT * FROM coupon_merchants WHERE status = 'pending' ORDER BY created_at LIMIT 200"),
};
};
- function get(idOrSlug) {
+ async function get(idOrSlug) {
const s = String(idOrSlug || '');
const s = String(idOrSlug || '');
- return s.startsWith('mer_') ? q.byId.get(s) : q.bySlug.get(s);
+ return s.startsWith('mer_') ? await q.byId.get(s) : await q.bySlug.get(s);
}
}
- function uniqueSlug(base) {
+ async function uniqueSlug(base) {
let slug = SLUG_RE.test(base) ? base : slugify(base) || 'merchant';
let slug = SLUG_RE.test(base) ? base : slugify(base) || 'merchant';
- if (!q.bySlug.get(slug)) return slug;
- for (let i = 2; i < 1000; i++) { const s = `${slug.slice(0, 58)}-${i}`; if (!q.bySlug.get(s)) return s; }
+ if (!await q.bySlug.get(slug)) return slug;
+ for (let i = 2; i < 1000; i++) { const s = `${slug.slice(0, 58)}-${i}`; if (!await q.bySlug.get(s)) return s; }
throw new ApiError(409, 'merchant.slug_taken', 'could not find a free slug');
throw new ApiError(409, 'merchant.slug_taken', 'could not find a free slug');
}
}
/** Add one domain rule (inside the caller's transaction). */
/** Add one domain rule (inside the caller's transaction). */
- function addDomain(merchant, rule, actor) {
+ async function addDomain(merchant, rule, actor) {
const r = hosts.checkRule(rule);
const r = hosts.checkRule(rule);
- const clash = q.domainByKey.get(r.host, r.path_prefix);
+ const clash = await q.domainByKey.get(r.host, r.path_prefix);
if (clash) {
if (clash) {
if (clash.merchant_id === merchant.id) return clash;
if (clash.merchant_id === merchant.id) return clash;
throw new ApiError(409, 'merchant.domain_taken', `${r.host}${r.path_prefix} already belongs to another merchant`);
throw new ApiError(409, 'merchant.domain_taken', `${r.host}${r.path_prefix} already belongs to another merchant`);
}
}
- q.domainInsert.run({ merchant_id: merchant.id, ...r, created_by: actor, now: store.now() });
- return q.domainByKey.get(r.host, r.path_prefix);
+ await q.domainInsert.run({ merchant_id: merchant.id, ...r, created_by: actor, now: store.now() });
+ return await q.domainByKey.get(r.host, r.path_prefix);
}
}
/**
/**
* Create a merchant with at least one domain rule.
* Create a merchant with at least one domain rule.
* input: { name, slug?, homepage_url?, description?, domains: [{ host, include_subdomains?, path_prefix? }] }
* input: { name, slug?, homepage_url?, description?, domains: [{ host, include_subdomains?, path_prefix? }] }
*/
*/
- function create(input, { status = 'active', actor }) {
+ async function create(input, { status = 'active', actor }) {
const domains = Array.isArray(input.domains) ? input.domains : [];
const domains = Array.isArray(input.domains) ? input.domains : [];
if (!domains.length) throw new ApiError(422, 'merchant.domain_required', 'a merchant needs at least one domain');
if (!domains.length) throw new ApiError(422, 'merchant.domain_required', 'a merchant needs at least one domain');
const first = hosts.checkRule(domains[0]);
const first = hosts.checkRule(domains[0]);
@@ -86,16 +87,16 @@ function createMerchants({ store }) {
@@ -86,16 +87,16 @@ function createMerchants({ store }) {
} else {
} else {
homepage = `https://${first.host}/`;
homepage = `https://${first.host}/`;
}
}
- return store.tx(() => {
+ return await store.tx(async () => {
const id = `mer_${ids.ulid(store.now())}`;
const id = `mer_${ids.ulid(store.now())}`;
- const slug = uniqueSlug(input.slug ? String(input.slug) : slugify(name));
- q.insert.run({
+ const slug = await uniqueSlug(input.slug ? String(input.slug) : slugify(name));
+ await q.insert.run({
id, slug, name, homepage_url: homepage,
id, slug, name, homepage_url: homepage,
description: input.description ? String(input.description).trim().slice(0, 1000) : null,
description: input.description ? String(input.description).trim().slice(0, 1000) : null,
status, created_by: actor, now: store.now(),
status, created_by: actor, now: store.now(),
});
});
- const m = q.byId.get(id);
- for (const d of domains) addDomain(m, d, actor);
+ const m = await q.byId.get(id);
+ for (const d of domains) await addDomain(m, d, actor);
return m;
return m;
});
});
}
}
@@ -104,46 +105,46 @@ function createMerchants({ store }) {
@@ -104,46 +105,46 @@ function createMerchants({ store }) {
* The merchant for a host (and optionally a path). `includeStatuses` limits which merchants count
* The merchant for a host (and optionally a path). `includeStatuses` limits which merchants count
* (default: active only). → { merchant, rule, host, registrable } or null.
* (default: active only). → { merchant, rule, host, registrable } or null.
*/
*/
- function resolve(rawHost, { path = null, includeStatuses = ['active'] } = {}) {
+ async function resolve(rawHost, { path = null, includeStatuses = ['active'] } = {}) {
const host = hosts.normalizeHost(rawHost);
const host = hosts.normalizeHost(rawHost);
const reg = hosts.registrable(host);
const reg = hosts.registrable(host);
if (!reg) return null;
if (!reg) return null;
- const rules = q.rulesFor.all(reg).filter((r) => includeStatuses.includes(r.merchant_status));
+ const rules = (await q.rulesFor.all(reg)).filter((r) => includeStatuses.includes(r.merchant_status));
const rule = hosts.bestRule(rules, host, path);
const rule = hosts.bestRule(rules, host, path);
if (!rule) return null;
if (!rule) return null;
- return { merchant: q.byId.get(rule.merchant_id), rule, host, registrable: reg };
+ return { merchant: await q.byId.get(rule.merchant_id), rule, host, registrable: reg };
}
}
/** Is `url` on one of the merchant's own domains (merchant evidence)? */
/** Is `url` on one of the merchant's own domains (merchant evidence)? */
- function ownsUrl(merchant, url) {
+ async function ownsUrl(merchant, url) {
const h = hosts.hostOfUrl(url);
const h = hosts.hostOfUrl(url);
if (!h) return false;
if (!h) return false;
- const rules = q.domains.all(merchant.id);
+ const rules = await q.domains.all(merchant.id);
return Boolean(hosts.bestRule(rules, h.host, h.path));
return Boolean(hosts.bestRule(rules, h.host, h.path));
}
}
return {
return {
get,
get,
- byId: (id) => q.byId.get(id),
- domains: (m) => q.domains.all(m.id),
+ byId: async (id) => await q.byId.get(id),
+ domains: async (m) => await q.domains.all(m.id),
create,
create,
- addDomain: (m, rule, actor) => store.tx(() => addDomain(m, rule, actor)),
- removeDomain: (m, domainId) => q.domainDelete.run(Number(domainId), m.id).changes === 1,
- setStatus(m, status) {
+ addDomain: async (m, rule, actor) => await store.tx(async () => await addDomain(m, rule, actor)),
+ removeDomain: async (m, domainId) => (await q.domainDelete.run(Number(domainId), m.id)).changes === 1,
+ async setStatus(m, status) {
if (!['pending', 'active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be pending, active or disabled');
if (!['pending', 'active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be pending, active or disabled');
- q.setStatus.run(status, store.now(), m.id);
- return q.byId.get(m.id);
+ await q.setStatus.run(status, store.now(), m.id);
+ return await q.byId.get(m.id);
},
},
resolve,
resolve,
ownsUrl,
ownsUrl,
- listActive: ({ limit = 100, offset = 0 } = {}) => q.listActive.all(limit, offset),
- countActive: () => q.countActive.get().n,
- search(text) {
+ listActive: async ({ limit = 100, offset = 0 } = {}) => await q.listActive.all(limit, offset),
+ countActive: async () => (await q.countActive.get()).n,
+ async search(text) {
const t = String(text || '').trim().toLowerCase().slice(0, 60);
const t = String(text || '').trim().toLowerCase().slice(0, 60);
if (!t) return [];
if (!t) return [];
- return q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });
+ return await q.search.all({ q: `%${t.replace(/[\\%_]/g, (c) => `\\${c}`)}%` });
},
},
- pending: () => q.pending.all(),
+ pending: async () => await q.pending.all(),
slugify,
slugify,
};
};
}
}
@@ -15,16 +15,16 @@ const { ApiError } = require('../http/errors');
@@ -15,16 +15,16 @@ const { ApiError } = require('../http/errors');
function createModeration({ store, merchants, coupons, outbox = null }) {
function createModeration({ store, merchants, coupons, outbox = null }) {
return {
return {
- setMerchantStatus(m, status, { actor = 'system', traceparent } = {}) {
+ async setMerchantStatus(m, status, { actor = 'system', traceparent } = {}) {
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
- return store.tx(() => {
- const after = merchants.setStatus(m, status);
- const published = status === 'active' ? coupons.publishWaitingOn(after, { actor }) : [];
- coupons.syncMerchant(after);
+ return await store.tx(async () => {
+ const after = await merchants.setStatus(m, status);
+ const published = status === 'active' ? await coupons.publishWaitingOn(after, { actor }) : [];
+ await coupons.syncMerchant(after);
// The moderation audit log (ADR-022): who approved, disabled or re-enabled the shop, never who proposed it.
// The moderation audit log (ADR-022): who approved, disabled or re-enabled the shop, never who proposed it.
if (outbox && m.status !== status && actor !== 'system') {
if (outbox && m.status !== status && actor !== 'system') {
const action = status === 'disabled' ? 'merchant.disabled' : (m.status === 'pending' ? 'merchant.approved' : 'merchant.enabled');
const action = status === 'disabled' ? 'merchant.disabled' : (m.status === 'pending' ? 'merchant.approved' : 'merchant.enabled');
- outbox.moderationAction({
+ await outbox.moderationAction({
action, target: { type: 'merchant', id: m.id }, actorSubject: /^usr_/.test(String(actor)) ? actor : null,
action, target: { type: 'merchant', id: m.id }, actorSubject: /^usr_/.test(String(actor)) ? actor : null,
details: { previous: m.status, status, ...(published.length ? { codes_published: published.length } : {}) },
details: { previous: m.status, status, ...(published.length ? { codes_published: published.length } : {}) },
}, { traceparent });
}, { traceparent });
@@ -91,20 +91,20 @@ function createPublication({ store, config, outbox }) {
@@ -91,20 +91,20 @@ function createPublication({ store, config, outbox }) {
}
}
/** Stamp and enqueue a Search document (inside the caller's transaction). */
/** Stamp and enqueue a Search document (inside the caller's transaction). */
- function sendDocument(doc, { traceparent } = {}) {
- if (doc.deleted && store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed
- const current = store.sequencer.current(doc.owner, doc.type, doc.id);
- const stamped = store.sequencer.stamp(doc);
+ async function sendDocument(doc, { traceparent } = {}) {
+ if (doc.deleted && await store.sequencer.current(doc.owner, doc.type, doc.id) == null) return null; // never indexed
+ const current = await store.sequencer.current(doc.owner, doc.type, doc.id);
+ const stamped = await store.sequencer.stamp(store.db, doc);
if (current === stamped.revision) return null; // the same document again: nothing to send
if (current === stamped.revision) return null; // the same document again: nothing to send
- return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
}
}
/**
/**
* A product event (inside the caller's transaction). Public only while the code is publicly
* A product event (inside the caller's transaction). Public only while the code is publicly
* listed; the actor is always the service and the payload never names a person.
* listed; the actor is always the service and the payload never names a person.
*/
*/
- function emit(eventType, subject, payload, { isPublic = false, traceparent } = {}) {
- return outbox.emit({
+ async function emit(eventType, subject, payload, { isPublic = false, traceparent } = {}) {
+ return await outbox.emit({
event_type: eventType,
event_type: eventType,
actor: ACTOR,
actor: ACTOR,
visibility: isPublic ? 'public' : 'internal',
visibility: isPublic ? 'public' : 'internal',
@@ -58,44 +58,44 @@ function createReports({ store, config, coupons, merchants, publication }) {
@@ -58,44 +58,44 @@ function createReports({ store, config, coupons, merchants, publication }) {
/**
/**
* → { accepted, deduplicated, corrected, coupon (public view) }
* → { accepted, deduplicated, corrected, coupon (public view) }
*/
*/
- function report(viewer, couponId, body, { channel = 'api', traceparent } = {}) {
+ async function report(viewer, couponId, body, { channel = 'api', traceparent } = {}) {
const outcome = body && body.outcome;
const outcome = body && body.outcome;
if (!OUTCOMES.includes(outcome)) throw new ApiError(422, 'report.bad_outcome', 'outcome must be "worked" or "failed"');
if (!OUTCOMES.includes(outcome)) throw new ApiError(422, 'report.bad_outcome', 'outcome must be "worked" or "failed"');
const reason = body.reason == null || body.reason === '' ? null : body.reason;
const reason = body.reason == null || body.reason === '' ? null : body.reason;
if (reason !== null && !REASONS.includes(reason)) throw new ApiError(422, 'report.bad_reason', `reason must be one of ${REASONS.join(', ')}`);
if (reason !== null && !REASONS.includes(reason)) throw new ApiError(422, 'report.bad_reason', `reason must be one of ${REASONS.join(', ')}`);
if (outcome === 'worked' && reason) throw new ApiError(422, 'report.bad_reason', 'a reason applies to failed reports only');
if (outcome === 'worked' && reason) throw new ApiError(422, 'report.bad_reason', 'a reason applies to failed reports only');
const who = reporterOf(viewer, channel);
const who = reporterOf(viewer, channel);
- const c = coupons.get(couponId);
+ const c = await coupons.get(couponId);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
- const m = merchants.byId(c.merchant_id);
+ const m = await merchants.byId(c.merchant_id);
const now = store.now();
const now = store.now();
const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);
const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);
if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');
if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');
// The submitter is not a reporter: otherwise one account could submit a made-up code and
// The submitter is not a reporter: otherwise one account could submit a made-up code and
// make it "reported working" with its own report.
// make it "reported working" with its own report.
- if (c.created_by === who.subject || q.submittedBy.get(c.id, who.subject)) {
+ if (c.created_by === who.subject || await q.submittedBy.get(c.id, who.subject)) {
throw new ApiError(403, 'report.own_submission', 'you submitted this code; its status comes from other people\'s reports');
throw new ApiError(403, 'report.own_submission', 'you submitted this code; its status comes from other people\'s reports');
}
}
const key = reporterKey(who.subject);
const key = reporterKey(who.subject);
const day = new Date(now).toISOString().slice(0, 10);
const day = new Date(now).toISOString().slice(0, 10);
- return store.tx(() => {
- const existing = q.find.get(c.id, key, day);
+ return await store.tx(async () => {
+ const existing = await q.find.get(c.id, key, day);
if (existing && existing.outcome === outcome && existing.reason === reason) {
if (existing && existing.outcome === outcome && existing.reason === reason) {
- return { accepted: true, deduplicated: true, corrected: false, coupon: coupons.view(coupons.get(c.id), { merchant: m }) };
+ return { accepted: true, deduplicated: true, corrected: false, coupon: await coupons.view(await coupons.get(c.id), { merchant: m }) };
}
}
if (!existing) {
if (!existing) {
- if (q.since.get(key, now - 60 * 60 * 1000).n >= config.limits.reportsPerHour || q.since.get(key, now - 24 * 60 * 60 * 1000).n >= config.limits.reportsPerDay) {
+ if ((await q.since.get(key, now - 60 * 60 * 1000)).n >= config.limits.reportsPerHour || (await q.since.get(key, now - 24 * 60 * 60 * 1000)).n >= config.limits.reportsPerDay) {
throw new ApiError(429, 'report.rate_limited', 'too many reports; try again later');
throw new ApiError(429, 'report.rate_limited', 'too many reports; try again later');
}
}
- q.insert.run({ coupon_id: c.id, reporter_key: key, channel: who.channel, install_id: who.install, day, outcome, reason, now });
- publication.emit('coupons.report.created', { type: 'coupon', id: c.id }, { merchant_id: c.merchant_id, outcome, ...(reason && /^[a-z][a-z0-9_]{0,39}$/.test(reason) ? { reason } : {}), channel: who.channel, day }, { traceparent });
+ await q.insert.run({ coupon_id: c.id, reporter_key: key, channel: who.channel, install_id: who.install, day, outcome, reason, now });
+ await publication.emit('coupons.report.created', { type: 'coupon', id: c.id }, { merchant_id: c.merchant_id, outcome, ...(reason && /^[a-z][a-z0-9_]{0,39}$/.test(reason) ? { reason } : {}), channel: who.channel, day }, { traceparent });
} else {
} else {
- q.correct.run({ id: existing.id, outcome, reason, channel: who.channel, install_id: who.install, now });
+ await q.correct.run({ id: existing.id, outcome, reason, channel: who.channel, install_id: who.install, now });
}
}
- q.stamp.run({ id: c.id, now, outcome });
- coupons.recompute(c.id, { reason: 'report', actor: 'system', traceparent });
- return { accepted: true, deduplicated: Boolean(existing), corrected: Boolean(existing), coupon: coupons.view(coupons.get(c.id), { merchant: m }) };
+ await q.stamp.run({ id: c.id, now, outcome });
+ await coupons.recompute(c.id, { reason: 'report', actor: 'system', traceparent });
+ return { accepted: true, deduplicated: Boolean(existing), corrected: Boolean(existing), coupon: await coupons.view(await coupons.get(c.id), { merchant: m }) };
});
});
}
}
@@ -16,26 +16,26 @@ const MAX_WATCHES = 200;
@@ -16,26 +16,26 @@ const MAX_WATCHES = 200;
function createWatches({ store }) {
function createWatches({ store }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
- add: db.prepare('INSERT OR IGNORE INTO coupon_watches (subject, merchant_id, created_at) VALUES (?, ?, ?)'),
+ add: db.prepare('INSERT INTO coupon_watches (subject, merchant_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING'),
remove: db.prepare('DELETE FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),
remove: db.prepare('DELETE FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),
has: db.prepare('SELECT 1 FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),
has: db.prepare('SELECT 1 FROM coupon_watches WHERE subject = ? AND merchant_id = ?'),
count: db.prepare('SELECT COUNT(*) AS n FROM coupon_watches WHERE subject = ?'),
count: db.prepare('SELECT COUNT(*) AS n FROM coupon_watches WHERE subject = ?'),
mine: db.prepare(`SELECT m.* FROM coupon_watches w JOIN coupon_merchants m ON m.id = w.merchant_id
mine: db.prepare(`SELECT m.* FROM coupon_watches w JOIN coupon_merchants m ON m.id = w.merchant_id
- WHERE w.subject = ? AND m.status = 'active' ORDER BY m.name COLLATE NOCASE`),
+ WHERE w.subject = ? AND m.status = 'active' ORDER BY lower(m.name)`),
};
};
const person = (subject) => {
const person = (subject) => {
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'sign in to watch a merchant');
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'auth.person_required', 'sign in to watch a merchant');
return subject;
return subject;
};
};
return {
return {
- watch(subject, merchant) {
+ async watch(subject, merchant) {
person(subject);
person(subject);
- if (!q.has.get(subject, merchant.id) && q.count.get(subject).n >= MAX_WATCHES) throw new ApiError(409, 'watch.limit', `at most ${MAX_WATCHES} watches`);
- q.add.run(subject, merchant.id, store.now());
+ if (!await q.has.get(subject, merchant.id) && (await q.count.get(subject)).n >= MAX_WATCHES) throw new ApiError(409, 'watch.limit', `at most ${MAX_WATCHES} watches`);
+ await q.add.run(subject, merchant.id, store.now());
},
},
- unwatch(subject, merchant) { q.remove.run(person(subject), merchant.id); },
- isWatching: (subject, merchant) => Boolean(subject && q.has.get(subject, merchant.id)),
- list: (subject) => q.mine.all(person(subject)),
+ async unwatch(subject, merchant) { await q.remove.run(person(subject), merchant.id); },
+ isWatching: async (subject, merchant) => Boolean(subject && await q.has.get(subject, merchant.id)),
+ list: async (subject) => await q.mine.all(person(subject)),
};
};
}
}
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
*/
*/
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@ -45,7 +45,9 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -45,7 +45,9 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
}
}
const events = createEventsClient(createClient(clientOpts), { source: 'coupons' });
const events = createEventsClient(createClient(clientOpts), { source: 'coupons' });
let lastError = null;
let lastError = null;
- const outbox = createOutbox(db, {
+ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient
+ // transaction); several processes relay one table safely (leases).
+ const outbox = createPgOutbox(db, {
events,
events,
intervalMs: config.events.intervalMs,
intervalMs: config.events.intervalMs,
now,
now,
@@ -55,20 +57,19 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -55,20 +57,19 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
lastError = msg;
lastError = msg;
},
},
});
});
- outbox.ensureSchema();
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
- function emit(envelope, { traceparent } = {}) {
- return outbox.enqueue(envelope, { traceparent });
+ async function emit(envelope, { traceparent } = {}) {
+ return await outbox.enqueue(db, envelope, { traceparent });
}
}
/**
/**
* coupons.moderation.action, inside the caller's transaction. actorSubject: the staff member (null
* coupons.moderation.action, inside the caller's transaction. actorSubject: the staff member (null
* for a service); target: { type, id }, owner_subject always null. Never the code or its text.
* for a service); target: { type, id }, owner_subject always null. Never the code or its text.
*/
*/
- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
+ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: null };
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: null };
- return emit({
+ return await emit({
event_type: 'coupons.moderation.action',
event_type: 'coupons.moderation.action',
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'coupons' },
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'coupons' },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
@@ -85,7 +86,7 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -85,7 +86,7 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
stop: () => outbox.stop(),
stop: () => outbox.stop(),
kick() { if (enabled) outbox.kick(); },
kick() { if (enabled) outbox.kick(); },
- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
};
};
}
}
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, revoking a browser helper
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, revoking a browser helper
* (a person must always be able to cut one off), and the pages and feeds people read.
* (a person must always be able to cut one off), and the pages and feeds people read.
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const FIRST_PARTY = /^svc:/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
@@ -79,7 +79,7 @@ const SERVICE_SUBMIT = { minute: 120, hour: 600 };
@@ -79,7 +79,7 @@ const SERVICE_SUBMIT = { minute: 120, hour: 600 };
* first-party service reading for itself not counted), limits.budget(name) (one of BUDGETS) and
* first-party service reading for itself not counted), limits.budget(name) (one of BUDGETS) and
* limits.submit (the submission budget: a person's, or a service's).
* limits.submit (the submission budget: a person's, or a service's).
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'coupons_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'coupons_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -87,6 +87,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -87,6 +87,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
actor,
actor,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
// The actor is a subject id, a principal or an address, never a token.
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -85,21 +85,21 @@ function createApi(ctx) {
@@ -85,21 +85,21 @@ function createApi(ctx) {
const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');
const publicCache = (res) => res.set('Cache-Control', 'public, max-age=60');
- function merchantView(m) {
+ async function merchantView(m) {
return {
return {
id: m.id,
id: m.id,
slug: m.slug,
slug: m.slug,
name: m.name,
name: m.name,
homepage_url: m.homepage_url,
homepage_url: m.homepage_url,
url: publication.merchantUrl(m),
url: publication.merchantUrl(m),
- domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
- active_codes: coupons.activeCount(m.id),
- hints: coupons.merchantHints(m.id).map((h) => h.text),
+ domains: (await merchants.domains(m)).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
+ active_codes: await coupons.activeCount(m.id),
+ hints: (await coupons.merchantHints(m.id)).map((h) => h.text),
};
};
}
}
- function activeMerchant(idOrSlug) {
- const m = merchants.get(idOrSlug);
+ async function activeMerchant(idOrSlug) {
+ const m = await merchants.get(idOrSlug);
if (!m || m.status !== 'active') throw new ApiError(404, 'merchant.not_found', 'no such merchant');
if (!m || m.status !== 'active') throw new ApiError(404, 'merchant.not_found', 'no such merchant');
return m;
return m;
}
}
@@ -117,9 +117,9 @@ function createApi(ctx) {
@@ -117,9 +117,9 @@ function createApi(ctx) {
// ── Lookup ──────────────────────────────────────────────
// ── Lookup ──────────────────────────────────────────────
router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);
router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);
- router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
+ router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');
if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');
- const found = merchants.resolve(req.query.host);
+ const found = await merchants.resolve(req.query.host);
publicCache(res);
publicCache(res);
if (!found) {
if (!found) {
const host = hosts.normalizeHost(req.query.host);
const host = hosts.normalizeHost(req.query.host);
@@ -129,38 +129,38 @@ function createApi(ctx) {
@@ -129,38 +129,38 @@ function createApi(ctx) {
host: found.host,
host: found.host,
registrable_domain: found.registrable,
registrable_domain: found.registrable,
matched_rule: { host: found.rule.host, include_subdomains: Boolean(found.rule.include_subdomains), path_prefix: found.rule.path_prefix || null },
matched_rule: { host: found.rule.host, include_subdomains: Boolean(found.rule.include_subdomains), path_prefix: found.rule.path_prefix || null },
- merchant: merchantView(found.merchant),
+ merchant: await merchantView(found.merchant),
};
};
}));
}));
- router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
- const m = activeMerchant(req.params.id);
+ router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
+ const m = await activeMerchant(req.params.id);
publicCache(res);
publicCache(res);
- return { merchant: merchantView(m), coupons: coupons.active(m.id).map((c) => coupons.view(c, { merchant: m })) };
+ return { merchant: await merchantView(m), coupons: await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) };
}));
}));
- router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
- const m = activeMerchant(req.params.id);
+ router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
+ const m = await activeMerchant(req.params.id);
publicCache(res);
publicCache(res);
- return { merchant: merchantView(m) };
+ return { merchant: await merchantView(m) };
}));
}));
- router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
- const c = coupons.get(req.params.id);
- const m = c && merchants.byId(c.merchant_id);
+ router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run(async (req, res) => {
+ const c = await coupons.get(req.params.id);
+ const m = c && await merchants.byId(c.merchant_id);
if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');
if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');
publicCache(res);
publicCache(res);
- return { coupon: coupons.view(c, { merchant: m }), merchant: merchantView(m) };
+ return { coupon: await coupons.view(c, { merchant: m }), merchant: await merchantView(m) };
}));
}));
// ── Reports ─────────────────────────────────────────────
// ── Reports ─────────────────────────────────────────────
- router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run((req, res) => {
+ router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run(async (req, res) => {
noStore(res);
noStore(res);
- return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });
+ return await reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });
}, (out) => (out.deduplicated ? 200 : 201)));
}, (out) => (out.deduplicated ? 200 : 201)));
// ── Submissions ─────────────────────────────────────────
// ── Submissions ─────────────────────────────────────────
- router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), actorLimits.submit, run((req, res) => {
+ router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), actorLimits.submit, run(async (req, res) => {
noStore(res);
noStore(res);
const v = req.viewer;
const v = req.viewer;
let who;
let who;
@@ -168,9 +168,9 @@ function createApi(ctx) {
@@ -168,9 +168,9 @@ function createApi(ctx) {
if (v.kind === 'service' && v.origin === 'ai') who = { actor: v.service, kind: 'ai', subject: null };
if (v.kind === 'service' && v.origin === 'ai') who = { actor: v.service, kind: 'ai', subject: null };
else if (v.subject) who = { actor: v.subject, kind: v.kind === 'user' && v.staff ? 'staff' : 'member', subject: v.subject };
else if (v.subject) who = { actor: v.subject, kind: v.kind === 'user' && v.staff ? 'staff' : 'member', subject: v.subject };
else throw new ApiError(403, 'auth.person_required', 'a submission needs the person it is from (X-OV-Subject for services)');
else throw new ApiError(403, 'auth.person_required', 'a submission needs the person it is from (X-OV-Subject for services)');
- const out = submitCode(who, req.body || {}, { traceparent: req.get('traceparent'), limits: v.kind === 'service' ? scaled(config.limits, 10) : config.limits });
+ const out = await submitCode(who, req.body || {}, { traceparent: req.get('traceparent'), limits: v.kind === 'service' ? scaled(config.limits, 10) : config.limits });
return {
return {
- coupon: coupons.view(out.coupon, { merchant: out.merchant }),
+ coupon: await coupons.view(out.coupon, { merchant: out.merchant }),
merchant: { id: out.merchant.id, slug: out.merchant.slug, name: out.merchant.name, status: out.merchant.status },
merchant: { id: out.merchant.id, slug: out.merchant.slug, name: out.merchant.name, status: out.merchant.status },
duplicate: out.duplicate,
duplicate: out.duplicate,
review_state: out.coupon.review_state,
review_state: out.coupon.review_state,
@@ -178,70 +178,70 @@ function createApi(ctx) {
@@ -178,70 +178,70 @@ function createApi(ctx) {
}, (out) => (out.duplicate ? 200 : 201)));
}, (out) => (out.duplicate ? 200 : 201)));
// ── Staff and services ──────────────────────────────────
// ── Staff and services ──────────────────────────────────
- router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), moderate, run((req, res) => {
+ router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), moderate, run(async (req, res) => {
noStore(res);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
- const c = coupons.get(req.params.id);
+ const c = await coupons.get(req.params.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
const body = req.body || {};
const body = req.body || {};
- const after = coupons.setStatus(c, body.status, { actor, note: body.note, traceparent: req.get('traceparent') });
- return { coupon: coupons.view(after) };
+ const after = await coupons.setStatus(c, body.status, { actor, note: body.note, traceparent: req.get('traceparent') });
+ return { coupon: await coupons.view(after) };
}));
}));
- router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {
+ router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run(async (req, res) => {
noStore(res);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
const body = req.body || {};
const body = req.body || {};
const status = body.status === 'pending' ? 'pending' : 'active';
const status = body.status === 'pending' ? 'pending' : 'active';
- const m = merchants.create(body, { status, actor });
- store().tx(() => coupons.syncMerchant(m));
- return { merchant: { ...merchantView(m), status: m.status } };
+ const m = await merchants.create(body, { status, actor });
+ store().tx(async () => await coupons.syncMerchant(m));
+ return { merchant: { ...await merchantView(m), status: m.status } };
}, 201));
}, 201));
- router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {
+ router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run(async (req, res) => {
noStore(res);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
- const m = merchants.get(req.params.id);
+ const m = await merchants.get(req.params.id);
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
- merchants.addDomain(m, req.body || {}, actor);
- return { merchant: { ...merchantView(m), status: m.status } };
+ await merchants.addDomain(m, req.body || {}, actor);
+ return { merchant: { ...await merchantView(m), status: m.status } };
}, 201));
}, 201));
- router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {
+ router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run(async (req, res) => {
noStore(res);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
- const m = merchants.get(req.params.id);
+ const m = await merchants.get(req.params.id);
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
const status = (req.body || {}).status;
const status = (req.body || {}).status;
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
- const after = ctx.moderation.setMerchantStatus(m, status, { actor, traceparent: req.get('traceparent') });
- return { merchant: { ...merchantView(after), status: after.status } };
+ const after = await ctx.moderation.setMerchantStatus(m, status, { actor, traceparent: req.get('traceparent') });
+ return { merchant: { ...await merchantView(after), status: after.status } };
}));
}));
// ── The shared submission path (API and the /submit form) ──
// ── The shared submission path (API and the /submit form) ──
- function submitCode(who, body, { traceparent, limits = config.limits } = {}) {
+ async function submitCode(who, body, { traceparent, limits = config.limits } = {}) {
const input = coupons.parseSubmission(body, ctx.store.now());
const input = coupons.parseSubmission(body, ctx.store.now());
let merchant = null;
let merchant = null;
if (body.merchant_id) {
if (body.merchant_id) {
- merchant = merchants.get(String(body.merchant_id));
+ merchant = await merchants.get(String(body.merchant_id));
if (!merchant) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
if (!merchant) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
} else if (body.host || body.url) {
} else if (body.host || body.url) {
const at = body.url ? hosts.hostOfUrl(body.url) : { host: hosts.normalizeHost(String(body.host)), path: null };
const at = body.url ? hosts.hostOfUrl(body.url) : { host: hosts.normalizeHost(String(body.host)), path: null };
if (!at) throw new ApiError(422, 'host.invalid', 'url must be an http(s) URL of a public site');
if (!at) throw new ApiError(422, 'host.invalid', 'url must be an http(s) URL of a public site');
- const any = merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending', 'disabled'] });
+ const any = await merchants.resolve(at.host, { path: at.path, includeStatuses: ['active', 'pending', 'disabled'] });
if (any) merchant = any.merchant;
if (any) merchant = any.merchant;
else {
else {
- coupons.checkSubmissionRate(who.actor, limits);
+ await coupons.checkSubmissionRate(who.actor, limits);
const reg = hosts.registrable(at.host);
const reg = hosts.registrable(at.host);
if (!reg) throw new ApiError(422, 'host.invalid', `${at.host} is a public suffix, not a site`);
if (!reg) throw new ApiError(422, 'host.invalid', `${at.host} is a public suffix, not a site`);
// A site no merchant covers yet: proposed as a PENDING merchant, invisible until staff approve it.
// A site no merchant covers yet: proposed as a PENDING merchant, invisible until staff approve it.
- merchant = merchants.create({ name: reg, domains: [{ host: reg, include_subdomains: true }] }, { status: 'pending', actor: who.actor });
+ merchant = await merchants.create({ name: reg, domains: [{ host: reg, include_subdomains: true }] }, { status: 'pending', actor: who.actor });
}
}
} else {
} else {
throw new ApiError(422, 'merchant.required', 'name the merchant: merchant_id, host or url');
throw new ApiError(422, 'merchant.required', 'name the merchant: merchant_id, host or url');
}
}
if (merchant.status === 'disabled') throw new ApiError(409, 'merchant.disabled', 'this merchant was taken down');
if (merchant.status === 'disabled') throw new ApiError(409, 'merchant.disabled', 'this merchant was taken down');
- return coupons.submit(who, merchant, input, { limits, traceparent });
+ return await coupons.submit(who, merchant, input, { limits, traceparent });
}
}
const store = () => ctx.store;
const store = () => ctx.store;
@@ -25,12 +25,12 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
@@ -25,12 +25,12 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);
const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);
- function activeEntries() {
+ async function activeEntries() {
const byMerchant = new Map();
const byMerchant = new Map();
const out = [];
const out = [];
- for (const c of coupons.allActive()) {
+ for (const c of await coupons.allActive()) {
let m = byMerchant.get(c.merchant_id);
let m = byMerchant.get(c.merchant_id);
- if (!m) { m = merchants.byId(c.merchant_id); byMerchant.set(c.merchant_id, m); }
+ if (!m) { m = await merchants.byId(c.merchant_id); byMerchant.set(c.merchant_id, m); }
out.push({ c, m, decision: publication.couponDecision(c, m) });
out.push({ c, m, decision: publication.couponDecision(c, m) });
}
}
return out;
return out;
@@ -67,8 +67,8 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
@@ -67,8 +67,8 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
}));
}));
});
});
- router.get('/sitemap.xml', (_req, res) => {
- const entries = activeEntries().filter((e) => e.decision.indexable);
+ router.get('/sitemap.xml', async (_req, res) => {
+ const entries = (await activeEntries()).filter((e) => e.decision.indexable);
const newest = entries.length ? Math.max(...entries.map((e) => e.c.updated_at)) : null;
const newest = entries.length ? Math.max(...entries.map((e) => e.c.updated_at)) : null;
xml(res, seo.sitemapIndex([
xml(res, seo.sitemapIndex([
{ loc: abs('/sitemaps/merchants.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },
{ loc: abs('/sitemaps/merchants.xml'), ...(newest ? { lastmod: new Date(newest).toISOString() } : {}) },
@@ -76,13 +76,13 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
@@ -76,13 +76,13 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
]));
]));
});
});
- router.get('/sitemaps/coupons.xml', (_req, res) => {
- xml(res, seo.sitemap(activeEntries().map((e) => ({ loc: publication.couponUrl(e.c), lastmod: e.c.updated_at, decision: e.decision }))).files[0]);
+ router.get('/sitemaps/coupons.xml', async (_req, res) => {
+ xml(res, seo.sitemap((await activeEntries()).map((e) => ({ loc: publication.couponUrl(e.c), lastmod: e.c.updated_at, decision: e.decision }))).files[0]);
});
});
- router.get('/sitemaps/merchants.xml', (_req, res) => {
+ router.get('/sitemaps/merchants.xml', async (_req, res) => {
const byMerchant = new Map();
const byMerchant = new Map();
- for (const e of activeEntries()) {
+ for (const e of await activeEntries()) {
const cur = byMerchant.get(e.m.id);
const cur = byMerchant.get(e.m.id);
if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });
if (!cur) byMerchant.set(e.m.id, { m: e.m, count: 1, lastmod: e.c.updated_at });
else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }
else { cur.count++; cur.lastmod = Math.max(cur.lastmod, e.c.updated_at); }
@@ -104,23 +104,23 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
@@ -104,23 +104,23 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
decision,
decision,
}));
}));
}
}
- const recent = () => activeEntries().slice(0, 50);
+ const recent = async () => (await activeEntries()).slice(0, 50);
const channel = { title: 'OpenVibe.Coupons — new codes', link: abs('/'), description: 'Codes added to OpenVibe.Coupons, with their status and expiry.' };
const channel = { title: 'OpenVibe.Coupons — new codes', link: abs('/'), description: 'Codes added to OpenVibe.Coupons, with their status and expiry.' };
- router.get('/feed.xml', (_req, res) => xml(res, seo.rssFeed({ ...channel, feedUrl: abs('/feed.xml'), language: 'en' }, feedItems(recent())), 'application/rss+xml'));
- router.get('/atom.xml', (_req, res) => {
- const items = feedItems(recent());
+ router.get('/feed.xml', async (_req, res) => xml(res, seo.rssFeed({ ...channel, feedUrl: abs('/feed.xml'), language: 'en' }, feedItems(await recent())), 'application/rss+xml'));
+ router.get('/atom.xml', async (_req, res) => {
+ const items = feedItems(await recent());
// Atom needs an <updated>; an empty feed has been empty since this process started, so that
// Atom needs an <updated>; an empty feed has been empty since this process started, so that
// instant is its last change (never the request time).
// instant is its last change (never the request time).
xml(res, seo.atomFeed({ ...channel, feedUrl: abs('/atom.xml'), ...(items.length ? {} : { updated: bootTime }) }, items), 'application/atom+xml');
xml(res, seo.atomFeed({ ...channel, feedUrl: abs('/atom.xml'), ...(items.length ? {} : { updated: bootTime }) }, items), 'application/atom+xml');
});
});
- router.get('/feed.json', (_req, res) => {
- res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(recent()))));
+ router.get('/feed.json', async (_req, res) => {
+ res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel, feedUrl: abs('/feed.json') }, feedItems(await recent()))));
});
});
- router.get('/m/:slug/feed.xml', (req, res, next) => {
- const m = merchants.get(req.params.slug);
+ router.get('/m/:slug/feed.xml', async (req, res, next) => {
+ const m = await merchants.get(req.params.slug);
if (!m || m.status !== 'active' || m.slug !== req.params.slug) return next();
if (!m || m.status !== 'active' || m.slug !== req.params.slug) return next();
- const list = coupons.active(m.id).map((c) => ({ c, m, decision: publication.couponDecision(c, m) }));
+ const list = (await coupons.active(m.id)).map((c) => ({ c, m, decision: publication.couponDecision(c, m) }));
xml(res, seo.rssFeed({ title: `${m.name} codes — OpenVibe.Coupons`, link: publication.merchantUrl(m), description: `Active codes for ${m.name}.`, feedUrl: abs(`/m/${m.slug}/feed.xml`), language: 'en' }, feedItems(list)), 'application/rss+xml');
xml(res, seo.rssFeed({ title: `${m.name} codes — OpenVibe.Coupons`, link: publication.merchantUrl(m), description: `Active codes for ${m.name}.`, feedUrl: abs(`/m/${m.slug}/feed.xml`), language: 'en' }, feedItems(list)), 'application/rss+xml');
});
});
@@ -105,16 +105,18 @@ function createPublicRoutes(ctx) {
@@ -105,16 +105,18 @@ function createPublicRoutes(ctx) {
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
body: frame.updatesBody({ service: 'coupons', siteName: 'OpenVibe.Coupons' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'coupons', siteName: 'OpenVibe.Coupons' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}, { cacheable: true }));
}, { cacheable: true }));
- router.get('/', (req, res) => {
+ router.get('/', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 60) : '';
- const recent = coupons.recentActive(12).map((c) => ({ coupon: c, merchant: merchants.byId(c.merchant_id) }));
- const list = merchants.listActive({ limit: 500 }).map((m) => ({ merchant: m, active: coupons.activeCount(m.id) }));
+ const recent = await Promise.all((await coupons.recentActive(12)).map(async (c) => ({ coupon: c, merchant: await merchants.byId(c.merchant_id) })));
+ const shops = await merchants.listActive({ limit: 500 });
+ const counts = await coupons.activeCounts(shops.map((m) => m.id));
+ const list = shops.map((m) => ({ merchant: m, active: counts.get(m.id) || 0 }));
const decision = q ? pageDecision('/', { indexable: false }) : pageDecision('/');
const decision = q ? pageDecision('/', { indexable: false }) : pageDecision('/');
send(req, res, 200, {
send(req, res, 200, {
title: q ? `Search: ${q}` : null, decision, canonical: `${config.baseUrl}/`,
title: q ? `Search: ${q}` : null, decision, canonical: `${config.baseUrl}/`,
feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],
jsonLd: [seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Coupons', inLanguage: 'en' })],
jsonLd: [seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Coupons', inLanguage: 'en' })],
- body: pages.home({ merchants: list, recent, q, results: q ? merchants.search(q) : [], total: list.length }),
+ body: pages.home({ merchants: list, recent, q, results: q ? await merchants.search(q) : [], total: list.length }),
}, { cacheable: true });
}, { cacheable: true });
});
});
@@ -123,27 +125,27 @@ function createPublicRoutes(ctx) {
@@ -123,27 +125,27 @@ function createPublicRoutes(ctx) {
});
});
// ── Shops ───────────────────────────────────────────────
// ── Shops ───────────────────────────────────────────────
- function merchantData(m) {
- const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));
+ async function merchantData(m) {
+ const active = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));
return {
return {
merchant: { id: m.id, slug: m.slug, name: m.name, homepage_url: m.homepage_url, url: publication.merchantUrl(m),
merchant: { id: m.id, slug: m.slug, name: m.name, homepage_url: m.homepage_url, url: publication.merchantUrl(m),
- domains: merchants.domains(m).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
- hints: coupons.merchantHints(m.id).map((x) => x.text) },
+ domains: (await merchants.domains(m)).map((d) => ({ host: d.host, include_subdomains: Boolean(d.include_subdomains), path_prefix: d.path_prefix || null })),
+ hints: (await coupons.merchantHints(m.id)).map((x) => x.text) },
coupons: active,
coupons: active,
};
};
}
}
- function visibleMerchant(req, res) {
- const m = merchants.get(req.params.slug);
+ async function visibleMerchant(req, res) {
+ const m = await merchants.get(req.params.slug);
if (!m || m.slug !== req.params.slug || m.status === 'pending') { notFound(req, res); return null; }
if (!m || m.slug !== req.params.slug || m.status === 'pending') { notFound(req, res); return null; }
if (m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This shop is no longer listed.', { href: '/', label: 'All shops' }); return null; }
if (m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This shop is no longer listed.', { href: '/', label: 'All shops' }); return null; }
return m;
return m;
}
}
- router.get('/m/:slug.json', (req, res) => {
- const m = visibleMerchant(req, res);
+ router.get('/m/:slug.json', async (req, res) => {
+ const m = await visibleMerchant(req, res);
if (!m) return;
if (!m) return;
- const data = merchantData(m);
+ const data = await merchantData(m);
const decision = publication.merchantDecision(m, data.coupons.length);
const decision = publication.merchantDecision(m, data.coupons.length);
res.vary('Cookie'); res.vary('Authorization');
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
@@ -151,11 +153,11 @@ function createPublicRoutes(ctx) {
@@ -151,11 +153,11 @@ function createPublicRoutes(ctx) {
res.json({ ...data, indexability: { indexable: decision.indexable, reasons: decision.codes } });
res.json({ ...data, indexability: { indexable: decision.indexable, reasons: decision.codes } });
});
});
- router.get('/m/:slug', (req, res) => {
- const m = visibleMerchant(req, res);
+ router.get('/m/:slug', async (req, res) => {
+ const m = await visibleMerchant(req, res);
if (!m) return;
if (!m) return;
- const active = coupons.active(m.id).map((c) => coupons.view(c, { merchant: m }));
- const ended = coupons.recentlyEnded(m.id).map((c) => coupons.view(c, { merchant: m }));
+ const active = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));
+ const ended = await Promise.all((await coupons.recentlyEnded(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));
const decision = publication.merchantDecision(m, active.length);
const decision = publication.merchantDecision(m, active.length);
const flashMsg = FLASH[req.query.done] || null;
const flashMsg = FLASH[req.query.done] || null;
send(req, res, 200, {
send(req, res, 200, {
@@ -164,60 +166,60 @@ function createPublicRoutes(ctx) {
@@ -164,60 +166,60 @@ function createPublicRoutes(ctx) {
decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
body: pages.merchant({
body: pages.merchant({
- m, domains: merchants.domains(m), hints: coupons.merchantHints(m.id), active, ended,
+ m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,
viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,
viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,
- watching: watches.isWatching(req.viewer.subject, m),
+ watching: await watches.isWatching(req.viewer.subject, m),
}),
}),
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
});
});
- router.post('/m/:slug/watch', B('coupons.watch'), form, (req, res) => {
+ router.post('/m/:slug/watch', B('coupons.watch'), form, async (req, res) => {
if (!requireForm(req, res)) return;
if (!requireForm(req, res)) return;
- const m = merchants.get(req.params.slug);
+ const m = await merchants.get(req.params.slug);
if (!m || m.status !== 'active') return notFound(req, res);
if (!m || m.status !== 'active') return notFound(req, res);
- if (req.body.action === 'unwatch') watches.unwatch(req.viewer.subject, m); else watches.watch(req.viewer.subject, m);
+ if (req.body.action === 'unwatch') await watches.unwatch(req.viewer.subject, m); else await watches.watch(req.viewer.subject, m);
res.redirect(303, withFlash(`/m/${m.slug}`, req.body.action === 'unwatch' ? 'unwatched' : 'watching'));
res.redirect(303, withFlash(`/m/${m.slug}`, req.body.action === 'unwatch' ? 'unwatched' : 'watching'));
});
});
// ── Codes ───────────────────────────────────────────────
// ── Codes ───────────────────────────────────────────────
- function visibleCoupon(req, res) {
+ async function visibleCoupon(req, res) {
const id = String(req.params.id || '');
const id = String(req.params.id || '');
- const c = COUPON_ID_RE.test(id) ? coupons.get(id) : null;
- const m = c && merchants.byId(c.merchant_id);
+ const c = COUPON_ID_RE.test(id) ? await coupons.get(id) : null;
+ const m = c && await merchants.byId(c.merchant_id);
if (!c || !m || m.status === 'pending' || c.review_state !== 'published') { notFound(req, res); return null; }
if (!c || !m || m.status === 'pending' || c.review_state !== 'published') { notFound(req, res); return null; }
if (c.status === 'disabled' || m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This code was taken down.', { href: '/', label: 'All shops' }); return null; }
if (c.status === 'disabled' || m.status === 'disabled') { messagePage(req, res, 410, 'Gone', 'This code was taken down.', { href: '/', label: 'All shops' }); return null; }
return { c, m };
return { c, m };
}
}
- router.get('/c/:id.json', (req, res) => {
- const found = visibleCoupon(req, res);
+ router.get('/c/:id.json', async (req, res) => {
+ const found = await visibleCoupon(req, res);
if (!found) return;
if (!found) return;
const decision = publication.couponDecision(found.c, found.m);
const decision = publication.couponDecision(found.c, found.m);
res.vary('Cookie'); res.vary('Authorization');
res.vary('Cookie'); res.vary('Authorization');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
if (!decision.indexable) res.set('X-Robots-Tag', decision.robots);
- res.json({ coupon: coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });
+ res.json({ coupon: await coupons.view(found.c, { merchant: found.m }), merchant: { id: found.m.id, slug: found.m.slug, name: found.m.name, url: publication.merchantUrl(found.m) }, indexability: { indexable: decision.indexable, reasons: decision.codes } });
});
});
- router.get('/c/:id', (req, res) => {
- const found = visibleCoupon(req, res);
+ router.get('/c/:id', async (req, res) => {
+ const found = await visibleCoupon(req, res);
if (!found) return;
if (!found) return;
const { c, m } = found;
const { c, m } = found;
- const v = coupons.view(c, { merchant: m });
+ const v = await coupons.view(c, { merchant: m });
const flashMsg = FLASH[req.query.done] || null;
const flashMsg = FLASH[req.query.done] || null;
send(req, res, 200, {
send(req, res, 200, {
title: `${v.code} — ${v.title} at ${m.name}`,
title: `${v.code} — ${v.title} at ${m.name}`,
description: `${pages.STATUS_LABEL[v.status]}. ${v.expiry.known ? `Expires ${v.expiry.expires_at.slice(0, 10)}.` : 'Expiry unknown.'}`,
description: `${pages.STATUS_LABEL[v.status]}. ${v.expiry.known ? `Expires ${v.expiry.expires_at.slice(0, 10)}.` : 'Expiry unknown.'}`,
decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
- body: pages.couponPage({ v, m, history: coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),
+ body: pages.couponPage({ v, m, history: await coupons.history(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }),
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
});
});
- router.post('/c/:id/report', B('coupons.report.create'), form, (req, res) => {
+ router.post('/c/:id/report', B('coupons.report.create'), form, async (req, res) => {
if (!requireForm(req, res)) return;
if (!requireForm(req, res)) return;
const back = safeBack(req.body.back, `/c/${req.params.id}`);
const back = safeBack(req.body.back, `/c/${req.params.id}`);
try {
try {
- const out = reports.report(req.viewer, String(req.params.id), { outcome: req.body.outcome, reason: req.body.reason || null }, { channel: 'site' });
+ const out = await reports.report(req.viewer, String(req.params.id), { outcome: req.body.outcome, reason: req.body.reason || null }, { channel: 'site' });
const key = !out.deduplicated ? 'reported' : out.corrected ? 'report_corrected' : 'report_same';
const key = !out.deduplicated ? 'reported' : out.corrected ? 'report_corrected' : 'report_same';
res.redirect(303, withFlash(back, key));
res.redirect(303, withFlash(back, key));
} catch (err) {
} catch (err) {
@@ -227,8 +229,8 @@ function createPublicRoutes(ctx) {
@@ -227,8 +229,8 @@ function createPublicRoutes(ctx) {
});
});
// ── Submit ──────────────────────────────────────────────
// ── Submit ──────────────────────────────────────────────
- router.get('/submit', (req, res) => {
- const m = typeof req.query.merchant === 'string' ? merchants.get(req.query.merchant) : null;
+ router.get('/submit', async (req, res) => {
+ const m = typeof req.query.merchant === 'string' ? await merchants.get(req.query.merchant) : null;
const values = m && m.status === 'active' ? { merchant: m.slug } : {};
const values = m && m.status === 'active' ? { merchant: m.slug } : {};
send(req, res, 200, {
send(req, res, 200, {
title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),
title: 'Submit a code', decision: pageDecision('/submit', { indexable: false }),
@@ -236,10 +238,10 @@ function createPublicRoutes(ctx) {
@@ -236,10 +238,10 @@ function createPublicRoutes(ctx) {
});
});
});
});
- router.post('/submit', B('coupons.coupon.submit'), form, (req, res) => {
+ router.post('/submit', B('coupons.coupon.submit'), form, async (req, res) => {
if (!requireForm(req, res)) return;
if (!requireForm(req, res)) return;
const b = req.body || {};
const b = req.body || {};
- const m = b.merchant ? merchants.get(String(b.merchant)) : null;
+ const m = b.merchant ? await merchants.get(String(b.merchant)) : null;
const body = {
const body = {
...(m ? { merchant_id: m.id } : { url: /^https?:\/\//i.test(String(b.url || '')) ? b.url : undefined, host: /^https?:\/\//i.test(String(b.url || '')) ? undefined : b.url }),
...(m ? { merchant_id: m.id } : { url: /^https?:\/\//i.test(String(b.url || '')) ? b.url : undefined, host: /^https?:\/\//i.test(String(b.url || '')) ? undefined : b.url }),
code: b.code, title: b.title, description: b.description, evidence_url: b.evidence_url || null,
code: b.code, title: b.title, description: b.description, evidence_url: b.evidence_url || null,
@@ -252,7 +254,7 @@ function createPublicRoutes(ctx) {
@@ -252,7 +254,7 @@ function createPublicRoutes(ctx) {
};
};
const who = { actor: req.viewer.subject, kind: req.viewer.staff ? 'staff' : 'member', subject: req.viewer.subject };
const who = { actor: req.viewer.subject, kind: req.viewer.staff ? 'staff' : 'member', subject: req.viewer.subject };
try {
try {
- const out = ctx.submitCode(who, body, {});
+ const out = await ctx.submitCode(who, body, {});
send(req, res, out.duplicate ? 200 : 201, { title: 'Submitted', decision: pageDecision('/submit', { indexable: false }), body: pages.submitted(out) });
send(req, res, out.duplicate ? 200 : 201, { title: 'Submitted', decision: pageDecision('/submit', { indexable: false }), body: pages.submitted(out) });
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;
if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;
@@ -264,33 +266,33 @@ function createPublicRoutes(ctx) {
@@ -264,33 +266,33 @@ function createPublicRoutes(ctx) {
});
});
// ── Browser helper tokens ───────────────────────────────
// ── Browser helper tokens ───────────────────────────────
- function connectPage(req, res, status, extra = {}) {
- const list = req.viewer.kind === 'user' && req.viewer.subject ? installs.list(req.viewer.subject) : [];
+ async function connectPage(req, res, status, extra = {}) {
+ const list = req.viewer.kind === 'user' && req.viewer.subject ? await installs.list(req.viewer.subject) : [];
send(req, res, status, {
send(req, res, status, {
title: 'Connect the browser helper', decision: pageDecision('/connect-extension', { indexable: req.viewer.kind === 'anonymous' }),
title: 'Connect the browser helper', decision: pageDecision('/connect-extension', { indexable: req.viewer.kind === 'anonymous' }),
body: pages.connect({ viewer: req.viewer, installs: list, csrf: csrf(req), ...extra }),
body: pages.connect({ viewer: req.viewer, installs: list, csrf: csrf(req), ...extra }),
}, { cacheable: req.viewer.kind === 'anonymous' });
}, { cacheable: req.viewer.kind === 'anonymous' });
}
}
- router.get('/connect-extension', (req, res) => connectPage(req, res, 200));
- router.post('/connect-extension', B('coupons.install.create'), form, (req, res) => {
+ router.get('/connect-extension', async (req, res) => await connectPage(req, res, 200));
+ router.post('/connect-extension', B('coupons.install.create'), form, async (req, res) => {
if (!requireForm(req, res)) return;
if (!requireForm(req, res)) return;
try {
try {
- const created = installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });
- connectPage(req, res, 201, { created });
+ const created = await installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });
+ await connectPage(req, res, 201, { created });
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError)) throw err;
if (!(err instanceof ApiError)) throw err;
- connectPage(req, res, err.status, { errs: [err.message] });
+ await connectPage(req, res, err.status, { errs: [err.message] });
}
}
});
});
- router.post('/connect-extension/:id/revoke', form, (req, res) => {
+ router.post('/connect-extension/:id/revoke', form, async (req, res) => {
if (!requireForm(req, res)) return;
if (!requireForm(req, res)) return;
- installs.revoke(req.viewer.subject, String(req.params.id));
+ await installs.revoke(req.viewer.subject, String(req.params.id));
res.redirect(303, '/connect-extension');
res.redirect(303, '/connect-extension');
});
});
- router.get('/watching', (req, res) => {
+ router.get('/watching', async (req, res) => {
if (!requireUser(req, res)) return;
if (!requireUser(req, res)) return;
- send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: watches.list(req.viewer.subject) }) });
+ send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: await watches.list(req.viewer.subject) }) });
});
});
// ── Staff ───────────────────────────────────────────────
// ── Staff ───────────────────────────────────────────────
@@ -299,56 +301,56 @@ function createPublicRoutes(ctx) {
@@ -299,56 +301,56 @@ function createPublicRoutes(ctx) {
if (!req.viewer.staff) { messagePage(req, res, 403, 'Staff only', 'This page is for OpenVibe staff.'); return false; }
if (!req.viewer.staff) { messagePage(req, res, 403, 'Staff only', 'This page is for OpenVibe staff.'); return false; }
return true;
return true;
}
}
- function staffPage(req, res, status = 200, extra = {}) {
- const pendingMerchants = merchants.pending().map((m) => ({ ...m, domains: merchants.domains(m).map((d) => d.host + (d.path_prefix || '')), codes: coupons.pendingOfMerchant(m.id).length }));
- const pendingCoupons = coupons.pendingReview().map((c) => {
- const m = merchants.byId(c.merchant_id);
- const v = coupons.view(c, { merchant: m });
+ async function staffPage(req, res, status = 200, extra = {}) {
+ const pendingMerchants = await Promise.all((await merchants.pending()).map(async (m) => ({ ...m, domains: (await merchants.domains(m)).map((d) => d.host + (d.path_prefix || '')), codes: (await coupons.pendingOfMerchant(m.id)).length })));
+ const pendingCoupons = await Promise.all((await coupons.pendingReview()).map(async (c) => {
+ const m = await merchants.byId(c.merchant_id);
+ const v = await coupons.view(c, { merchant: m });
return { id: c.id, code: c.code, title: c.title, origin: c.origin, merchant: m ? m.name : '?', evidence: (v.evidence.find((e) => e.url) || {}).url || null };
return { id: c.id, code: c.code, title: c.title, origin: c.origin, merchant: m ? m.name : '?', evidence: (v.evidence.find((e) => e.url) || {}).url || null };
- });
+ }));
send(req, res, status, {
send(req, res, status, {
title: 'Staff', decision: pageDecision('/staff', { indexable: false }),
title: 'Staff', decision: pageDecision('/staff', { indexable: false }),
- body: pages.staff({ pendingMerchants, pendingCoupons, holds: ctx.importer.holds(), csrf: csrf(req), flashMsg: FLASH[req.query.done] || extra.flashMsg || null, errs: extra.errs }),
+ body: pages.staff({ pendingMerchants, pendingCoupons, holds: await ctx.importer.holds(), csrf: csrf(req), flashMsg: FLASH[req.query.done] || extra.flashMsg || null, errs: extra.errs }),
});
});
}
}
function staffAction(fn) {
function staffAction(fn) {
- return (req, res) => {
+ return async (req, res) => {
if (!requireStaff(req, res)) return;
if (!requireStaff(req, res)) return;
if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
try {
try {
- const msg = fn(req);
- staffPage(req, res, 200, { flashMsg: msg });
+ const msg = await fn(req);
+ await staffPage(req, res, 200, { flashMsg: msg });
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;
if (!(err instanceof ApiError) && err.name !== 'HostError') throw err;
- staffPage(req, res, err.status || 422, { errs: [err.message] });
+ await staffPage(req, res, err.status || 422, { errs: [err.message] });
}
}
};
};
}
}
- router.get('/staff', (req, res) => { if (requireStaff(req, res)) staffPage(req, res); });
- router.post('/staff/merchants', B('coupons.moderate'), form, staffAction((req) => {
+ router.get('/staff', async (req, res) => { if (requireStaff(req, res)) await staffPage(req, res); });
+ router.post('/staff/merchants', B('coupons.moderate'), form, staffAction(async (req) => {
const b = req.body;
const b = req.body;
- const m = merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });
- store.tx(() => coupons.syncMerchant(m));
+ const m = await merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });
+ await store.tx(async () => await coupons.syncMerchant(m));
return `Added ${m.name}.`;
return `Added ${m.name}.`;
}));
}));
- router.post('/staff/merchants/:id/status', B('coupons.moderate'), form, staffAction((req) => {
- const m = merchants.get(req.params.id);
+ router.post('/staff/merchants/:id/status', B('coupons.moderate'), form, staffAction(async (req) => {
+ const m = await merchants.get(req.params.id);
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');
- const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });
+ const after = await moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });
return `${after.name} is now ${after.status}.`;
return `${after.name} is now ${after.status}.`;
}));
}));
- router.post('/staff/coupons/:id/approve', B('coupons.moderate'), form, staffAction((req) => {
- const c = coupons.get(req.params.id);
+ router.post('/staff/coupons/:id/approve', B('coupons.moderate'), form, staffAction(async (req) => {
+ const c = await coupons.get(req.params.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
- const m = merchants.byId(c.merchant_id);
+ const m = await merchants.byId(c.merchant_id);
if (m.status !== 'active') throw new ApiError(409, 'merchant.not_active', 'approve the shop first');
if (m.status !== 'active') throw new ApiError(409, 'merchant.not_active', 'approve the shop first');
- coupons.approve(c, { actor: req.viewer.subject });
+ await coupons.approve(c, { actor: req.viewer.subject });
return `Published ${c.code}.`;
return `Published ${c.code}.`;
}));
}));
- router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], B('coupons.moderate'), form, staffAction((req) => {
- const c = coupons.get(req.params.id || req.body.id);
+ router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], B('coupons.moderate'), form, staffAction(async (req) => {
+ const c = await coupons.get(req.params.id || req.body.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
- const after = coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });
+ const after = await coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });
return `${c.code} is now ${pages.STATUS_LABEL[after.status].toLowerCase()}.`;
return `${c.code} is now ${pages.STATUS_LABEL[after.status].toLowerCase()}.`;
}));
}));
@@ -7,11 +7,12 @@
@@ -7,11 +7,12 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
-const { app, ctx } = createApp();
+(async () => {
+const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
@@ -23,10 +24,11 @@ function shutdown(signal) {
@@ -23,10 +24,11 @@ function shutdown(signal) {
ctx.worker.stop();
ctx.worker.stop();
server.close(async () => {
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { ctx.store.close(); } catch { /* already closed */ }
+ try { await ctx.store.close(); } catch { /* already closed */ }
process.exit(0);
process.exit(0);
});
});
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
+})().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
const { CHARTER_TABLES } = require('./db');
-function createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, release = null }) {
+function createCouponsReadiness({ store, auth, outbox, worker, importer, reports, config, valkey = null, release = null }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'coupons',
service: 'coupons',
@@ -22,12 +22,16 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports
@@ -22,12 +22,16 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports
checks: [
checks: [
{
{
name: 'db', required: true,
name: 'db', required: true,
- check: () => {
- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ check: async () => {
+ // A real round trip that names the store (postgresql / pglite), and the charter tables present.
+ const r = await db.ready();
+ if (!r.ok) return r.error;
+ const names = new Set((await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((x) => x.name));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
- return missing.length ? `missing ${missing.join(', ')}` : true;
+ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
},
},
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -38,8 +42,8 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports
@@ -38,8 +42,8 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports
},
},
{
{
name: 'events_relay', required: false,
name: 'events_relay', required: false,
- check: () => {
- const s = outbox.status();
+ check: async () => {
+ const s = await outbox.status();
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
return { ok: true, detail: { pending: s.pending } };
return { ok: true, detail: { pending: s.pending } };
@@ -58,11 +62,11 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports
@@ -58,11 +62,11 @@ function createCouponsReadiness({ store, auth, outbox, worker, importer, reports
},
},
{
{
name: 'sources_import', required: false,
name: 'sources_import', required: false,
- check: () => {
+ check: async () => {
if (!importer.enabled) return 'off (OV_SOURCES_INTERNAL_URL or OV_OAUTH_CLIENT_SECRET unset)';
if (!importer.enabled) return 'off (OV_SOURCES_INTERNAL_URL or OV_OAUTH_CLIENT_SECRET unset)';
- const st = importer.state();
+ const st = await importer.state();
if (st && st.last_error) return `last run failed: ${st.last_error}`;
if (st && st.last_error) return `last run failed: ${st.last_error}`;
- return { ok: true, detail: { cursor: st ? st.cursor : 0, held: importer.holdCount() } };
+ return { ok: true, detail: { cursor: st ? st.cursor : 0, held: await importer.holdCount() } };
},
},
},
},
{
{
@@ -15,11 +15,11 @@ function createWorker({ config, coupons, importer, outbox, log = console }) {
@@ -15,11 +15,11 @@ function createWorker({ config, coupons, importer, outbox, log = console }) {
let importing = false;
let importing = false;
let lastSweep = null;
let lastSweep = null;
- function sweep() {
+ async function sweep() {
if (sweeping) return null;
if (sweeping) return null;
sweeping = true;
sweeping = true;
try {
try {
- const r = coupons.sweep();
+ const r = await coupons.sweep();
lastSweep = { at: Date.now(), ...r, error: null };
lastSweep = { at: Date.now(), ...r, error: null };
if (r.expired.length || r.changed.length) outbox.kick();
if (r.expired.length || r.changed.length) outbox.kick();
return r;
return r;
@@ -57,21 +57,21 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
@@ -57,21 +57,21 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
await check('reports have their own budget (30 a minute) for the person, whichever browser or form sends them', async () => {
await check('reports have their own budget (30 a minute) for the person, whichever browser or form sends them', async () => {
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
- const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n, MAX(updated_at) AS at FROM coupon_validation_reports').get();
+ const stored = async () => await t.ctx.store.db.prepare('SELECT COUNT(*) AS n, MAX(updated_at) AS at FROM coupon_validation_reports').get();
for (let i = 0; i < 30; i++) {
for (let i = 0; i < 30; i++) {
const r = await t.get(`/api/v1/coupons/${code.id}/report`, { as: i % 2 ? laptop : phone, json: { outcome: i % 3 ? 'worked' : 'failed', reason: i % 3 ? null : 'expired' } });
const r = await t.get(`/api/v1/coupons/${code.id}/report`, { as: i % 2 ? laptop : phone, json: { outcome: i % 3 ? 'worked' : 'failed', reason: i % 3 ? null : 'expired' } });
assert.ok(r.status === 200 || r.status === 201, `report ${i + 1}: ${r.text}`);
assert.ok(r.status === 200 || r.status === 201, `report ${i + 1}: ${r.text}`);
}
}
- const before = JSON.stringify(stored());
+ const before = JSON.stringify(await stored());
const r = await t.get(`/c/${code.id}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'failed', reason: 'expired' } });
const r = await t.get(`/c/${code.id}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'failed', reason: 'expired' } });
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the site form shares the budget');
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the site form shares the budget');
assert.ok(r.json().detail.includes('coupons.report.create'), r.json().detail);
assert.ok(r.json().detail.includes('coupons.report.create'), r.json().detail);
- assert.strictEqual(JSON.stringify(stored()), before, 'nothing recorded');
+ assert.strictEqual(JSON.stringify(await stored()), before, 'nothing recorded');
assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: carols, json: { outcome: 'worked' } })).status, 201, 'another person still reports');
assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: carols, json: { outcome: 'worked' } })).status, 201, 'another person still reports');
});
});
await check('revoking a browser helper is never limited', async () => {
await check('revoking a browser helper is never limited', async () => {
- const installs = t.ctx.installs.list(bob.subject);
+ const installs = await t.ctx.installs.list(bob.subject);
assert.ok(installs.length >= 2);
assert.ok(installs.length >= 2);
for (let i = 0; i < 6; i++) {
for (let i = 0; i < 6; i++) {
const r = await t.get(`/connect-extension/${installs[0].id}/revoke`, { as: bob, form: { csrf: t.csrf(bob) } });
const r = await t.get(`/connect-extension/${installs[0].id}/revoke`, { as: bob, form: { csrf: t.csrf(bob) } });
@@ -20,7 +20,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
@@ -20,7 +20,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
const caps = ['coupons.report.create', 'coupons.coupon.submit'];
const caps = ['coupons.report.create', 'coupons.coupon.submit'];
const appToken = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.coupons'], cap: caps, extra });
const appToken = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.coupons'], cap: caps, extra });
const report = (token, headers) => t.get(`/api/v1/coupons/${code.id}/report`, { as: token, headers, json: { outcome: 'failed', reason: 'invalid' } });
const report = (token, headers) => t.get(`/api/v1/coupons/${code.id}/report`, { as: token, headers, json: { outcome: 'failed', reason: 'invalid' } });
- const reportsOf = (subject) => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ?').get(t.ctx.reports.reporterKey(subject)).n;
+ const reportsOf = async (subject) => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ?').get(t.ctx.reports.reporterKey(subject))).n;
await check('an app token cannot act for someone else by naming them in X-OV-Subject', async () => {
await check('an app token cannot act for someone else by naming them in X-OV-Subject', async () => {
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
@@ -32,14 +32,14 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
@@ -32,14 +32,14 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
}
}
const sub = await t.get('/api/v1/coupons/submit', { as: appToken(APP, 'app', {}), headers: { 'x-ov-subject': victim.subject }, json: { host: 'tinrobot.shop', code: 'FAKE99', title: 'Made up' } });
const sub = await t.get('/api/v1/coupons/submit', { as: appToken(APP, 'app', {}), headers: { 'x-ov-subject': victim.subject }, json: { host: 'tinrobot.shop', code: 'FAKE99', title: 'Made up' } });
assert.strictEqual(sub.status, 403);
assert.strictEqual(sub.status, 403);
- assert.strictEqual(reportsOf(victim.subject), 0);
+ assert.strictEqual(await reportsOf(victim.subject), 0);
});
});
await check('an app token acts for its on_behalf_of person (header optional, must match)', async () => {
await check('an app token acts for its on_behalf_of person (header optional, must match)', async () => {
const token = appToken(APP, 'app', { on_behalf_of: appUser.subject });
const token = appToken(APP, 'app', { on_behalf_of: appUser.subject });
assert.strictEqual((await report(token)).status, 201);
assert.strictEqual((await report(token)).status, 201);
assert.strictEqual((await report(token, { 'x-ov-subject': appUser.subject })).status, 200, 'same person, same day: deduplicated');
assert.strictEqual((await report(token, { 'x-ov-subject': appUser.subject })).status, 200, 'same person, same day: deduplicated');
- assert.strictEqual(reportsOf(appUser.subject), 1);
+ assert.strictEqual(await reportsOf(appUser.subject), 1);
});
});
await check('sandbox app tokens are refused', async () => {
await check('sandbox app tokens are refused', async () => {
@@ -30,7 +30,7 @@ const DAY = 24 * 3600 * 1000;
@@ -30,7 +30,7 @@ const DAY = 24 * 3600 * 1000;
aliceToken = await t.connect(alice, { label: 'Firefox' });
aliceToken = await t.connect(alice, { label: 'Firefox' });
bobToken = await t.connect(bob, { label: 'Chrome', report: false });
bobToken = await t.connect(bob, { label: 'Chrome', report: false });
assert.match(aliceToken, /^cpx_[A-Za-z0-9_-]{43}$/);
assert.match(aliceToken, /^cpx_[A-Za-z0-9_-]{43}$/);
- const rows = t.ctx.store.db.prepare('SELECT * FROM extension_installs').all();
+ const rows = await t.ctx.store.db.prepare('SELECT * FROM extension_installs').all();
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows.length, 2);
const dump = JSON.stringify(rows);
const dump = JSON.stringify(rows);
assert.ok(!dump.includes(aliceToken) && !dump.includes(bobToken), 'the token itself is never stored');
assert.ok(!dump.includes(aliceToken) && !dump.includes(bobToken), 'the token itself is never stored');
@@ -43,10 +43,10 @@ const DAY = 24 * 3600 * 1000;
@@ -43,10 +43,10 @@ const DAY = 24 * 3600 * 1000;
await check('connect and revoke forms need the form token (CSRF)', async () => {
await check('connect and revoke forms need the form token (CSRF)', async () => {
const r = await t.get('/connect-extension', { as: alice, form: { label: 'x', scope_report: '1' } });
const r = await t.get('/connect-extension', { as: alice, form: { label: 'x', scope_report: '1' } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- const install = t.ctx.installs.list(alice.subject)[0];
+ const install = (await t.ctx.installs.list(alice.subject))[0];
const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: 'forged' } });
const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: 'forged' } });
assert.strictEqual(rv.status, 403);
assert.strictEqual(rv.status, 403);
- assert.strictEqual(t.ctx.installs.list(alice.subject)[0].active, true);
+ assert.strictEqual((await t.ctx.installs.list(alice.subject))[0].active, true);
});
});
await check('scopes: lookup always; report only when granted; nothing else (no submit, no staff)', async () => {
await check('scopes: lookup always; report only when granted; nothing else (no submit, no staff)', async () => {
@@ -64,7 +64,7 @@ const DAY = 24 * 3600 * 1000;
@@ -64,7 +64,7 @@ const DAY = 24 * 3600 * 1000;
await check('revocation takes effect on the very next request', async () => {
await check('revocation takes effect on the very next request', async () => {
assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken })).status, 200);
assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken })).status, 200);
- const install = t.ctx.installs.list(alice.subject).find((i) => i.label === 'Firefox');
+ const install = (await t.ctx.installs.list(alice.subject)).find((i) => i.label === 'Firefox');
const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });
const rv = await t.get(`/connect-extension/${install.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });
assert.strictEqual(rv.status, 303);
assert.strictEqual(rv.status, 303);
const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken });
const r = await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: aliceToken });
@@ -72,7 +72,7 @@ const DAY = 24 * 3600 * 1000;
@@ -72,7 +72,7 @@ const DAY = 24 * 3600 * 1000;
assert.strictEqual(r.json().code, 'token.revoked');
assert.strictEqual(r.json().code, 'token.revoked');
assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'failed' } })).status, 401);
assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: aliceToken, json: { outcome: 'failed' } })).status, 401);
// Someone else cannot revoke bob's install.
// Someone else cannot revoke bob's install.
- const bobInstall = t.ctx.installs.list(bob.subject)[0];
+ const bobInstall = (await t.ctx.installs.list(bob.subject))[0];
await t.get(`/connect-extension/${bobInstall.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });
await t.get(`/connect-extension/${bobInstall.id}/revoke`, { as: alice, form: { csrf: t.csrf(alice) } });
assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: bobToken })).status, 200);
assert.strictEqual((await t.get('/api/v1/merchants/resolve?host=paperlantern.store', { as: bobToken })).status, 200);
});
});
@@ -41,11 +41,14 @@ async function boot(opts = {}) {
@@ -41,11 +41,14 @@ async function boot(opts = {}) {
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
+ const { createStore } = require('../../server/db');
+ // One database per boot (PGlite, or COUPONS_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
+ const testdb = await require('./db').testDb();
let server = null;
let server = null;
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;
@@ -54,7 +57,7 @@ async function boot(opts = {}) {
@@ -54,7 +57,7 @@ async function boot(opts = {}) {
}
}
async function stop() {
async function stop() {
if (server) await new Promise((r) => server.close(r));
if (server) await new Promise((r) => server.close(r));
- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
+ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }
server = null; built = null;
server = null; built = null;
}
}
@@ -72,8 +75,8 @@ async function boot(opts = {}) {
@@ -72,8 +75,8 @@ async function boot(opts = {}) {
}
}
/** Rows of event_outbox as parsed envelopes. */
/** Rows of event_outbox as parsed envelopes. */
- function events(type = null) {
- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+ async function events(type = null) {
+ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
.filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
.filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
}
}
@@ -86,7 +89,7 @@ async function boot(opts = {}) {
@@ -86,7 +89,7 @@ async function boot(opts = {}) {
}
}
async function submit(user, body) {
async function submit(user, body) {
- return get('/api/v1/coupons/submit', { as: network.userToken(user), json: body });
+ return await get('/api/v1/coupons/submit', { as: network.userToken(user), json: body });
}
}
async function connect(user, { report = true, label = 'test browser' } = {}) {
async function connect(user, { report = true, label = 'test browser' } = {}) {
@@ -101,7 +104,7 @@ async function boot(opts = {}) {
@@ -101,7 +104,7 @@ async function boot(opts = {}) {
network, sources, clock, dbPath, staff, get, events, csrf, merchant, submit, connect,
network, sources, clock, dbPath, staff, get, events, csrf, merchant, submit, connect,
staffToken: () => network.userToken(staff),
staffToken: () => network.userToken(staff),
async restart() { await stop(); await start(); },
async restart() { await stop(); await start(); },
- async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ async close() { await stop(); await testdb.close(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
};
};
await start();
await start();
return t;
return t;
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * COUPONS_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.COUPONS_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'coupons', max });
+
+module.exports = { testDb, pgAvailable };
@@ -46,7 +46,7 @@ const DAY = 24 * HOUR;
@@ -46,7 +46,7 @@ const DAY = 24 * HOUR;
await check('the unknown code stays unknown across time and sweeps while nobody reports', async () => {
await check('the unknown code stays unknown across time and sweeps while nobody reports', async () => {
t.clock.advance(40 * DAY);
t.clock.advance(40 * DAY);
- t.ctx.worker.sweep();
+ await t.ctx.worker.sweep();
const c = (await t.get(`/api/v1/coupons/${unknownCode.id}`)).json().coupon;
const c = (await t.get(`/api/v1/coupons/${unknownCode.id}`)).json().coupon;
assert.strictEqual(c.status, 'unknown');
assert.strictEqual(c.status, 'unknown');
assert.strictEqual(c.confidence, null);
assert.strictEqual(c.confidence, null);
@@ -153,21 +153,21 @@ const DAY = 24 * HOUR;
@@ -153,21 +153,21 @@ const DAY = 24 * HOUR;
});
});
await check('the sweep records the expiry: status, history, coupons.coupon.expired, Search tombstone — once', async () => {
await check('the sweep records the expiry: status, history, coupons.coupon.expired, Search tombstone — once', async () => {
- const before = t.events().length;
- const r = t.ctx.worker.sweep();
+ const before = (await t.events()).length;
+ const r = await t.ctx.worker.sweep();
assert.deepStrictEqual(r.expired, [dated.id]);
assert.deepStrictEqual(r.expired, [dated.id]);
- const row = t.ctx.coupons.get(dated.id);
+ const row = await t.ctx.coupons.get(dated.id);
assert.strictEqual(row.status, 'expired');
assert.strictEqual(row.status, 'expired');
assert.strictEqual(row.expired_at, expiresAt, 'expired at the stated instant, not at sweep time');
assert.strictEqual(row.expired_at, expiresAt, 'expired at the stated instant, not at sweep time');
- const hist = t.ctx.coupons.history(dated.id);
+ const hist = await t.ctx.coupons.history(dated.id);
assert.strictEqual(hist[0].to_status, 'expired');
assert.strictEqual(hist[0].to_status, 'expired');
assert.strictEqual(hist[0].reason, 'expiry');
assert.strictEqual(hist[0].reason, 'expiry');
- const evs = t.events().slice(before);
+ const evs = (await t.events()).slice(before);
const mine = evs.filter((e) => e.subject.id === dated.id).map((e) => e.event_type).sort();
const mine = evs.filter((e) => e.subject.id === dated.id).map((e) => e.event_type).sort();
assert.deepStrictEqual(mine, ['coupons.coupon.expired', 'coupons.index_document.deleted']);
assert.deepStrictEqual(mine, ['coupons.coupon.expired', 'coupons.index_document.deleted']);
assert.ok(evs.some((e) => e.event_type === 'coupons.index_document.upserted' && e.subject.id === shop.id), 'the shop document is re-sent with one active code fewer');
assert.ok(evs.some((e) => e.event_type === 'coupons.index_document.upserted' && e.subject.id === shop.id), 'the shop document is re-sent with one active code fewer');
- assert.deepStrictEqual(t.ctx.worker.sweep().expired, [], 'idempotent');
- assert.strictEqual(t.events().length, before + evs.length);
+ assert.deepStrictEqual((await t.ctx.worker.sweep()).expired, [], 'idempotent');
+ assert.strictEqual((await t.events()).length, before + evs.length);
});
});
await check('a date-only expiry means the end of that day in UTC, and the page says the time zone is not stated', async () => {
await check('a date-only expiry means the end of that day in UTC, and the page says the time zone is not stated', async () => {
@@ -216,7 +216,7 @@ const DAY = 24 * HOUR;
@@ -216,7 +216,7 @@ const DAY = 24 * HOUR;
});
});
await check('every event is a valid envelope from the service, and none names a person', async () => {
await check('every event is a valid envelope from the service, and none names a person', async () => {
- const all = t.events();
+ const all = await t.events();
assert.ok(all.length > 10);
assert.ok(all.length > 10);
for (const e of all) {
for (const e of all) {
const v = contracts.validate('events.event-envelope@1', e);
const v = contracts.validate('events.event-envelope@1', e);
@@ -12,7 +12,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -12,7 +12,7 @@ const { boot, check, done } = require('./helpers/boot');
const t = await boot();
const t = await boot();
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
await t.merchant({ name: 'Acme Outdoor', host: 'acme-outdoor.com' });
await t.merchant({ name: 'Acme Outdoor', host: 'acme-outdoor.com' });
- const moderation = () => t.events('coupons.moderation.action');
+ const moderation = async () => await t.events('coupons.moderation.action');
const setStatus = (id, body) => t.get(`/api/v1/coupons/${id}/status`, { as: t.staffToken(), json: body });
const setStatus = (id, body) => t.get(`/api/v1/coupons/${id}/status`, { as: t.staffToken(), json: body });
const valid = (e) => {
const valid = (e) => {
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
@@ -30,7 +30,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -30,7 +30,7 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.submit(t.staff, { host: 'acme-outdoor.com', code: 'STAFFOWN', title: 'Staff code' });
const r = await t.submit(t.staff, { host: 'acme-outdoor.com', code: 'STAFFOWN', title: 'Staff code' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual((await setStatus(r.json().coupon.id, { status: 'disabled' })).status, 200);
assert.strictEqual((await setStatus(r.json().coupon.id, { status: 'disabled' })).status, 200);
- assert.strictEqual(moderation().length, 0);
+ assert.strictEqual((await moderation()).length, 0);
});
});
await check('staff disabling someone else\'s code: exactly one valid event', async () => {
await check('staff disabling someone else\'s code: exactly one valid event', async () => {
@@ -39,7 +39,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -39,7 +39,7 @@ const { boot, check, done } = require('./helpers/boot');
const id = r.json().coupon.id;
const id = r.json().coupon.id;
const off = await setStatus(id, { status: 'disabled', note: 'fabricated code' });
const off = await setStatus(id, { status: 'disabled', note: 'fabricated code' });
assert.strictEqual(off.status, 200, off.text);
assert.strictEqual(off.status, 200, off.text);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
valid(ev[0]);
valid(ev[0]);
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `coupon:${id}` });
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `coupon:${id}` });
@@ -53,10 +53,10 @@ const { boot, check, done } = require('./helpers/boot');
@@ -53,10 +53,10 @@ const { boot, check, done } = require('./helpers/boot');
await check('approving a shop members proposed: one merchant.approved, not one per code it publishes', async () => {
await check('approving a shop members proposed: one merchant.approved, not one per code it publishes', async () => {
const r = await t.submit(alice, { url: 'https://www.new-gadgets.net/cart', code: 'GADGET5', title: 'Five off gadgets' });
const r = await t.submit(alice, { url: 'https://www.new-gadgets.net/cart', code: 'GADGET5', title: 'Five off gadgets' });
assert.strictEqual(r.json().merchant.status, 'pending');
assert.strictEqual(r.json().merchant.status, 'pending');
- const n = moderation().length;
+ const n = (await moderation()).length;
const ok = await t.get(`/staff/merchants/${r.json().merchant.id}/status`, { as: t.staff, form: { csrf: t.csrf(t.staff), status: 'active' } });
const ok = await t.get(`/staff/merchants/${r.json().merchant.id}/status`, { as: t.staff, form: { csrf: t.csrf(t.staff), status: 'active' } });
assert.strictEqual(ok.status, 200);
assert.strictEqual(ok.status, 200);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, n + 1);
assert.strictEqual(ev.length, n + 1);
valid(ev[n]);
valid(ev[n]);
assert.strictEqual(ev[n].payload.action, 'merchant.approved');
assert.strictEqual(ev[n].payload.action, 'merchant.approved');
@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });
const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });
assert.strictEqual(r.status, 201, r.text.slice(0, 300));
assert.strictEqual(r.status, 201, r.text.slice(0, 300));
assert.match(r.text, /your code is listed/);
assert.match(r.text, /your code is listed/);
- codeId = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'VEG5'").get().id;
+ codeId = (await t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'VEG5'").get()).id;
const m = await t.get(`/m/${shop.slug}`);
const m = await t.get(`/m/${shop.slug}`);
assert.match(m.text, /VEG5/);
assert.match(m.text, /VEG5/);
assert.match(m.text, /Minimum spend £30\.00/);
assert.match(m.text, /Minimum spend £30\.00/);
@@ -24,7 +24,7 @@ const DAY = 24 * HOUR;
@@ -24,7 +24,7 @@ const DAY = 24 * HOUR;
assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { json: { outcome: 'worked' } })).status, 401);
assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { json: { outcome: 'worked' } })).status, 401);
const cookieOnly = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bob, json: { outcome: 'worked' } });
const cookieOnly = await t.get(`/api/v1/coupons/${code.id}/report`, { as: bob, json: { outcome: 'worked' } });
assert.strictEqual(cookieOnly.status, 401, 'a signed-in cookie does not authenticate the API');
assert.strictEqual(cookieOnly.status, 401, 'a signed-in cookie does not authenticate the API');
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get()).n, 0);
});
});
await check('input is validated: outcome worked|failed, reasons only for failed ones', async () => {
await check('input is validated: outcome worked|failed, reasons only for failed ones', async () => {
@@ -50,12 +50,12 @@ const DAY = 24 * HOUR;
@@ -50,12 +50,12 @@ const DAY = 24 * HOUR;
const tokenB = await t.connect(bob, { label: 'phone' });
const tokenB = await t.connect(bob, { label: 'phone' });
assert.strictEqual((await report(tokenA, code.id, { outcome: 'worked' })).json().deduplicated, true);
assert.strictEqual((await report(tokenA, code.id, { outcome: 'worked' })).json().deduplicated, true);
assert.strictEqual((await report(tokenB, code.id, { outcome: 'worked' })).json().deduplicated, true);
assert.strictEqual((await report(tokenB, code.id, { outcome: 'worked' })).json().deduplicated, true);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get()).n, 1);
const c = await report(bob, code.id, { outcome: 'failed', reason: 'min_spend_not_met' });
const c = await report(bob, code.id, { outcome: 'failed', reason: 'min_spend_not_met' });
assert.strictEqual(c.json().corrected, true);
assert.strictEqual(c.json().corrected, true);
assert.strictEqual(c.json().coupon.status, 'reported_failed');
assert.strictEqual(c.json().coupon.status, 'reported_failed');
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get().n, 1);
- assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports').get()).n, 1);
+ assert.strictEqual((await t.events('coupons.report.created')).length, 1, 'one report, one event');
});
});
await check('the submitter (or anyone who added evidence) cannot report on the code: one account cannot make its own code "working"', async () => {
await check('the submitter (or anyone who added evidence) cannot report on the code: one account cannot make its own code "working"', async () => {
@@ -70,8 +70,8 @@ const DAY = 24 * HOUR;
@@ -70,8 +70,8 @@ const DAY = 24 * HOUR;
const hank = t.network.addUser('hank');
const hank = t.network.addUser('hank');
assert.strictEqual((await t.submit(hank, { host: 'bluekettle.shop', code: 'MADEUP50', title: 'Code MADEUP50', evidence_url: 'https://bluekettle.shop/deals' })).status, 200, 'duplicate: evidence added');
assert.strictEqual((await t.submit(hank, { host: 'bluekettle.shop', code: 'MADEUP50', title: 'Code MADEUP50', evidence_url: 'https://bluekettle.shop/deals' })).status, 200, 'duplicate: evidence added');
assert.strictEqual((await report(hank, fake.id, { outcome: 'worked' })).status, 403, 'evidence submitters are submitters too');
assert.strictEqual((await report(hank, fake.id, { outcome: 'worked' })).status, 403, 'evidence submitters are submitters too');
- assert.strictEqual(t.ctx.coupons.get(fake.id).status, 'unknown');
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id).n, 0);
+ assert.strictEqual((await t.ctx.coupons.get(fake.id)).status, 'unknown');
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id)).n, 0);
});
});
await check('the next day is a new report, but only the latest per person counts', async () => {
await check('the next day is a new report, but only the latest per person counts', async () => {
@@ -95,11 +95,11 @@ const DAY = 24 * HOUR;
@@ -95,11 +95,11 @@ const DAY = 24 * HOUR;
assert.doesNotMatch(body, /reporter|install_id|cpi_/);
assert.doesNotMatch(body, /reporter|install_id|cpi_/);
const last = r.json().coupon.reports.last_report_at;
const last = r.json().coupon.reports.last_report_at;
assert.match(last, /T\d{2}:00:00\.000Z$/);
assert.match(last, /T\d{2}:00:00\.000Z$/);
- for (const e of t.events('coupons.report.created')) {
+ for (const e of await t.events('coupons.report.created')) {
assert.deepStrictEqual(Object.keys(e.payload).sort(), ['channel', 'day', 'merchant_id', 'outcome', ...(e.payload.reason ? ['reason'] : [])].sort());
assert.deepStrictEqual(Object.keys(e.payload).sort(), ['channel', 'day', 'merchant_id', 'outcome', ...(e.payload.reason ? ['reason'] : [])].sort());
assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
}
}
- const keys = t.ctx.store.db.prepare('SELECT reporter_key FROM coupon_validation_reports').all().map((x) => x.reporter_key);
+ const keys = (await t.ctx.store.db.prepare('SELECT reporter_key FROM coupon_validation_reports').all()).map((x) => x.reporter_key);
for (const k of keys) { assert.match(k, /^[0-9a-f]{64}$/); assert.ok(!k.includes('usr_')); }
for (const k of keys) { assert.match(k, /^[0-9a-f]{64}$/); assert.ok(!k.includes('usr_')); }
});
});
@@ -125,20 +125,20 @@ const DAY = 24 * HOUR;
@@ -125,20 +125,20 @@ const DAY = 24 * HOUR;
const fresh = await mk('DECAY1');
const fresh = await mk('DECAY1');
const frank = t.network.addUser('frank');
const frank = t.network.addUser('frank');
await report(frank, fresh.id, { outcome: 'worked' });
await report(frank, fresh.id, { outcome: 'worked' });
- assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');
+ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).status, 'reported_working');
t.clock.advance(6 * DAY);
t.clock.advance(6 * DAY);
- t.ctx.worker.sweep();
- assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'reported_working');
+ await t.ctx.worker.sweep();
+ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).status, 'reported_working');
t.clock.advance(2 * DAY);
t.clock.advance(2 * DAY);
- t.ctx.worker.sweep();
- assert.strictEqual(t.ctx.coupons.get(fresh.id).status, 'unknown');
- assert.ok(t.ctx.coupons.get(fresh.id).confidence > 0.5);
+ await t.ctx.worker.sweep();
+ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).status, 'unknown');
+ assert.ok((await t.ctx.coupons.get(fresh.id)).confidence > 0.5);
t.clock.advance(23 * DAY);
t.clock.advance(23 * DAY);
- t.ctx.worker.sweep();
- assert.strictEqual(t.ctx.coupons.get(fresh.id).confidence, null);
- const hist = t.ctx.coupons.history(fresh.id).map((h) => h.reason);
+ await t.ctx.worker.sweep();
+ assert.strictEqual((await t.ctx.coupons.get(fresh.id)).confidence, null);
+ const hist = (await t.ctx.coupons.history(fresh.id)).map((h) => h.reason);
assert.ok(hist.includes('decay'));
assert.ok(hist.includes('decay'));
- assert.ok(t.events('coupons.confidence.changed').some((e) => e.subject.id === fresh.id && e.payload.to === null));
+ assert.ok((await t.events('coupons.confidence.changed')).some((e) => e.subject.id === fresh.id && e.payload.to === null));
});
});
await t.close();
await t.close();
@@ -20,13 +20,13 @@ const { boot, check, done } = require('./helpers/boot');
@@ -20,13 +20,13 @@ const { boot, check, done } = require('./helpers/boot');
t.sources.put(item('itm_01K0000000000000000000000A'));
t.sources.put(item('itm_01K0000000000000000000000A'));
const r = await t.ctx.importer.run();
const r = await t.ctx.importer.run();
assert.deepStrictEqual(r.outcomes, { imported: 1 });
assert.deepStrictEqual(r.outcomes, { imported: 1 });
- const c = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").get();
+ const c = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").get();
assert.strictEqual(c.status, 'unknown');
assert.strictEqual(c.status, 'unknown');
assert.strictEqual(c.confidence, null);
assert.strictEqual(c.confidence, null);
assert.strictEqual(c.expires_at, null);
assert.strictEqual(c.expires_at, null);
assert.strictEqual(c.origin, 'source');
assert.strictEqual(c.origin, 'source');
assert.strictEqual(c.review_state, 'pending');
assert.strictEqual(c.review_state, 'pending');
- const v = t.ctx.coupons.view(c);
+ const v = await t.ctx.coupons.view(c);
assert.deepStrictEqual(v.evidence, [{ kind: 'source', url: 'https://riverbooks.com/promo', merchant_page: true, sources_item: 'itm_01K0000000000000000000000A', retrieved_at: '2026-09-22T10:00:00.000Z' }]);
assert.deepStrictEqual(v.evidence, [{ kind: 'source', url: 'https://riverbooks.com/promo', merchant_page: true, sources_item: 'itm_01K0000000000000000000000A', retrieved_at: '2026-09-22T10:00:00.000Z' }]);
assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.length, 0, 'not listed before review');
assert.strictEqual((await t.get(`/api/v1/merchants/${shop.id}/coupons`)).json().coupons.length, 0, 'not listed before review');
assert.strictEqual(t.sources.calls.length, 1);
assert.strictEqual(t.sources.calls.length, 1);
@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/boot');
t.sources.put(item('itm_01K0000000000000000000000E', { fields: { code: 'OLD', expires: '2026-01-01' } }));
t.sources.put(item('itm_01K0000000000000000000000E', { fields: { code: 'OLD', expires: '2026-01-01' } }));
const r = await t.ctx.importer.run();
const r = await t.ctx.importer.run();
assert.deepStrictEqual(r.outcomes, { 'held:no_merchant': 1, 'held:not_a_coupon': 1, 'held:no_code': 1, 'held:expired': 1, 'retry:held:no_merchant': 1 });
assert.deepStrictEqual(r.outcomes, { 'held:no_merchant': 1, 'held:not_a_coupon': 1, 'held:no_code': 1, 'held:expired': 1, 'retry:held:no_merchant': 1 });
- const holds = t.ctx.importer.holds().map((h) => h.reason).sort();
+ const holds = (await t.ctx.importer.holds()).map((h) => h.reason).sort();
assert.deepStrictEqual(holds, ['expired', 'no_code', 'no_merchant', 'not_a_coupon']);
assert.deepStrictEqual(holds, ['expired', 'no_code', 'no_merchant', 'not_a_coupon']);
const staff = await t.get('/staff', { as: t.staff });
const staff = await t.get('/staff', { as: t.staff });
assert.match(staff.text, /itm_01K0000000000000000000000B from staff-coupon-codes: no_merchant/);
assert.match(staff.text, /itm_01K0000000000000000000000B from staff-coupon-codes: no_merchant/);
@@ -56,18 +56,18 @@ const { boot, check, done } = require('./helpers/boot');
@@ -56,18 +56,18 @@ const { boot, check, done } = require('./helpers/boot');
await t.merchant({ name: 'Unknown Shop', host: 'unknown-shop.com' });
await t.merchant({ name: 'Unknown Shop', host: 'unknown-shop.com' });
const r = await t.ctx.importer.run();
const r = await t.ctx.importer.run();
assert.deepStrictEqual(r.outcomes, { 'retry:imported': 1 });
assert.deepStrictEqual(r.outcomes, { 'retry:imported': 1 });
- assert.ok(t.ctx.store.db.prepare("SELECT 1 FROM coupons WHERE code = 'NOPE'").get());
- assert.ok(!t.ctx.importer.holds().some((h) => h.reason === 'no_merchant'));
+ assert.ok(await t.ctx.store.db.prepare("SELECT 1 FROM coupons WHERE code = 'NOPE'").get());
+ assert.ok(!(await t.ctx.importer.holds()).some((h) => h.reason === 'no_merchant'));
});
});
await check('a stated expiry is kept with basis "source"; a new revision of the same item updates the evidence, not a second code', async () => {
await check('a stated expiry is kept with basis "source"; a new revision of the same item updates the evidence, not a second code', async () => {
t.sources.put(item('itm_01K0000000000000000000000A', { revision: 2, fields: { code: 'READ20', expires: '2026-12-31', min_spend: 25, currency: 'EUR' } }));
t.sources.put(item('itm_01K0000000000000000000000A', { revision: 2, fields: { code: 'READ20', expires: '2026-12-31', min_spend: 25, currency: 'EUR' } }));
await t.ctx.importer.run();
await t.ctx.importer.run();
- const rows = t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").all();
+ const rows = await t.ctx.store.db.prepare("SELECT * FROM coupons WHERE code = 'READ20'").all();
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].expiry_basis, 'source');
assert.strictEqual(rows[0].expiry_basis, 'source');
assert.strictEqual(new Date(rows[0].expires_at).toISOString(), '2026-12-31T23:59:59.999Z');
assert.strictEqual(new Date(rows[0].expires_at).toISOString(), '2026-12-31T23:59:59.999Z');
- const src = t.ctx.store.db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ?').all(rows[0].id);
+ const src = await t.ctx.store.db.prepare('SELECT * FROM coupon_sources WHERE coupon_id = ?').all(rows[0].id);
assert.strictEqual(src.length, 1);
assert.strictEqual(src.length, 1);
assert.strictEqual(src[0].sources_item_rev, 2);
assert.strictEqual(src[0].sources_item_rev, 2);
});
});
@@ -83,24 +83,24 @@ const { boot, check, done } = require('./helpers/boot');
@@ -83,24 +83,24 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('a removed item withdraws its evidence; a code with no evidence left is taken down', async () => {
await check('a removed item withdraws its evidence; a code with no evidence left is taken down', async () => {
- const id = t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'READ20'").get().id;
+ const id = (await t.ctx.store.db.prepare("SELECT id FROM coupons WHERE code = 'READ20'").get()).id;
t.sources.put(item('itm_01K0000000000000000000000A', { revision: 3, removed: { at: '2026-09-23T00:00:00.000Z', reason: 'merchant asked' } }));
t.sources.put(item('itm_01K0000000000000000000000A', { revision: 3, removed: { at: '2026-09-23T00:00:00.000Z', reason: 'merchant asked' } }));
const r = await t.ctx.importer.run();
const r = await t.ctx.importer.run();
assert.strictEqual(r.outcomes.withdrawn, 1);
assert.strictEqual(r.outcomes.withdrawn, 1);
- const c = t.ctx.coupons.get(id);
+ const c = await t.ctx.coupons.get(id);
assert.strictEqual(c.status, 'disabled');
assert.strictEqual(c.status, 'disabled');
assert.strictEqual(c.status_reason, 'source_removed');
assert.strictEqual(c.status_reason, 'source_removed');
});
});
await check('a failed fetch changes nothing and is recorded', async () => {
await check('a failed fetch changes nothing and is recorded', async () => {
- const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n;
- const cursor = t.ctx.importer.state().cursor;
+ const before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n;
+ const cursor = (await t.ctx.importer.state()).cursor;
t.sources.setDown(true);
t.sources.setDown(true);
const r = await t.ctx.importer.run();
const r = await t.ctx.importer.run();
assert.match(r.error, /503/);
assert.match(r.error, /503/);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get().n, before);
- assert.strictEqual(t.ctx.importer.state().cursor, cursor);
- assert.match(t.ctx.importer.state().last_error, /503/);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupons').get()).n, before);
+ assert.strictEqual((await t.ctx.importer.state()).cursor, cursor);
+ assert.match((await t.ctx.importer.state()).last_error, /503/);
const ready = (await t.get('/api/ready')).json();
const ready = (await t.get('/api/ready')).json();
assert.match(JSON.stringify(ready), /last run failed/);
assert.match(JSON.stringify(ready), /last run failed/);
t.sources.setDown(false);
t.sources.setDown(false);