openvibers/openvibe.deals AGENTS.md
AGENTS.md
| 1 | # AGENTS.md — OpenVibe.Deals |
| 2 | |
| 3 | ## Project overview |
| 4 | |
| 5 | Deals submitted and voted on by the community, with source, price and freshness always shown: |
| 6 | normalised deal submissions and imports, product/store records, offers with timestamped observations, |
| 7 | server-authoritative votes and hotness, duplicate resolution, watches and saved searches. Discussion |
| 8 | is OpenVibe.Community's; imports come from OpenVibe.Sources; notifications are events for Network. |
| 9 | Node.js/Express, PostgreSQL (`ov_deals`), server-rendered, CommonJS, **no build step**. |
| 10 | |
| 11 | - Service id `deals`, port `4840`, origin `https://openvibe.deals`, unit `openvibe-deals.service`, |
| 12 | release root `/opt/openvibe.deals`. |
| 13 | - **The one rule: a price is an observation.** It has a source and an `observed_at`, it is shown |
| 14 | "as of" that time, and an offer is never presented as current without a fresh observation. A price |
| 15 | nobody stated stays "not stated" — in the page, JSON, structured data, comparison, feeds and Search. |
| 16 | |
| 17 | ## What it owns |
| 18 | |
| 19 | The nine charter tables in [migrations/](migrations/): |
| 20 | |
| 21 | | Table | What | |
| 22 | |---|---| |
| 23 | | `deal_products` | product records (name, brand, category), `/p/:slug` | |
| 24 | | `deal_product_aliases` | `gtin` (14 digits), `mpn`, `sku`, exact `name`, `url` → one product; an alias never silently moves | |
| 25 | | `deal_offers` | title, link (tracking stripped except per-source policy), store, product, status `active\|expired\|disabled`, **stated** expiry, review state, `merged_into` | |
| 26 | | `deal_offer_sources` | the submission, a person's observations, or a Sources item reference `{sources, item, itm_…, revision}` — the item is never copied | |
| 27 | | `deal_votes` | one per (offer, subject): `+1`/`-1`/`0` (kept as history), weight and IP hash | |
| 28 | | `deal_hotness_snapshots` | every hotness computation with all inputs (`hot@1`) | |
| 29 | | `deal_watches` | keyword / product / price-below watches and saved searches | |
| 30 | | `deal_price_observations` | the only place a price lives: price/currency, shipping, condition, availability, `observed_at`, source | |
| 31 | | `deal_flags` | reports by people and system flags (vote rings) | |
| 32 | |
| 33 | Companion tables include `deal_stores`, `watch_notifications` (unique watch+observation), |
| 34 | `moderation_log`, `rate_events`, `import_state`, `subject_projections`, `deal_campaigns`, |
| 35 | `event_outbox`/`idempotency_receipts`, and publishing's `deal_offer_discussion_refs`/ |
| 36 | `deal_index_revisions`. [docs/](docs/) records the deviations from the charter list. |
| 37 | |
| 38 | Not owned: comment content (Community), the source items (Sources), notification delivery and |
| 39 | membership (Network/VIP), payments (Billing), file bytes (Media). |
| 40 | |
| 41 | ## Who calls it, and whom it calls |
| 42 | |
| 43 | - Callers: people in the browser; the browser extension/Embed for offers; services with a Network |
| 44 | service token audience `openvibe.deals` and the route's capability; OpenVibe.Sources' pull loop and |
| 45 | signed event consumer. |
| 46 | - Depends on: **Network** (SSO client `deals`, JWKS, `identity.subject.resolve`, future notification |
| 47 | consumer); **Sources** (`sources.item.read`; the seeded `dealnews-daily` is disabled until a person |
| 48 | re-verifies its terms; its terms are encoded in `server/domain/source-policy.js`); **Community** |
| 49 | (`community.comment.write`, optionally moderate); **Events** (outbox, and `events.subscription.manage` |
| 50 | once for `scripts/subscribe.js`); **Search** (consumes `deals.index_document.*`); PostgreSQL and |
| 51 | Valkey; `openvibe-publishing` (seo/JSON-LD/feeds/sitemaps/index hooks, ingest, SSR). |
| 52 | |
| 53 | ## Repository map |
| 54 | |
| 55 | - **Entry:** [server/index.js](server/index.js) — app, worker (Sources pull, hotness, expiry), |
| 56 | outbox; graceful stop. |
| 57 | - **App:** [server/app.js](server/app.js) — helmet/CSP, `/auth`, `/shared`, `/internal/config`, |
| 58 | `/internal/events`, `/api/v1`, pages, discovery, health/ready. |
| 59 | - **Config/db:** [server/config.js](server/config.js), [server/db.js](server/db.js), |
| 60 | [server/settings.js](server/settings.js). |
| 61 | - **Domain:** [server/domain/](server/domain/) — `reads.js`, `catalog.js`, `publication.js`, |
| 62 | `indexing.js`, `hotness.js`, `watches.js`, `limits.js`, `access.js`, `offers.js`, `flags.js`, |
| 63 | `votes.js`, `listings.js`, `source-items.js`, `source-policy.js`, `account-data.js`. |
| 64 | - **Clients:** [server/clients/network.js](server/clients/network.js), `community.js`, and publishing's |
| 65 | `openvibe-publishing/ingest`. |
| 66 | - **HTTP:** [server/http/api.js](server/http/api.js), `pages.js`, `discovery.js`, `events.js`, |
| 67 | `actor-limits.js`; views [server/render/views.js](server/render/views.js). |
| 68 | - **Auth:** [server/auth/viewer.js](server/auth/viewer.js), [server/sharing.js](server/sharing.js). |
| 69 | - **Worker:** [server/worker.js](server/worker.js). |
| 70 | - **Deploy:** [deploy/systemd/openvibe-deals.service](deploy/systemd/openvibe-deals.service), |
| 71 | [deploy/nginx/openvibe.deals.conf](deploy/nginx/openvibe.deals.conf). |
| 72 | |
| 73 | ## Commands |
| 74 | |
| 75 | ```bash |
| 76 | npm ci |
| 77 | npm run dev # NODE_ENV=development node server/index.js |
| 78 | npm start # node server/index.js, http://127.0.0.1:4840 |
| 79 | npm test # test/run.js, PGlite, mock Network/Sources/Community |
| 80 | npm run test:pg # same on PostgreSQL (DEALS_TEST_STORE=pg) |
| 81 | npm test -- freshness # filter by file name; --strict makes a skip fail |
| 82 | ov test # the harness's sharded run of what the change reaches |
| 83 | ``` |
| 84 | |
| 85 | ## Database and migrations |
| 86 | |
| 87 | `ov_deals` on the host's data role (ADR-035); PGlite in development/tests. Async through |
| 88 | `openvibe-sdk/db`; migrations `migrations/NNNN_*.sql` applied at boot; never edit an applied one, add a |
| 89 | new number. The price observation is the only price storage; do not add a price column to |
| 90 | `deal_offers`. Votes keep history (`0` means removed); merges keep `merged_into` and never delete the |
| 91 | loser. Outbox rows are written in the same transaction as the change. |
| 92 | |
| 93 | ## Auth, votes and moderation |
| 94 | |
| 95 | - People sign in through Network SSO (OAuth client `deals`); a person's subject is the identity, never a |
| 96 | body/query integer. Service callers need a service token audience `openvibe.deals` and the route's |
| 97 | capability; `X-OV-Subject` scopes a first-party service call. |
| 98 | - **Votes are server-authoritative.** The server computes the weight and hotness; a client-supplied |
| 99 | score is ignored. Vote rings raise a system flag for moderators. |
| 100 | - Watches and saved searches are per person; notifications are events (`deals.watch.matched`) — no |
| 101 | consumer exists yet, so nothing is "sent" by Deals. |
| 102 | - Moderation actions are audited; a flagged vote ring can be removed without erasing history. |
| 103 | |
| 104 | ## Frontend loading model |
| 105 | |
| 106 | Every page is server-rendered and **useful without JavaScript**; there is no SPA, bundler or |
| 107 | transpiler. `openvibe-shared` supplies the Frame, legal/SEO and `/shared/*` from the pinned |
| 108 | node_modules; `openvibe-publishing` supplies JSON-LD, feeds, sitemaps and SSR. Discovery is generated |
| 109 | from the same data. CSP and cache policy are in [server/app.js](server/app.js) and |
| 110 | `openvibe-shared/cache-policy`. |
| 111 | |
| 112 | ## Deploy |
| 113 | |
| 114 | `sudo ovhost deploy deals` (git-checkout: fetch, fast-forward `/opt/openvibe.deals`, install on a |
| 115 | lockfile change, restart, wait for `/api/ready`). Unit `openvibe-deals.service` on |
| 116 | `127.0.0.1:4840`, env `/etc/openvibe/deals.env`; the release migrates `ov_deals` at boot; vhost |
| 117 | [deploy/nginx/openvibe.deals.conf](deploy/nginx/openvibe.deals.conf). Rollback |
| 118 | `sudo ovhost rollback deals --to <sha>`; migrations only add tables and columns. |
| 119 | |
| 120 | ## Tests |
| 121 | |
| 122 | `npm test` runs each `test/*.test.js` in its own process with PGlite and in-process Network/Sources/ |
| 123 | Community mocks. The README's acceptance table maps each guarantee to its test: price observations and |
| 124 | freshness/as-of rendering, unknown stays unknown, votes/hotness/abuse, duplicate merge, watches and |
| 125 | saved searches, Sources import and per-source link policy, deletion/export, privacy and discovery. |
| 126 | `ov test` runs the same. |
| 127 | |
| 128 | ## Conventions |
| 129 | |
| 130 | - CommonJS, 4-space indent, single quotes, semicolons; domain in `server/domain/`, HTTP in |
| 131 | `server/http/`, no SQL in routes. |
| 132 | - Money is integer minor units with an ISO 4217 currency; a price is never a float and never a bare |
| 133 | number without its currency and `observed_at`. |
| 134 | - IDs/errors/capabilities from `openvibe-contracts`; errors are problem+json with stable codes. |
| 135 | - Time is UTC and rendered "as of" the observation; a page may say "not stated", never invent a price. |
| 136 | |
| 137 | ## Pitfalls |
| 138 | |
| 139 | - **Never present a price as current.** Render the observation with `observed_at` and the source, or |
| 140 | say it is not stated. A cached/stale page must keep its as-of date (and Search/JSON must agree). |
| 141 | - **Do not copy a Sources item into Deals.** Store the typed reference `{sources, item, itm_…, |
| 142 | revision}` and read through the source; a copy drifts and breaks attribution. |
| 143 | - **An alias never silently moves** to another product; a wrong mapping needs a correction and an audit |
| 144 | trail, not an update in place. |
| 145 | - **Track-only links are stripped**, except where a source policy keeps a link verbatim; the policy |
| 146 | lives in `server/domain/source-policy.js`, not in the renderer. |
| 147 | - **A merge keeps both records**: the loser gets `merged_into`, its votes/observations stay for history, |
| 148 | and the API redirects/canonicalises; do not hard-delete. |
| 149 | - **Only one notification per (watch, observation)** — the unique row is the guard; do not "notify |
| 150 | again" on a retry. |
| 151 | - **Votes are not client-computed** and `0` is a removal kept as history; a vote ring raises a flag. |
| 152 | - **No delivery pretence:** `deals.watch.matched` is emitted but no consumer exists yet; the UI must not |
| 153 | say a notification was sent. |
| 154 | - **The charter list is nine tables; the deviations are documented** (`deal_stores`, etc.) — adding a |
| 155 | tenth charter table needs the doc updated, not just the migration. |
| 156 | |
| 157 | ## Documentation |
| 158 | |
| 159 | - [README.md](README.md) — purpose, tables, routes, API, events, prices/freshness, duplicates, votes, |
| 160 | watches, imports, discovery, acceptance, security and deploy. |
| 161 | - [docs/](docs/) — the charter deviations and source policy notes. |
| 162 | - OpenVibe.Contracts holds `deals.*` shapes/capabilities; Sources, Community and publishing own their |
| 163 | contracts. |
| 164 |