openvibers/openvibe.coupons Commits

Coupons moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.

OP OpenVibers committed 28 Sept 2026 · 040dc3e3b7 · 39 files, +1131 −679

- Schema: migrations/0001_initial.sql (openvibe-sdk tools/asyncify gen-migration: the SQLite schema converted, the
  Publishing index-hooks table, the SDK inbox and outbox).
- server/db.js: openDb (DATABASE_URL through PgBouncer, migrations as the owner, PGlite in development) and
  createStore; store.tx is an ambient transaction.
- Every read and write is awaited (asyncify plus hand fixes: views in lists through Promise.all; the home page's
  per-shop active counts in one GROUP BY query instead of one query per shop; staff actions awaited through their
  wrapper; the Sources importer and the outbox stay synchronous to construct, the importer's state row is written
  on its first run). SQL: lower() for COLLATE NOCASE, ILIKE, ON CONFLICT DO NOTHING, the hold upsert's counter
  qualified, each reporter's latest report by DISTINCT ON (SQLite took the bare column from the MAX row), merchant
  search by EXISTS instead of DISTINCT with ORDER BY.
- Per-actor limits in Valkey; /api/ready reports the store that answered.
- scripts/migrate-to-postgres.js (runSqliteMigration); rehearsed on a copy of production: verified.
- Tests on createTestDb (PGlite; npm run test:pg through PgBouncer). Pins: openvibe-publishing v1.0.0,
  openvibe-contracts v0.76.0, openvibe-sdk v0.20.0. README and STATUS.json follow.

npm test 13/13 on PGlite; npm run test:pg 13/13 through PgBouncer.

Changes

ddiff --git a/.env.example b/.env.example
iindex 87e02be..962ac94 100644
---- a/.env.example
++++ b/.env.example
@@@ -13,6 +13,14 @@ BASE_URL=http://localhost:4850
TRUST_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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex efcf5dc..38f01bc 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
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
ddiff --git a/README.md b/README.md
iindex 1d57702..144a1ec 100644
---- a/README.md
++++ b/README.md
@@@ -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_OAUTH
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`).
--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):
ddiff --git a/STATUS.json b/STATUS.json
iindex c0dc731..4687b7b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/deploy/systemd/openvibe-coupons.service b/deploy/systemd/openvibe-coupons.service
iindex 050a5db..2266bc8 100644
---- a/deploy/systemd/openvibe-coupons.service
++++ b/deploy/systemd/openvibe-coupons.service
@@@ -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=simple
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..36f3035
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 1b67ef4..a2bf05c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 1a29e6f..4f3efd3 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 0000000..8860679
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 15531d7..7958601 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) ─────────
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex fddd4b1..9504cee 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -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');
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
iindex 505e64a..6a564d2 100644
---- a/server/clients/sources.js
++++ b/server/clients/sources.js
@@@ -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,
};
}
ddiff --git a/server/config.js b/server/config.js
iindex abec05b..e512cc5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/db.js b/server/db.js
iindex b2395a5..0de88b5 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex fe7ceaa..f7506c5 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -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_id
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'
@@@ -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) latest
GROUP 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),
};
}
ddiff --git a/server/domain/installs.js b/server/domain/installs.js
iindex c95800e..84e51ab 100644
---- a/server/domain/installs.js
++++ b/server/domain/installs.js
@@@ -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) };
},
};
ddiff --git a/server/domain/merchants.js b/server/domain/merchants.js
iindex 65a1683..081261d 100644
---- a/server/domain/merchants.js
++++ b/server/domain/merchants.js
@@@ -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_id
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'"),
-- 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,
};
}
ddiff --git a/server/domain/moderation.js b/server/domain/moderation.js
iindex 521c0b9..692eff7 100644
---- a/server/domain/moderation.js
++++ b/server/domain/moderation.js
@@@ -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 });
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex c71161a..daf2afb 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -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',
ddiff --git a/server/domain/reports.js b/server/domain/reports.js
iindex 17f64db..fdfecf0 100644
---- a/server/domain/reports.js
++++ b/server/domain/reports.js
@@@ -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 }) };
});
}
ddiff --git a/server/domain/watches.js b/server/domain/watches.js
iindex 98d28bf..4056d97 100644
---- a/server/domain/watches.js
++++ b/server/domain/watches.js
@@@ -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)),
};
}
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 8bb809f..bb8474b 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -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 }),
};
}
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 65e8be8..1ac324f 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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, lo
limits: { 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}`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 5717a13..a5a699f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex c34ea29..a6a3f44 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -25,12 +25,12 @@ function createDiscoveryRoutes({ config, store, merchants, coupons, publication
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);
-- 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, publication
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.' };
-- 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');
});
ddiff --git a/server/http/public.js b/server/http/public.js
iindex d73e770..69af93d 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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()}.`;
}));
ddiff --git a/server/index.js b/server/index.js
iindex 8eddf1e..16c16d0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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); });
ddiff --git a/server/observability.js b/server/observability.js
iindex cffc321..c57a9f1 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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, reports
checks: [
{
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() } };
},
},
{
ddiff --git a/server/worker.js b/server/worker.js
iindex 43d5b5a..4a4cab7 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -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;
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex d96b100..a9ffd61 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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) } });
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
iindex 1956855..2e43efe 100644
---- a/test/delegation.test.js
++++ b/test/delegation.test.js
@@@ -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 () => {
ddiff --git a/test/extension-privacy.test.js b/test/extension-privacy.test.js
iindex 20b01ab..5c0cd91 100644
---- a/test/extension-privacy.test.js
++++ b/test/extension-privacy.test.js
@@@ -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);
});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 11ae750..17049d2 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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;
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..71791e0
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex 88b44e3..ca217f9 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -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);
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
iindex cc4a984..62fe49f 100644
---- a/test/moderation-events.test.js
++++ b/test/moderation-events.test.js
@@@ -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');
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 0bff723..04f502e 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -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/);
ddiff --git a/test/reports.test.js b/test/reports.test.js
iindex dbb50dd..0ec21df 100644
---- a/test/reports.test.js
++++ b/test/reports.test.js
@@@ -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();
ddiff --git a/test/sources.test.js b/test/sources.test.js
iindex 3bd4dcd..c3c62a1 100644
---- a/test/sources.test.js
++++ b/test/sources.test.js
@@@ -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);