# OpenVibe.Deals > Deals submitted and voted on by the community, with source, price and freshness always shown. **Live site:** https://openvibe.deals · **API docs:** https://openvibe.services · **For AI:** https://openvibe.deals/llms.txt **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` **Status:** alpha (roadmap Wave 18, Deals). The service runs and its tests pass. It is **public at `openvibe.deals` since 2026-10-08** (contracts 0.118.0), with offers from the DealNews daily feed through OpenVibe.Sources. Its capabilities and service manifest are registered in openvibe-contracts v0.97.0. **Domain:** `openvibe.deals` · **Port:** 4840 · **Service id:** `deals` **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.9; roadmap §15.13, §29, §32. **License:** AGPL-3.0 (same as every OpenVibe service). ## Purpose Normalised deal submissions and imports, product and store records, offers with timestamped price, shipping, condition and availability observations, server-authoritative votes and hotness, duplicate resolution, watches and saved searches. Discussion is OpenVibe.Community's; imports come from OpenVibe.Sources; notifications are events for OpenVibe.Network. The one rule everything else follows: **a price is an observation.** It has a source and an `observed_at`, it is only ever shown "as of" that time, and an offer is never presented as current without a fresh observation. A price nobody stated stays "not stated" — in the page, the JSON, the structured data, the comparison, the feeds and Search. ## Owns The nine charter tables live in Deals' own PostgreSQL database (`ov_deals` on the host's data role, ADR-035; schema in [migrations/](migrations/)): | Charter table | What it is | |---|---| | `deal_products` | product records (name, brand, category), `/p/:slug` | | `deal_product_aliases` | `gtin` (normalised to 14 digits), `mpn`, `sku`, exact `name`, `url` → one product; an alias never silently moves to another product | | `deal_offers` | an offer: title, link (tracking parameters stripped, except where a source policy keeps it verbatim — see "Per-source policy"), store, product, status (`active`/`expired`/`disabled`), **stated** expiry, review state, and the merge pointer `merged_into` | | `deal_offer_sources` | where an offer came from: the submission, a person's observations, or an OpenVibe.Sources item (typed reference `{sources, item, itm_…, revision}`; the item is never copied) | | `deal_votes` | one row per (offer, subject): `+1` / `-1` / `0` (removed, kept as history), with the weight and IP hash it was cast with | | `deal_hotness_snapshots` | every hotness computation with all of its inputs (formula `hot@1`) | | `deal_watches` | keyword / product / price-below watches and saved searches | | `deal_price_observations` | the only place a price lives: price and currency as stated, shipping, condition, availability, `observed_at`, source | | `deal_flags` | reports by people and system flags (vote rings) for moderators | Companion tables (not charter tables): `deal_stores` (store records by domain — the plan's "store records" obligation; a deviation from the nine-table list, see the final section), `watch_notifications` (the (watch, observation) uniqueness), `moderation_log`, `rate_events`, `import_state`, `subject_projections`, `deal_campaigns` (crowdfunding discovery), the SDK's `event_outbox` / `idempotency_receipts`, and openvibe-publishing's `deal_offer_discussion_refs` and `deal_index_revisions`. Telemetry (openvibe-sdk 0.46, `platform.telemetry-sample@1`): requests are aggregated by route template, method and status class, never raw URL; `/shared`, static assets, health, readiness and `/metrics` are skipped. `server/app.js` builds `createHttpTelemetry` and mounts its middleware; `server/index.js` writes one `http.request` sample per key plus the active-requests, p95 and event-loop-lag gauges to `telemetry_samples` every minute and on stop, and deletes rows older than 14 days daily (`migrations/0004_telemetry.sql`). ## Does not own - coupon codes (OpenVibe.Coupons; cross-linked later, separate database) - discussion (OpenVibe.Community threads, referenced by id) - source registry, fetching, robots and terms (OpenVibe.Sources) - notifications delivery (OpenVibe.Network; Deals only emits `deals.watch.matched` and never emails) - identity (OpenVibe.Network subjects; Deals stores `usr_…` ids only) ## What works ### Routes (server-rendered, useful without JavaScript) | Route | What it is | |---|---| | `GET /` | the front page (`server/render/home.js`): a search, the site's numbers (active deals, shops, prices seen in 24 hours, ending this week, categories, crowdfunding campaigns), then editors' picks, hot (voted up), just in, ending soon, under $20 (a fresh stated price), categories, shops and crowdfunding, how it works and questions (FAQPage JSON-LD). A deal shows once; a section with nothing to show is left out. `/?page=N` moved to `/hot?page=N` (301). | | `GET /hot`, `GET /new`, `GET /ending`, `GET /picks` | hot deals (latest `hot@1` snapshots), newest deals, deals whose stated end is in the next 7 days (soonest first) and a source's editors' picks; each card shows the latest price **as of** its time with a fresh/stale badge | | `GET /c`, `GET /c/:category` | deals by category, as each deal's source names it (DealNews' `dealnews:category`, Sources feed@3; migration 0007) | | `GET /d/:slug` | an offer: latest observation "as of", freshness, stated end (or "not stated"), votes and hotness with the formula, description (safe Markdown), AI/import disclosure, **price history table** (every observation, newest first, with its source), sources, merged listings, discussion. Forms: vote, report what the page says now, report a problem, mark expired (submitter), moderation. `…/:slug.json` is the same facts as data. A merged listing answers 301 to its canonical offer; a disabled one 410. | | `GET /p/:slug` | a product: **price comparison** across its offers, each with its own latest observation and time (`.json` too) | | `GET /s/:domain` | a store's deals | | `GET /search?q=` | search (noindex), with "save this search" / "watch these words" | | `GET /crowdfunding?q=&sort=` | crowdfunding discovery: one search opens Kickstarter, Indiegogo, Crowd Supply, Ulule, GoFundMe, Open Collective, Patreon, Wefunder and StartEngine at the words (deep links, nothing read from them), and the campaigns Sources collected (Open Collective) by contributors, newest or most received, amounts "as of" their fetch. Indexable without words; a search is noindex. `/crowdfunding.json` is the same as data (CORS *). | | `GET/POST /submit` | submit a deal (signed in) | | `GET/POST /watches` | watches and saved searches; `POST /watches/:id/delete` | | `GET /mod` | moderators: open flags (incl. vote rings), text waiting for review, possible duplicates; `POST /mod/offers/:slug/(merge\|unmerge\|disable\|enable\|review\|expire)`, `POST /mod/flags/:id/(resolve\|dismiss)` | | `GET /feed.xml`, `/atom.xml`, `/feed.json` | newest active deals, with "as of" times and stable ids `deals:offer:` | | `GET /robots.txt`, `/llms.txt`, `/sitemap.xml`, `/sitemap-pages.xml`, `/sitemap-.xml` | discovery (below): the index names `sitemap-pages.xml` (the front page and every list — hot, new, ending, picks, each category and shop, crowdfunding — while it has deals) and one `/sitemap-.xml` child per 10,000 catalog URLs (offers, then products, always in that order); a child number past the last is 404 | | `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | operations (`/metrics` loopback only; nginx never proxies it) | | `/auth/*` | Network SSO (the same session layer as Community/Blog) | Sitemap entries carry lastmod from each offer or product page's latest recorded change. ### API (`/api/v1`, problem+json errors) Service tokens (audience `openvibe.deals`) are checked for **one capability per route** and act for the person in `X-OV-Subject`; browsers or apps with a Network user JWT are judged by the domain (signed in, submitter, moderator). Public reads need nothing. | Route | Capability | |---|---| | `GET /offers?sort=hot\|new`, `GET /offers/:id`, `GET /offers/:id/hotness`, `GET /products/:slug`, `GET /stores/:domain` | public | | `POST /offers` | `deals.offer.submit` | | `PATCH /offers/:id`, `POST /offers/:id/observations` | `deals.offer.update` | | `POST /offers/:id/expire` | `deals.offer.expire` | | `PUT /offers/:id/vote` · `DELETE /offers/:id/vote` | `deals.vote.set` · `deals.vote.remove` | | `POST /offers/:id/flags` | `deals.flag.create` | | `POST /offers/:id/merge`, `/unmerge` | `deals.offer.merge` | | `POST /offers/:id/disable\|enable\|review`, `GET /flags`, `POST /flags/:id/resolve` | `deals.offer.moderate` | | `POST /products/resolve` | `deals.product.resolve` | | `GET /watches` · `POST /watches` · `DELETE /watches/:id` | `deals.watch.read` · `deals.watch.create` · `deals.watch.delete` | The eight charter capabilities are all there; `deals.offer.merge`, `deals.offer.moderate`, `deals.watch.read` and `deals.flag.create` are additions (released in openvibe-contracts, pinned at v0.97.0; the source proposals stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and [docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)); `server/auth/capabilities.js` decides them with the contracts grant rule. ### Events (SDK outbox, written in the same transaction as the change) | Event | When | Visibility | |---|---|---| | `deals.offer.created` | a submission or an import created an offer | public if listable, else internal | | `deals.offer.updated` | edit, new observation, merge/unmerge, review, disable/enable, source removed | public if listable, else internal | | `deals.offer.expired` | submitter, moderator or stated end time | internal once expired | | `deals.vote.changed` | a person's effective vote changed (payload: counts and hotness) | internal | | `deals.watch.matched` | a watch matched a new observation (payload: `recipient`, offer, observation) — for Network's notification consumer, which does not exist yet | internal | | `deals.moderation.action` | a moderator edited, expired, disabled, enabled, reviewed, merged or unmerged someone else's offer (`offer.*`), or resolved or dismissed a report (`flag.*`); for the network's moderation audit log (ADR-022, `common.moderation-action@1`), never the text | internal | | `deals.index_document.upserted\|deleted` | the OpenVibe.Search document (`search.index-document@1`) or tombstone for offers and products | internal | Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed, `DEALS_EVENTS_SECRET`, inbox-deduplicated). They carry no prices, so they only wake the importer. Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` arrive at the same route and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`). An export part carries the person's profile name, votes, watches, the offers, price reports, products and reports they contributed. A deletion removes their watches (and the notifications under them), votes, name and rate-limit windows. Offers, price reports, products, aliases and reports stay in the shared catalog with the person's id set to NULL, and a vote-ring flag's evidence names them as `deleted`. A staff member's `moderation_log` rows stay attributed (counted as retained). The two subscriptions are created at boot when missing (`startSubscriptions`, needs `EVENTS_URL` and `DEALS_EVENTS_SECRET`); `account_data_events` keeps one receipt per export and deletion. ## Prices and freshness - Every price, shipping cost, condition and availability is a row in `deal_price_observations` with `observed_at` (when it was seen: the moment a person reported it, or the item's `provenance.retrieved_at` for imports) and its source. Prices are never edited in place. - The latest observation is **fresh** while younger than `DEALS_FRESHNESS_HOURS` (48 h), **stale** after; no observation is **unobserved**. Pages print "as of