openvibers/openvibe.food Commits
Food recalls: the FDA's food enforcement reports from OpenVibe.Sources, at /recalls and on the home page (#73)
Changes
@@@ -27,6 +27,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route || Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested || Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) |++| Food recalls | [/recalls](server/http/recall-pages.js), [server/food/recalls.js](server/food/recalls.js) | The FDA's food enforcement reports, read from OpenVibe.Sources (source `openfda-food-enforcement`, category `food`, `sources.item.read`): Food follows Sources' change feed hourly from its cursor (`openvibe-publishing/ingest`) and keeps each recall as the report states it (product, reason, company, class, status, where it was sold, dates). `/recalls` filters by words, class and the company's state; `/recalls/:number` is one recall (noindex, as the source's registry entry asks). Every page carries openFDA's note: unvalidated, not for medical decisions, no FDA endorsement. Food never calls the FDA; `OV_SOURCES_INTERNAL_URL` (default `http://127.0.0.1:4720`, empty turns it off) || Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown || The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services || Household chat | [/plan/p/:id](server/http/pages.js), [server/food/household-chat.js](server/food/household-chat.js) | The owner of a shared plan starts a private room on OpenVibe.Chat for the household, made through Chat's room API as the owner, with every member added by account subject (and anyone who joins later, when the owner opens the plan) and **Pantry**, an AI member linked to the plan. Writing `@pantry` there asks it: Chat reads the household from `GET /internal/households/:id/chat-context` (`food.household.read`, Chat's service token) — the plan, what is still to buy and every member's cupboard — and OpenVibe.AI answers. The plan's page links the room for everyone |@@@ -56,6 +57,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by- `food_library_recipes`, `food_library_runs` (0007) — the recipe library: each kept recipe with its ingredients (foodlist slugs), steps, what to buy and its typical cost, the AI run, and who checked it; one row per theme run.++- `food_recalls`, `food_ingest_cursor` (0009) — the FDA's food recall reports as OpenVibe.Sources holds them, one row++ per recall number, and the cursor in Sources' change feed. Public domain data; nothing about a person.++0001 creates no tables. The service is the authority for these rows and for nothing else.## Does not ownnnew file mode 100644@@@ -0,0 +1,31 @@++-- phase: expand++-- OpenVibe.Food's food recalls (server/food/recalls.js): the FDA's food enforcement reports, as OpenVibe.Sources collects++-- them (source openfda-food-enforcement, category food). Food never calls the FDA: it follows Sources' change feed from++-- its cursor here (openvibe-publishing/ingest, prefix food) and keeps each recall as the report states it. The reports++-- are public domain (CC0, openFDA); nothing here belongs to a person.++++-- The DDL is openvibe-publishing lib/ingest.js schema('food').++CREATE TABLE IF NOT EXISTS food_ingest_cursor (++ name text COLLATE "C" NOT NULL,++ cursor bigint NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (name)++);++++CREATE TABLE food_recalls (++ recall_number text COLLATE "C" PRIMARY KEY, -- the FDA's own number (F-0123-2027), Sources' identity++ item_id text COLLATE "C" NOT NULL UNIQUE, -- the Sources item++ revision integer NOT NULL, -- the Sources item's revision this row holds++ product text NOT NULL, -- product_description, as stated++ reason text, -- reason_for_recall++ firm text, -- recalling_firm++ classification text, -- Class I, Class II or Class III++ status text, -- Ongoing, Completed, Terminated++ state text, -- the recalling firm's state++ distribution text, -- distribution_pattern++ voluntary text, -- voluntary_mandated++ initiated_on text COLLATE "C", -- YYYY-MM-DD++ reported_on text COLLATE "C", -- YYYY-MM-DD, the enforcement report's date++ retrieved_at text COLLATE "C" NOT NULL -- when Sources last read it, ISO 8601 UTC++);++CREATE INDEX food_recalls_reported ON food_recalls (reported_on DESC, recall_number);nnew file mode 100644@@@ -0,0 +1,16 @@++/* OpenVibe.Food — food recalls (/recalls, /recalls/:number) and the home page's recall block. */++.recalls { list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 12px; }++.recall { border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); padding: 14px 16px; min-width: 0; }++.recall p { margin: 4px 0; overflow-wrap: anywhere; }++.recall-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 0 0 6px; }++.recall-product { margin: 2px 0 6px; font-size: 1.05rem; line-height: 1.35; overflow-wrap: anywhere; }++.recall-product a { color: var(--text-primary, #e6edf7); }++.recall-title { overflow-wrap: anywhere; }++.recall-classes { margin: 10px 0 14px; }++.recall-classes ul { margin: 8px 0 0; padding-left: 1.1rem; }++.recall-classes li { margin: 0 0 6px; }++.recall-facts { display: grid; grid-template-columns: minmax(120px, max-content) 1fr; gap: 8px 18px; margin: 8px 0 18px; }++.recall-facts dt { color: var(--text-secondary, #96a7c2); }++.recall-facts dd { margin: 0; overflow-wrap: anywhere; }++@media (max-width: 520px) { .recall-facts { grid-template-columns: 1fr; gap: 2px; } .recall-facts dd { margin-bottom: 8px; } }++.recall-note { margin-top: 18px; }@@@ -90,6 +90,8 @@ async function createApp(opts = {}) {ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });// Checked recipes in OpenVibe.Search (server/food/search-index.js; off without FOOD_SEARCH_URL).ctx.recipeSearch = require('./food/search-index').createRecipeSearchIndex({ config, library: ctx.library, log });++ // Food recalls from OpenVibe.Sources (server/food/recalls.js); a test passes a stand-in source through opts.recallSource.++ ctx.recalls = require('./food/recalls').createRecalls({ config, s, fetchImpl, log, source: opts.recallSource || null });const app = express();app.disable('x-powered-by');@@@ -65,6 +65,9 @@ function load(env = process.env) {networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),// OpenVibe.Search's owner API (server/food/search-index.js): the checked library recipes as documents. Unset: off.search: { url: trim(env.FOOD_SEARCH_URL || '') },++ // OpenVibe.Sources (server/food/recalls.js): the FDA's food recall reports, read from its food category with++ // this service's token (sources.item.read). Food never calls the FDA itself. Empty: off.++ sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL == null ? 'http://127.0.0.1:4720' : env.OV_SOURCES_INTERNAL_URL) },// OpenVibe.Chat (server/food/household-chat.js): a household's room, made as its owner through Chat's room API.// `internalUrl` is where this service calls it (the loopback address in production), `publicUrl` what people open.chat: { internalUrl: trim(env.FOOD_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400'), publicUrl: trim(env.FOOD_CHAT_PUBLIC_URL || 'https://openvibe.chat') },nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++++/**++ * Food recalls: the FDA's food enforcement reports, read from OpenVibe.Sources (source openfda-food-enforcement,++ * category food), never from the FDA. Sources fetches openFDA once a day under its robots and terms checks and keeps++ * each report as an item; Food follows Sources' change feed (openvibe-publishing/ingest: the cursor in++ * food_ingest_cursor, one transaction per page) and keeps one row per recall in food_recalls, exactly as the report++ * states it. An item Sources removes (a takedown, a licence change) is deleted here.++ *++ * The reports are public domain (CC0). openFDA's terms ask that a use not imply FDA endorsement and say the data are++ * unvalidated and not for medical decisions: every page that shows a recall carries NOTE.++ *++ * Pulls 30 seconds after start and then hourly. Off without OV_SOURCES_INTERNAL_URL and the OAuth client secret (a++ * test passes its own source).++ */++const ingest = require('openvibe-publishing/ingest');++++const SOURCE_KEY = 'openfda-food-enforcement';++const CURSOR = 'sources.food.after';++const EVERY_MS = 60 * 60 * 1000;++const FIRST_MS = 30 * 1000;++const CLASSES = ['Class I', 'Class II', 'Class III'];++// What each class means, in the FDA's words (fda.gov, "Recalls Background and Definitions").++const CLASS_TEXT = {++ 'Class I': 'a reasonable probability that the product will cause serious adverse health consequences or death',++ 'Class II': 'the product may cause temporary or medically reversible adverse health consequences, or the chance of serious harm is remote',++ 'Class III': 'the product is not likely to cause adverse health consequences',++};++const NOTE = 'Recalls are the FDA\'s food enforcement reports through openFDA (public domain). They are not validated by openFDA, not for medical decisions, and OpenVibe.Food is not endorsed by the FDA.';++const FDA_REPORTS = 'https://www.accessdata.fda.gov/scripts/ires/index.cfm';++++const text = (v, max) => (v == null || v === '' ? null : String(v).replace(/\s+/g, ' ').trim().slice(0, max) || null);++/** The reports' YYYYMMDD → YYYY-MM-DD, or null. */++function day(v) {++ const m = /^(\d{4})(\d{2})(\d{2})$/.exec(String(v == null ? '' : v).trim());++ if (!m || Number(m[2]) < 1 || Number(m[2]) > 12 || Number(m[3]) < 1 || Number(m[3]) > 31) return null;++ return `${m[1]}-${m[2]}-${m[3]}`;++}++const NUMBER_RE = /^[A-Z]-\d{3,5}-\d{4}$/;++++/** A Sources item → a food_recalls row, or null when it is not a recall this page can show. */++function rowOf(item) {++ const f = (item && item.fields) || {};++ const number = text(item && item.identity, 40);++ const product = text(item && item.title, 2000);++ if (!number || !NUMBER_RE.test(number) || !product) return null;++ return {++ recall_number: number,++ item_id: String(item.id),++ revision: Number(item.revision) || 1,++ product,++ reason: text(item.summary, 2000),++ firm: text(f.recalling_firm, 300),++ classification: CLASSES.includes(f.classification) ? f.classification : null,++ status: text(f.status, 40),++ state: text(f.state, 40),++ distribution: text(f.distribution_pattern, 2000),++ voluntary: text(f.voluntary_mandated, 120),++ initiated_on: day(f.recall_initiation_date),++ reported_on: day(f.report_date),++ retrieved_at: (item.provenance && item.provenance.retrieved_at) || new Date(0).toISOString(),++ };++}++++function createRecalls({ config, s, fetchImpl = globalThis.fetch, log = console, source = null }) {++ const url = String((config.sources && config.sources.internalUrl) || '').replace(/\/+$/, '');++ const client = source || (url && config.oauth.clientSecret++ ? ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: url, category: 'food' } }, fetchImpl })++ : null);++ const cursor = ingest.createChangeCursor(s.db, { prefix: 'food', now: s.now });++ const stats = { pulls: 0, applied: 0, removed: 0, last_at: null, last_error: null };++ let timer = null;++ let first = null;++ let running = false;++++ async function apply(item, t) {++ if (!item || item.source_key !== SOURCE_KEY || item.kind !== 'recall') return 'hold';++ if (item.removed) {++ await t.query('DELETE FROM food_recalls WHERE item_id = $1', [String(item.id)]);++ return 'removed';++ }++ const row = rowOf(item);++ if (!row) return 'hold';++ const cols = Object.keys(row);++ // A replayed page (or an older revision arriving late) never takes a row back.++ await t.query(`INSERT INTO food_recalls (${cols.join(', ')}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(', ')})++ ON CONFLICT (recall_number) DO UPDATE SET ${cols.filter((c) => c !== 'recall_number').map((c) => `${c} = EXCLUDED.${c}`).join(', ')}++ WHERE food_recalls.item_id <> EXCLUDED.item_id OR food_recalls.revision <= EXCLUDED.revision`, cols.map((c) => row[c]));++ return 'applied';++ }++++ /** Follow Sources' change feed to its end. Never throws: the caller is a timer or a test. */++ async function pull() {++ if (!client || running) return { skipped: true };++ running = true;++ try {++ const out = await ingest.pullChanges({ db: s.db, cursor, source: client, name: CURSOR, apply });++ stats.pulls++;++ stats.applied += out.applied;++ stats.removed += out.removed;++ stats.last_at = s.iso();++ stats.last_error = null;++ if (out.applied || out.removed) log.log(`[Recalls] ${out.applied} recalls updated, ${out.removed} removed (Sources cursor ${out.after})`);++ return out;++ } catch (err) {++ stats.last_error = text(err && err.message, 300);++ log.warn(`[Recalls] pull from OpenVibe.Sources failed: ${stats.last_error}`);++ return { error: stats.last_error };++ } finally { running = false; }++ }++++ const shape = (r) => (r ? { ...r, class_text: CLASS_TEXT[r.classification] || null } : null);++++ /** Filters shared by list() and count(): words (product, firm, reason), a class, a state. */++ function where({ q, classification, state } = {}) {++ const clauses = [];++ const args = [];++ if (q) {++ args.push(`%${String(q).toLowerCase().replace(/[\\%_]/g, (c) => `\\${c}`)}%`);++ clauses.push(`(lower(product) LIKE $${args.length} OR lower(coalesce(firm, '')) LIKE $${args.length} OR lower(coalesce(reason, '')) LIKE $${args.length})`);++ }++ if (CLASSES.includes(classification)) { args.push(classification); clauses.push(`classification = $${args.length}`); }++ if (state) { args.push(String(state)); clauses.push(`state = $${args.length}`); }++ return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', args };++ }++++ return {++ enabled: Boolean(client),++ stats,++ pull,++ apply,++ /** Newest report first. */++ async list({ q = null, classification = null, state = null, limit = 50, offset = 0 } = {}) {++ const w = where({ q, classification, state });++ const rows = await s.db.many(`SELECT * FROM food_recalls ${w.sql} ORDER BY reported_on DESC NULLS LAST, recall_number DESC LIMIT $${w.args.length + 1} OFFSET $${w.args.length + 2}`,++ [...w.args, Math.min(Math.max(1, limit), 200), Math.max(0, offset)]);++ return rows.map(shape);++ },++ async count(filters = {}) {++ const w = where(filters);++ return Number(await s.db.value(`SELECT count(*) FROM food_recalls ${w.sql}`, w.args)) || 0;++ },++ async get(number) { return NUMBER_RE.test(String(number)) ? shape(await s.db.maybe('SELECT * FROM food_recalls WHERE recall_number = $1', [String(number)])) : null; },++ /** The states the recalling firms are in, most recalls first (the filter's choices). */++ async states() { return s.db.many('SELECT state, count(*)::int AS n FROM food_recalls WHERE state IS NOT NULL GROUP BY state ORDER BY n DESC, state'); },++ /** Every recall number with its report date (the sitemap). */++ async numbers() { return s.db.many('SELECT recall_number, reported_on FROM food_recalls ORDER BY reported_on DESC NULLS LAST, recall_number DESC LIMIT 5000'); },++ start() {++ if (!client || timer) return false;++ timer = setInterval(() => { pull().catch(() => {}); }, EVERY_MS);++ timer.unref();++ first = setTimeout(() => { pull().catch(() => {}); }, FIRST_MS);++ first.unref();++ return true;++ },++ stop() { clearInterval(timer); clearTimeout(first); timer = null; first = null; },++ };++}++++module.exports = { createRecalls, rowOf, day, SOURCE_KEY, CLASSES, CLASS_TEXT, NOTE, FDA_REPORTS };@@@ -24,6 +24,7 @@ const PAGE_TEXT = {'/near': ['Food near you', 'Food banks, soup kitchens, community fridges and budget grocery stores near a place, from OpenStreetMap.'],'/plan': ['Meal plan', 'Plan meals for one person, a couple or a group for up to fourteen days, with a shopping list and an estimated cost.'],'/foods': ['The food list', 'Every food OpenVibe.Food plans with: nutrition per serving and per 100 g, and a typical price band.'],++ '/recalls': ['Food recalls', 'The FDA\'s food recall reports, newest first: what was recalled, by whom, why, where it was sold and how serious it is (openFDA, public domain).'],'/updates': ['What shipped on OpenVibe.Food', 'This site\'s update log, from the network changelog feed.'],};const SUMMARIES = {@@@ -31,6 +32,7 @@ const SUMMARIES = {'/near': 'Food banks and budget grocery stores near a place, from OpenStreetMap.','/plan': 'A meal plan with a shopping list and an estimated cost.','/foods': 'Nutrition per serving and per 100 g, and a typical price band, for every food.',++ '/recalls': 'The FDA\'s food recall reports, newest first.','/updates': 'What shipped on the site.',};@@@ -57,6 +59,7 @@ const publicPages = () => [{ path: '/near', changefreq: 'weekly', priority: 0.9 },{ path: '/plan', changefreq: 'weekly', priority: 0.9 },{ path: '/foods', changefreq: 'weekly', priority: 0.8 },++ { path: '/recalls', changefreq: 'daily', priority: 0.8 },{ path: '/updates', changefreq: 'daily', priority: 0.5 },...foods.FOODS.map((f) => ({path: `/foods/${f.slug}`, changefreq: 'monthly', priority: 0.5,@@@ -88,6 +91,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'Food near you', url: abs('/near'), note: 'food banks and budget grocery stores, from OpenStreetMap' },{ title: 'Meal plan', url: abs('/plan'), note: 'a shopping list and an estimated cost' },{ title: 'The food list', url: abs('/foods'), note: 'nutrition and typical prices' },++ { title: 'Food recalls', url: abs('/recalls'), note: 'the FDA\'s food recall reports through OpenVibe.Sources, newest first, by product, company, class and state' },{ title: 'Recipe library', url: abs('/recipes/library'), note: 'budget recipes written by OpenVibe.AI from the food list, each with what to buy and its typical cost; a recipe a person checked is marked so' },{ title: 'What shipped on OpenVibe.Food', url: abs('/updates') },] },@@@ -9,6 +9,7 @@* /foods the food list, grouped* /foods/:slug one food: nutrition per serving and per 100 g, typical prices* /pantry what is in your cupboard, and what you can make from it (signed in)++ * /recalls the FDA's food recall reports, through OpenVibe.Sources (http/recall-pages.js)* /updates the update log** Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.@@@ -34,6 +35,7 @@ const { suggestRecipes } = require('../food/recipes');const store = require('../food/store');const delivery = require('../food/delivery');const householdChat = require('../food/household-chat');++const { date: recallDate } = require('./recall-pages');const seo = require('openvibe-shared/seo');const { products } = require('openvibe-contracts');@@@ -117,6 +119,9 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watchlib = { total, cheap };} catch { lib = { total: 0, cheap: [] }; }}++ // The newest FDA recall reports (server/food/recalls.js); none yet or a failed read leaves the block out.++ let recalls = { total: 0, latest: [] };++ try { const [total, latest] = await Promise.all([ctx.recalls.count(), ctx.recalls.list({ limit: 4 })]); recalls = { total, latest }; } catch { /* left out */ }const base = String(config.baseUrl).replace(/\/+$/, '');const hero = showcase.hero({eyebrow: `${SITE_NAME} · ${TAGLINE}`,@@@ -136,6 +141,7 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch{ value: Object.keys(foods.STORES).length, label: 'budget chains priced', href: '/foods' },{ value: delivery.APPS.length, label: 'delivery apps in one search', href: '/delivery' },{ value: delivery.KITS.length, label: 'meal kits side by side', href: '/delivery#kits' },++ { value: recalls.total, label: 'FDA food recalls, newest first', href: '/recalls' },].filter((x) => x.value);page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,@@@ -172,6 +178,9 @@ ${lib.cheap.length ? html`<section class="sc-sec" aria-labelledby="h-meals"><h2<p class="sc-lede">Food banks, soup kitchens, community fridges and free pantry boxes, with hours, from OpenStreetMap. Search any town or postcode, or start with a city.</p><ul class="home-chips">${NEAR_CITIES.map((c) => html`<li><a href="/near?${new URLSearchParams({ q: c })}">${c.split(',')[0]}</a></li>`)}</ul><p><a class="sc-btn sc-primary" href="/near">Search near you</a></p></section>++${recalls.latest.length ? html`<section class="sc-sec" aria-labelledby="h-recalls"><h2 id="h-recalls">Food recalls</h2>++<p class="sc-lede">The newest of the FDA's food recall reports: what was recalled, by whom and how serious it is. <a href="/recalls">All ${recalls.total} recalls</a></p>++<ul class="home-cards">${recalls.latest.map((x) => html`<li><a href="/recalls/${x.recall_number}"><b>${x.product.length > 90 ? `${x.product.slice(0, 89).replace(/\s+\S*$/, '')}…` : x.product}</b><span class="hc-main"><strong>${x.classification || 'Recall'}</strong> ${x.firm || ''}</span><span class="hc-sub">${[x.reported_on ? `Reported ${recallDate(x.reported_on)}` : null, x.state].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>` : ''}${raw(showcase.features({title: 'Everything food',lede: 'All working today: no card, no account needed to look around.',@@@ -875,6 +884,8 @@ ${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-r.use(createDiscoveryRoutes(ctx));// The recipe library (server/food/library.js, http/library-routes.js).require('./library-routes').mountLibrary(r, { ctx, page, config, signedIn, express });++ // Food recalls (server/food/recalls.js, http/recall-pages.js).++ require('./recall-pages').mountRecalls(r, { ctx, page, config, signedIn, publicCache: PUBLIC_CACHE });return r;}nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * Food recalls (server/food/recalls.js), from the FDA's food enforcement reports through OpenVibe.Sources:++ *++ * GET /recalls every recall, newest report first (30 a page), by words, class and state++ * GET /recalls/:number one recall (F-0123-2027): the product, why, who, where it was sold, its status++ *++ * Every page carries openFDA's note (unvalidated, not for medical decisions, no endorsement). The list is indexed;++ * a single recall is noindex, as the source's registry entry asks (Sources' default_indexability).++ */++const showcase = require('openvibe-shared/showcase');++const { html, raw, notice, badge } = require('../render/html');++const { CLASSES, CLASS_TEXT, NOTE, FDA_REPORTS } = require('../food/recalls');++++const PER_PAGE = 30;++const CLASS_BADGE = { 'Class I': 'bad', 'Class II': 'warn', 'Class III': '' };++const str = (v, max = 80) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);++const short = (s, max) => (s && s.length > max ? `${s.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : s || '');++/** 2026-10-14 → 14 Oct 2026 (the reports give days, not times). */++const date = (d) => (d ? new Date(`${d}T00:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : '—');++++const classBadge = (c) => (c ? badge(c, CLASS_BADGE[c] || '') : '');++const noteBlock = () => html`<p class="muted small recall-note">${NOTE} <a href="${FDA_REPORTS}" rel="noopener">The FDA's enforcement reports</a> are the record.</p>`;++++/** One recall as a list item (the list page and the home page). */++function recallCard(x, { reason = true } = {}) {++ return html`<li class="recall"><p class="recall-head">${classBadge(x.classification)} ${x.status ? badge(x.status) : ''} <span class="muted small">Reported ${date(x.reported_on)}</span></p>++<h2 class="recall-product"><a href="/recalls/${x.recall_number}">${short(x.product, 180)}</a></h2>++<p class="small">${x.firm ? html`<b>${x.firm}</b>` : ''}${x.state ? html` · ${x.state}` : ''}${x.distribution ? html` · sold in ${short(x.distribution, 120)}` : ''}</p>++${reason && x.reason ? html`<p class="small muted">${short(x.reason, 220)}</p>` : ''}</li>`;++}++++function mountRecalls(r, { ctx, page, config, signedIn, publicCache }) {++ const recalls = ctx.recalls;++++ r.get('/recalls', async (req, res) => {++ const q = str(req.query.q);++ const classification = CLASSES.includes(req.query.class) ? req.query.class : null;++ const state = typeof req.query.state === 'string' && /^[A-Z]{2}$/.test(req.query.state) ? req.query.state : null;++ const n = parseInt(req.query.page, 10);++ const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 200) : 1;++ const filters = { q, classification, state };++ const [total, all, rows, states] = await Promise.all([++ recalls.count(filters), recalls.count(), recalls.list({ ...filters, limit: PER_PAGE, offset: (pageNo - 1) * PER_PAGE }), recalls.states(),++ ]);++ const pages = Math.max(1, Math.ceil(total / PER_PAGE));++ const filtered = Boolean(q || classification || state);++ const qs = (p) => { const u = new URLSearchParams(); if (q) u.set('q', q); if (classification) u.set('class', classification); if (state) u.set('state', state); if (p > 1) u.set('page', String(p)); const s = u.toString(); return s ? `/recalls?${s}` : '/recalls'; };++ page(req, res, {++ title: filtered ? 'Food recalls, filtered' : 'Food recalls from the FDA',++ description: 'The FDA\'s food recall reports, newest first: what was recalled, by whom, why, where it was sold and how serious it is.',++ index: !filtered && pageNo === 1, cache: signedIn(req) ? null : publicCache,++ crumbs: [{ label: 'Food recalls' }], styles: [showcase.STYLESHEET], localStyles: ['css/recalls.css'],++ body: html`<h1>Food recalls</h1>++<p class="lede">The FDA's food recall reports, newest first: what was recalled, by whom, why, and where it was sold. ${all ? html`${all} recalls so far.` : ''}</p>++<form class="ov-form filter-form" method="get" action="/recalls" role="search">++<label for="rq">Product or company</label>++<input id="rq" name="q" type="search" value="${q || ''}" maxlength="80" placeholder="cheese, peanut butter, a brand…">++<label for="rclass">How serious</label>++<select id="rclass" name="class"><option value="">Every class</option>${CLASSES.map((c) => html`<option value="${c}"${c === classification ? raw(' selected') : ''}>${c}</option>`)}</select>++<label for="rstate">Company's state</label>++<select id="rstate" name="state"><option value="">Every state</option>${states.map((s) => html`<option value="${s.state}"${s.state === state ? raw(' selected') : ''}>${s.state} (${s.n})</option>`)}</select>++<button class="sc-btn" type="submit">Filter</button>++</form>++<details class="recall-classes"><summary>What Class I, II and III mean</summary><ul>${CLASSES.map((c) => html`<li>${classBadge(c)} ${CLASS_TEXT[c]}.</li>`)}</ul></details>++${rows.length ? html`<p class="muted small">${total} ${total === 1 ? 'recall' : 'recalls'}${filtered ? ' match' : ''}.${filtered ? html` <a href="/recalls">Show all</a>` : ''}</p>++<ol class="recalls">${rows.map((x) => recallCard(x))}</ol>`++ : all ? notice(html`Nothing matched. <a href="/recalls">Show every recall</a>.`, 'warn')++ : html`<p class="muted">The recall reports appear here once OpenVibe.Sources has read them. Meanwhile, <a href="${FDA_REPORTS}" rel="noopener">search the FDA's own reports</a>.</p>`}++${pages > 1 ? html`<nav class="pager" aria-label="Pages">${pageNo > 1 ? html`<a href="${qs(pageNo - 1)}">← Newer</a>` : ''} <span>Page ${pageNo} of ${pages}</span> ${pageNo < pages ? html`<a href="${qs(pageNo + 1)}">Older →</a>` : ''}</nav>` : ''}++${noteBlock()}`,++ });++ });++++ r.get('/recalls/:number', async (req, res) => {++ const x = await recalls.get(req.params.number);++ if (!x) {++ return page(req, res, { title: 'No such recall', crumbs: [{ label: 'Food recalls', href: '/recalls' }, { label: 'Not found' }],++ body: html`<h1>No such recall</h1><p>No recall here is numbered “${String(req.params.number).slice(0, 40)}”. <a href="/recalls">See every recall</a>.</p>` }, 404);++ }++ const facts = [++ ['Recall number', x.recall_number],++ ['How serious', x.classification ? html`${classBadge(x.classification)} ${x.class_text ? html`<span class="small">${x.class_text}</span>` : ''}` : '—'],++ ['Status', x.status || '—'],++ ['Company', x.firm ? html`${x.firm}${x.state ? ` (${x.state})` : ''}` : '—'],++ ['Where it was sold', x.distribution || '—'],++ ['Who started it', x.voluntary || '—'],++ ['Recall started', date(x.initiated_on)],++ ['Reported', date(x.reported_on)],++ ];++ page(req, res, {++ title: `Recall: ${short(x.product, 70)}`, index: false, cache: signedIn(req) ? null : publicCache,++ description: short(`${x.firm ? `${x.firm}: ` : ''}${x.reason || x.product}`, 160),++ crumbs: [{ label: 'Food recalls', href: '/recalls' }, { label: x.recall_number }],++ styles: [showcase.STYLESHEET], localStyles: ['css/recalls.css'],++ body: html`<h1 class="recall-title">${short(x.product, 200)}</h1>++<p class="recall-head">${classBadge(x.classification)} ${x.status ? badge(x.status) : ''}</p>++${x.product.length > 200 ? html`<p>${x.product}</p>` : ''}++${x.reason ? html`<h2>Why it was recalled</h2><p>${x.reason}</p>` : ''}++<h2>The details</h2>++<dl class="recall-facts">${facts.map(([k, v]) => html`<dt>${k}</dt><dd>${v}</dd>`)}</dl>++<p class="small">Compare the codes, sizes and dates in the description with the package you have.</p>++<p><a href="/recalls">Every food recall</a> · <a href="${FDA_REPORTS}" rel="noopener">Search the FDA's reports for ${x.recall_number}</a></p>++${noteBlock()}`,++ });++ });++}++++module.exports = { mountRecalls, recallCard, date };@@@ -44,7 +44,9 @@ async function start() {// The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.ctx.library.start();ctx.recipeSearch.start();-- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop(), () => ctx.recipeSearch.stop()];++ // Food recalls follow OpenVibe.Sources' change feed hourly (server/food/recalls.js).++ ctx.recalls.start();++ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop(), () => ctx.recipeSearch.stop(), () => ctx.recalls.stop()];createLifecycle({ server, ctx, extra });return { server, ctx };}@@@ -48,7 +48,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai, barcodes: opts.barcodes });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai, barcodes: opts.barcodes, recallSource: opts.recallSource });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++/**++ * Food recalls (server/food/recalls.js, server/http/recall-pages.js):++ * - a Sources item of openfda-food-enforcement becomes one row, as the report states it; anything else is held;++ * - the pull follows the change feed from the cursor, a removed item is deleted, an older revision never wins;++ * - /recalls lists, filters and pages them with openFDA's note; /recalls/:number shows one (noindex); the home shows++ * the newest; the sitemap lists /recalls and no single recall.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { rowOf, day } = require('../server/food/recalls');++++const item = (n, over = {}, fields = {}) => ({++ id: `itm_${n}`, source_key: 'openfda-food-enforcement', category: 'food', kind: 'recall', identity: `F-${String(n).padStart(4, '0')}-2027`,++ canonical_url: null, title: `Frozen pepperoni pizza ${n}, Net Weight 17 oz, UPC 6 66186 27762 ${n}`, summary: `Undeclared milk (${n}).`,++ fields: { recalling_firm: `Firm ${n}`, classification: n % 3 === 0 ? 'Class I' : 'Class II', status: 'Ongoing', state: n % 2 ? 'OH' : 'TX', distribution_pattern: 'KY, OH', voluntary_mandated: 'Voluntary: Firm initiated', recall_initiation_date: '20261001', report_date: `202610${String(10 + (n % 20)).padStart(2, '0')}`, ...fields },++ revision: 1, provenance: { retrieved_at: '2026-10-22T10:00:00.000Z' }, removed: null, change_seq: n, ...over,++});++++/** A stand-in for OpenVibe.Sources' change feed: items in change_seq order after the cursor. */++function feed(items) {++ const calls = [];++ return {++ calls,++ items,++ async listItems({ after = 0, limit = 100 }) {++ calls.push(after);++ const rest = items.filter((x) => x.change_seq > after).sort((a, b) => a.change_seq - b.change_seq);++ const page = rest.slice(0, limit);++ return { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rest.length > limit, sources: {} };++ },++ };++}++++(async () => {++ const items = Array.from({ length: 34 }, (_, i) => item(i + 1));++ items.push({ ...item(90), kind: 'record', identity: 'not-a-recall', change_seq: 90 });++ items.push({ ...item(91), source_key: 'fda-food-recalls', change_seq: 91 });++ const source = feed(items);++ const t = await boot({ recallSource: source });++ try {++ await check('a report becomes one row as stated; a bad number, another kind or another source is not a recall', async () => {++ assert.strictEqual(day('20261014'), '2026-10-14');++ assert.strictEqual(day('2026-10-14'), null);++ assert.strictEqual(day('20261340'), null);++ const row = rowOf(item(3));++ assert.strictEqual(row.recall_number, 'F-0003-2027');++ assert.strictEqual(row.classification, 'Class I');++ assert.strictEqual(row.firm, 'Firm 3');++ assert.strictEqual(row.reported_on, '2026-10-13');++ assert.strictEqual(rowOf({ ...item(4), identity: 'javascript:alert(1)' }), null);++ assert.strictEqual(rowOf({ ...item(4), title: '' }), null);++ assert.strictEqual(rowOf(item(5, {}, { classification: 'Class IV' })).classification, null);++ });++++ await check('the pull follows the change feed to its end and keeps the cursor', async () => {++ const page = await t.get('/recalls');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('once OpenVibe.Sources has read them'), 'before the first pull the page says where recalls come from');++ const out = await t.ctx.recalls.pull();++ assert.strictEqual(out.applied, 34);++ assert.strictEqual(out.hold, 2);++ assert.strictEqual(await t.ctx.recalls.count(), 34);++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT cursor FROM food_ingest_cursor WHERE name = 'sources.food.after'")), 91);++ const again = await t.ctx.recalls.pull();++ assert.strictEqual(again.applied, 0, 'nothing new after the cursor');++ });++++ await check('a removed item is deleted; an older revision arriving late changes nothing', async () => {++ source.items.push({ ...item(2), removed: { at: '2026-10-22T11:00:00.000Z', reason: 'takedown' }, revision: 2, change_seq: 100 });++ source.items.push({ ...item(5, { revision: 3, summary: 'Undeclared milk and egg.' }), change_seq: 101 });++ source.items.push({ ...item(5, { revision: 2, summary: 'stale' }), change_seq: 102 });++ const out = await t.ctx.recalls.pull();++ assert.strictEqual(out.removed, 1);++ assert.strictEqual(await t.ctx.recalls.get('F-0002-2027'), null);++ assert.strictEqual((await t.ctx.recalls.get('F-0005-2027')).reason, 'Undeclared milk and egg.');++ assert.strictEqual(await t.ctx.recalls.count(), 33);++ });++++ await check('/recalls lists the newest report first with openFDA\'s note, and filters by words, class and state', async () => {++ const all = await t.get('/recalls');++ assert.strictEqual(all.status, 200);++ assert.ok(all.text.includes('33 recalls so far'));++ assert.ok(all.text.includes('not for medical decisions') && all.text.includes('not endorsed by the FDA'));++ assert.ok(!/noindex/.test(all.text), 'the list is indexed');++ assert.ok(all.text.includes('Page 1 of 2'));++ assert.ok(all.text.indexOf('F-0029-2027') < all.text.indexOf('F-0028-2027'), 'newest report first');++ const classI = await t.get('/recalls?class=Class+I');++ const shown = classI.text.match(/\/recalls\/F-\d{4}-2027/g) || [];++ assert.ok(shown.length && shown.every((h) => Number(h.slice(11, 15)) % 3 === 0), 'only Class I');++ assert.ok(/noindex/.test(classI.text), 'a filtered list is not indexed');++ const tx = await t.get('/recalls?state=TX&q=firm+1');++ assert.ok((tx.text.match(/\/recalls\/F-\d{4}-2027/g) || []).every((h) => Number(h.slice(11, 15)) % 2 === 0));++ const none = await t.get('/recalls?q=%3Cscript%3E');++ assert.ok(none.text.includes('Nothing matched') && !none.text.includes('<script>alert'));++ const odd = await t.get('/recalls?state=%27%3B--&class=Class+IV&page=-3');++ assert.strictEqual(odd.status, 200);++ });++++ await check('/recalls/:number shows the report in full and is noindex; an unknown number is a 404', async () => {++ const one = await t.get('/recalls/F-0003-2027');++ assert.strictEqual(one.status, 200);++ assert.ok(one.text.includes('Firm 3') && one.text.includes('Undeclared milk (3).') && one.text.includes('Class I'));++ assert.ok(one.text.includes('serious adverse health consequences or death'), 'the class is explained in the FDA\'s words');++ assert.ok(/noindex/.test(one.text));++ assert.ok(one.text.includes('not for medical decisions'));++ assert.strictEqual((await t.get('/recalls/F-9999-2027')).status, 404);++ assert.strictEqual((await t.get('/recalls/..%2F..%2Fetc')).status, 404);++ });++++ await check('the home shows the newest recalls and the count; the sitemap lists /recalls only', async () => {++ const home = await t.get('/');++ assert.ok(home.text.includes('id="h-recalls"'));++ assert.ok(home.text.includes('All 33 recalls'));++ assert.ok(home.text.includes('FDA food recalls, newest first'));++ const map = await t.get('/sitemap.xml');++ assert.ok(map.text.includes('https://openvibe.food/recalls</loc>'));++ assert.ok(!map.text.includes('/recalls/F-'));++ const llms = await t.get('/llms.txt');++ assert.ok(llms.text.includes('https://openvibe.food/recalls'));++ });++ } finally {++ await t.close();++ }++ done();++})();
@@ -27,6 +27,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
@@ -27,6 +27,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) |
| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) |
+| Food recalls | [/recalls](server/http/recall-pages.js), [server/food/recalls.js](server/food/recalls.js) | The FDA's food enforcement reports, read from OpenVibe.Sources (source `openfda-food-enforcement`, category `food`, `sources.item.read`): Food follows Sources' change feed hourly from its cursor (`openvibe-publishing/ingest`) and keeps each recall as the report states it (product, reason, company, class, status, where it was sold, dates). `/recalls` filters by words, class and the company's state; `/recalls/:number` is one recall (noindex, as the source's registry entry asks). Every page carries openFDA's note: unvalidated, not for medical decisions, no FDA endorsement. Food never calls the FDA; `OV_SOURCES_INTERNAL_URL` (default `http://127.0.0.1:4720`, empty turns it off) |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Household chat | [/plan/p/:id](server/http/pages.js), [server/food/household-chat.js](server/food/household-chat.js) | The owner of a shared plan starts a private room on OpenVibe.Chat for the household, made through Chat's room API as the owner, with every member added by account subject (and anyone who joins later, when the owner opens the plan) and **Pantry**, an AI member linked to the plan. Writing `@pantry` there asks it: Chat reads the household from `GET /internal/households/:id/chat-context` (`food.household.read`, Chat's service token) — the plan, what is still to buy and every member's cupboard — and OpenVibe.AI answers. The plan's page links the room for everyone |
| Household chat | [/plan/p/:id](server/http/pages.js), [server/food/household-chat.js](server/food/household-chat.js) | The owner of a shared plan starts a private room on OpenVibe.Chat for the household, made through Chat's room API as the owner, with every member added by account subject (and anyone who joins later, when the owner opens the plan) and **Pantry**, an AI member linked to the plan. Writing `@pantry` there asks it: Chat reads the household from `GET /internal/households/:id/chat-context` (`food.household.read`, Chat's service token) — the plan, what is still to buy and every member's cupboard — and OpenVibe.AI answers. The plan's page links the room for everyone |
@@ -56,6 +57,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -56,6 +57,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
- `food_library_recipes`, `food_library_runs` (0007) — the recipe library: each kept recipe with its ingredients (food
- `food_library_recipes`, `food_library_runs` (0007) — the recipe library: each kept recipe with its ingredients (food
list slugs), steps, what to buy and its typical cost, the AI run, and who checked it; one row per theme run.
list slugs), steps, what to buy and its typical cost, the AI run, and who checked it; one row per theme run.
+- `food_recalls`, `food_ingest_cursor` (0009) — the FDA's food recall reports as OpenVibe.Sources holds them, one row
+ per recall number, and the cursor in Sources' change feed. Public domain data; nothing about a person.
+
0001 creates no tables. The service is the authority for these rows and for nothing else.
0001 creates no tables. The service is the authority for these rows and for nothing else.
## Does not own
## Does not own
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+-- phase: expand
+-- OpenVibe.Food's food recalls (server/food/recalls.js): the FDA's food enforcement reports, as OpenVibe.Sources collects
+-- them (source openfda-food-enforcement, category food). Food never calls the FDA: it follows Sources' change feed from
+-- its cursor here (openvibe-publishing/ingest, prefix food) and keeps each recall as the report states it. The reports
+-- are public domain (CC0, openFDA); nothing here belongs to a person.
+
+-- The DDL is openvibe-publishing lib/ingest.js schema('food').
+CREATE TABLE IF NOT EXISTS food_ingest_cursor (
+ name text COLLATE "C" NOT NULL,
+ cursor bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (name)
+);
+
+CREATE TABLE food_recalls (
+ recall_number text COLLATE "C" PRIMARY KEY, -- the FDA's own number (F-0123-2027), Sources' identity
+ item_id text COLLATE "C" NOT NULL UNIQUE, -- the Sources item
+ revision integer NOT NULL, -- the Sources item's revision this row holds
+ product text NOT NULL, -- product_description, as stated
+ reason text, -- reason_for_recall
+ firm text, -- recalling_firm
+ classification text, -- Class I, Class II or Class III
+ status text, -- Ongoing, Completed, Terminated
+ state text, -- the recalling firm's state
+ distribution text, -- distribution_pattern
+ voluntary text, -- voluntary_mandated
+ initiated_on text COLLATE "C", -- YYYY-MM-DD
+ reported_on text COLLATE "C", -- YYYY-MM-DD, the enforcement report's date
+ retrieved_at text COLLATE "C" NOT NULL -- when Sources last read it, ISO 8601 UTC
+);
+CREATE INDEX food_recalls_reported ON food_recalls (reported_on DESC, recall_number);
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+/* OpenVibe.Food — food recalls (/recalls, /recalls/:number) and the home page's recall block. */
+.recalls { list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 12px; }
+.recall { border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); padding: 14px 16px; min-width: 0; }
+.recall p { margin: 4px 0; overflow-wrap: anywhere; }
+.recall-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 0 0 6px; }
+.recall-product { margin: 2px 0 6px; font-size: 1.05rem; line-height: 1.35; overflow-wrap: anywhere; }
+.recall-product a { color: var(--text-primary, #e6edf7); }
+.recall-title { overflow-wrap: anywhere; }
+.recall-classes { margin: 10px 0 14px; }
+.recall-classes ul { margin: 8px 0 0; padding-left: 1.1rem; }
+.recall-classes li { margin: 0 0 6px; }
+.recall-facts { display: grid; grid-template-columns: minmax(120px, max-content) 1fr; gap: 8px 18px; margin: 8px 0 18px; }
+.recall-facts dt { color: var(--text-secondary, #96a7c2); }
+.recall-facts dd { margin: 0; overflow-wrap: anywhere; }
+@media (max-width: 520px) { .recall-facts { grid-template-columns: 1fr; gap: 2px; } .recall-facts dd { margin-bottom: 8px; } }
+.recall-note { margin-top: 18px; }
@@ -90,6 +90,8 @@ async function createApp(opts = {}) {
@@ -90,6 +90,8 @@ async function createApp(opts = {}) {
ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });
ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });
// Checked recipes in OpenVibe.Search (server/food/search-index.js; off without FOOD_SEARCH_URL).
// Checked recipes in OpenVibe.Search (server/food/search-index.js; off without FOOD_SEARCH_URL).
ctx.recipeSearch = require('./food/search-index').createRecipeSearchIndex({ config, library: ctx.library, log });
ctx.recipeSearch = require('./food/search-index').createRecipeSearchIndex({ config, library: ctx.library, log });
+ // Food recalls from OpenVibe.Sources (server/food/recalls.js); a test passes a stand-in source through opts.recallSource.
+ ctx.recalls = require('./food/recalls').createRecalls({ config, s, fetchImpl, log, source: opts.recallSource || null });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -65,6 +65,9 @@ function load(env = process.env) {
@@ -65,6 +65,9 @@ function load(env = process.env) {
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
// OpenVibe.Search's owner API (server/food/search-index.js): the checked library recipes as documents. Unset: off.
// OpenVibe.Search's owner API (server/food/search-index.js): the checked library recipes as documents. Unset: off.
search: { url: trim(env.FOOD_SEARCH_URL || '') },
search: { url: trim(env.FOOD_SEARCH_URL || '') },
+ // OpenVibe.Sources (server/food/recalls.js): the FDA's food recall reports, read from its food category with
+ // this service's token (sources.item.read). Food never calls the FDA itself. Empty: off.
+ sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL == null ? 'http://127.0.0.1:4720' : env.OV_SOURCES_INTERNAL_URL) },
// OpenVibe.Chat (server/food/household-chat.js): a household's room, made as its owner through Chat's room API.
// OpenVibe.Chat (server/food/household-chat.js): a household's room, made as its owner through Chat's room API.
// `internalUrl` is where this service calls it (the loopback address in production), `publicUrl` what people open.
// `internalUrl` is where this service calls it (the loopback address in production), `publicUrl` what people open.
chat: { internalUrl: trim(env.FOOD_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400'), publicUrl: trim(env.FOOD_CHAT_PUBLIC_URL || 'https://openvibe.chat') },
chat: { internalUrl: trim(env.FOOD_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400'), publicUrl: trim(env.FOOD_CHAT_PUBLIC_URL || 'https://openvibe.chat') },
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+
+/**
+ * Food recalls: the FDA's food enforcement reports, read from OpenVibe.Sources (source openfda-food-enforcement,
+ * category food), never from the FDA. Sources fetches openFDA once a day under its robots and terms checks and keeps
+ * each report as an item; Food follows Sources' change feed (openvibe-publishing/ingest: the cursor in
+ * food_ingest_cursor, one transaction per page) and keeps one row per recall in food_recalls, exactly as the report
+ * states it. An item Sources removes (a takedown, a licence change) is deleted here.
+ *
+ * The reports are public domain (CC0). openFDA's terms ask that a use not imply FDA endorsement and say the data are
+ * unvalidated and not for medical decisions: every page that shows a recall carries NOTE.
+ *
+ * Pulls 30 seconds after start and then hourly. Off without OV_SOURCES_INTERNAL_URL and the OAuth client secret (a
+ * test passes its own source).
+ */
+const ingest = require('openvibe-publishing/ingest');
+
+const SOURCE_KEY = 'openfda-food-enforcement';
+const CURSOR = 'sources.food.after';
+const EVERY_MS = 60 * 60 * 1000;
+const FIRST_MS = 30 * 1000;
+const CLASSES = ['Class I', 'Class II', 'Class III'];
+// What each class means, in the FDA's words (fda.gov, "Recalls Background and Definitions").
+const CLASS_TEXT = {
+ 'Class I': 'a reasonable probability that the product will cause serious adverse health consequences or death',
+ 'Class II': 'the product may cause temporary or medically reversible adverse health consequences, or the chance of serious harm is remote',
+ 'Class III': 'the product is not likely to cause adverse health consequences',
+};
+const NOTE = 'Recalls are the FDA\'s food enforcement reports through openFDA (public domain). They are not validated by openFDA, not for medical decisions, and OpenVibe.Food is not endorsed by the FDA.';
+const FDA_REPORTS = 'https://www.accessdata.fda.gov/scripts/ires/index.cfm';
+
+const text = (v, max) => (v == null || v === '' ? null : String(v).replace(/\s+/g, ' ').trim().slice(0, max) || null);
+/** The reports' YYYYMMDD → YYYY-MM-DD, or null. */
+function day(v) {
+ const m = /^(\d{4})(\d{2})(\d{2})$/.exec(String(v == null ? '' : v).trim());
+ if (!m || Number(m[2]) < 1 || Number(m[2]) > 12 || Number(m[3]) < 1 || Number(m[3]) > 31) return null;
+ return `${m[1]}-${m[2]}-${m[3]}`;
+}
+const NUMBER_RE = /^[A-Z]-\d{3,5}-\d{4}$/;
+
+/** A Sources item → a food_recalls row, or null when it is not a recall this page can show. */
+function rowOf(item) {
+ const f = (item && item.fields) || {};
+ const number = text(item && item.identity, 40);
+ const product = text(item && item.title, 2000);
+ if (!number || !NUMBER_RE.test(number) || !product) return null;
+ return {
+ recall_number: number,
+ item_id: String(item.id),
+ revision: Number(item.revision) || 1,
+ product,
+ reason: text(item.summary, 2000),
+ firm: text(f.recalling_firm, 300),
+ classification: CLASSES.includes(f.classification) ? f.classification : null,
+ status: text(f.status, 40),
+ state: text(f.state, 40),
+ distribution: text(f.distribution_pattern, 2000),
+ voluntary: text(f.voluntary_mandated, 120),
+ initiated_on: day(f.recall_initiation_date),
+ reported_on: day(f.report_date),
+ retrieved_at: (item.provenance && item.provenance.retrieved_at) || new Date(0).toISOString(),
+ };
+}
+
+function createRecalls({ config, s, fetchImpl = globalThis.fetch, log = console, source = null }) {
+ const url = String((config.sources && config.sources.internalUrl) || '').replace(/\/+$/, '');
+ const client = source || (url && config.oauth.clientSecret
+ ? ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: url, category: 'food' } }, fetchImpl })
+ : null);
+ const cursor = ingest.createChangeCursor(s.db, { prefix: 'food', now: s.now });
+ const stats = { pulls: 0, applied: 0, removed: 0, last_at: null, last_error: null };
+ let timer = null;
+ let first = null;
+ let running = false;
+
+ async function apply(item, t) {
+ if (!item || item.source_key !== SOURCE_KEY || item.kind !== 'recall') return 'hold';
+ if (item.removed) {
+ await t.query('DELETE FROM food_recalls WHERE item_id = $1', [String(item.id)]);
+ return 'removed';
+ }
+ const row = rowOf(item);
+ if (!row) return 'hold';
+ const cols = Object.keys(row);
+ // A replayed page (or an older revision arriving late) never takes a row back.
+ await t.query(`INSERT INTO food_recalls (${cols.join(', ')}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(', ')})
+ ON CONFLICT (recall_number) DO UPDATE SET ${cols.filter((c) => c !== 'recall_number').map((c) => `${c} = EXCLUDED.${c}`).join(', ')}
+ WHERE food_recalls.item_id <> EXCLUDED.item_id OR food_recalls.revision <= EXCLUDED.revision`, cols.map((c) => row[c]));
+ return 'applied';
+ }
+
+ /** Follow Sources' change feed to its end. Never throws: the caller is a timer or a test. */
+ async function pull() {
+ if (!client || running) return { skipped: true };
+ running = true;
+ try {
+ const out = await ingest.pullChanges({ db: s.db, cursor, source: client, name: CURSOR, apply });
+ stats.pulls++;
+ stats.applied += out.applied;
+ stats.removed += out.removed;
+ stats.last_at = s.iso();
+ stats.last_error = null;
+ if (out.applied || out.removed) log.log(`[Recalls] ${out.applied} recalls updated, ${out.removed} removed (Sources cursor ${out.after})`);
+ return out;
+ } catch (err) {
+ stats.last_error = text(err && err.message, 300);
+ log.warn(`[Recalls] pull from OpenVibe.Sources failed: ${stats.last_error}`);
+ return { error: stats.last_error };
+ } finally { running = false; }
+ }
+
+ const shape = (r) => (r ? { ...r, class_text: CLASS_TEXT[r.classification] || null } : null);
+
+ /** Filters shared by list() and count(): words (product, firm, reason), a class, a state. */
+ function where({ q, classification, state } = {}) {
+ const clauses = [];
+ const args = [];
+ if (q) {
+ args.push(`%${String(q).toLowerCase().replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
+ clauses.push(`(lower(product) LIKE $${args.length} OR lower(coalesce(firm, '')) LIKE $${args.length} OR lower(coalesce(reason, '')) LIKE $${args.length})`);
+ }
+ if (CLASSES.includes(classification)) { args.push(classification); clauses.push(`classification = $${args.length}`); }
+ if (state) { args.push(String(state)); clauses.push(`state = $${args.length}`); }
+ return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', args };
+ }
+
+ return {
+ enabled: Boolean(client),
+ stats,
+ pull,
+ apply,
+ /** Newest report first. */
+ async list({ q = null, classification = null, state = null, limit = 50, offset = 0 } = {}) {
+ const w = where({ q, classification, state });
+ const rows = await s.db.many(`SELECT * FROM food_recalls ${w.sql} ORDER BY reported_on DESC NULLS LAST, recall_number DESC LIMIT $${w.args.length + 1} OFFSET $${w.args.length + 2}`,
+ [...w.args, Math.min(Math.max(1, limit), 200), Math.max(0, offset)]);
+ return rows.map(shape);
+ },
+ async count(filters = {}) {
+ const w = where(filters);
+ return Number(await s.db.value(`SELECT count(*) FROM food_recalls ${w.sql}`, w.args)) || 0;
+ },
+ async get(number) { return NUMBER_RE.test(String(number)) ? shape(await s.db.maybe('SELECT * FROM food_recalls WHERE recall_number = $1', [String(number)])) : null; },
+ /** The states the recalling firms are in, most recalls first (the filter's choices). */
+ async states() { return s.db.many('SELECT state, count(*)::int AS n FROM food_recalls WHERE state IS NOT NULL GROUP BY state ORDER BY n DESC, state'); },
+ /** Every recall number with its report date (the sitemap). */
+ async numbers() { return s.db.many('SELECT recall_number, reported_on FROM food_recalls ORDER BY reported_on DESC NULLS LAST, recall_number DESC LIMIT 5000'); },
+ start() {
+ if (!client || timer) return false;
+ timer = setInterval(() => { pull().catch(() => {}); }, EVERY_MS);
+ timer.unref();
+ first = setTimeout(() => { pull().catch(() => {}); }, FIRST_MS);
+ first.unref();
+ return true;
+ },
+ stop() { clearInterval(timer); clearTimeout(first); timer = null; first = null; },
+ };
+}
+
+module.exports = { createRecalls, rowOf, day, SOURCE_KEY, CLASSES, CLASS_TEXT, NOTE, FDA_REPORTS };
@@ -24,6 +24,7 @@ const PAGE_TEXT = {
@@ -24,6 +24,7 @@ const PAGE_TEXT = {
'/near': ['Food near you', 'Food banks, soup kitchens, community fridges and budget grocery stores near a place, from OpenStreetMap.'],
'/near': ['Food near you', 'Food banks, soup kitchens, community fridges and budget grocery stores near a place, from OpenStreetMap.'],
'/plan': ['Meal plan', 'Plan meals for one person, a couple or a group for up to fourteen days, with a shopping list and an estimated cost.'],
'/plan': ['Meal plan', 'Plan meals for one person, a couple or a group for up to fourteen days, with a shopping list and an estimated cost.'],
'/foods': ['The food list', 'Every food OpenVibe.Food plans with: nutrition per serving and per 100 g, and a typical price band.'],
'/foods': ['The food list', 'Every food OpenVibe.Food plans with: nutrition per serving and per 100 g, and a typical price band.'],
+ '/recalls': ['Food recalls', 'The FDA\'s food recall reports, newest first: what was recalled, by whom, why, where it was sold and how serious it is (openFDA, public domain).'],
'/updates': ['What shipped on OpenVibe.Food', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.Food', 'This site\'s update log, from the network changelog feed.'],
};
};
const SUMMARIES = {
const SUMMARIES = {
@@ -31,6 +32,7 @@ const SUMMARIES = {
@@ -31,6 +32,7 @@ const SUMMARIES = {
'/near': 'Food banks and budget grocery stores near a place, from OpenStreetMap.',
'/near': 'Food banks and budget grocery stores near a place, from OpenStreetMap.',
'/plan': 'A meal plan with a shopping list and an estimated cost.',
'/plan': 'A meal plan with a shopping list and an estimated cost.',
'/foods': 'Nutrition per serving and per 100 g, and a typical price band, for every food.',
'/foods': 'Nutrition per serving and per 100 g, and a typical price band, for every food.',
+ '/recalls': 'The FDA\'s food recall reports, newest first.',
'/updates': 'What shipped on the site.',
'/updates': 'What shipped on the site.',
};
};
@@ -57,6 +59,7 @@ const publicPages = () => [
@@ -57,6 +59,7 @@ const publicPages = () => [
{ path: '/near', changefreq: 'weekly', priority: 0.9 },
{ path: '/near', changefreq: 'weekly', priority: 0.9 },
{ path: '/plan', changefreq: 'weekly', priority: 0.9 },
{ path: '/plan', changefreq: 'weekly', priority: 0.9 },
{ path: '/foods', changefreq: 'weekly', priority: 0.8 },
{ path: '/foods', changefreq: 'weekly', priority: 0.8 },
+ { path: '/recalls', changefreq: 'daily', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
...foods.FOODS.map((f) => ({
...foods.FOODS.map((f) => ({
path: `/foods/${f.slug}`, changefreq: 'monthly', priority: 0.5,
path: `/foods/${f.slug}`, changefreq: 'monthly', priority: 0.5,
@@ -88,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
@@ -88,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Food near you', url: abs('/near'), note: 'food banks and budget grocery stores, from OpenStreetMap' },
{ title: 'Food near you', url: abs('/near'), note: 'food banks and budget grocery stores, from OpenStreetMap' },
{ title: 'Meal plan', url: abs('/plan'), note: 'a shopping list and an estimated cost' },
{ title: 'Meal plan', url: abs('/plan'), note: 'a shopping list and an estimated cost' },
{ title: 'The food list', url: abs('/foods'), note: 'nutrition and typical prices' },
{ title: 'The food list', url: abs('/foods'), note: 'nutrition and typical prices' },
+ { title: 'Food recalls', url: abs('/recalls'), note: 'the FDA\'s food recall reports through OpenVibe.Sources, newest first, by product, company, class and state' },
{ title: 'Recipe library', url: abs('/recipes/library'), note: 'budget recipes written by OpenVibe.AI from the food list, each with what to buy and its typical cost; a recipe a person checked is marked so' },
{ title: 'Recipe library', url: abs('/recipes/library'), note: 'budget recipes written by OpenVibe.AI from the food list, each with what to buy and its typical cost; a recipe a person checked is marked so' },
{ title: 'What shipped on OpenVibe.Food', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Food', url: abs('/updates') },
] },
] },
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* /foods the food list, grouped
* /foods the food list, grouped
* /foods/:slug one food: nutrition per serving and per 100 g, typical prices
* /foods/:slug one food: nutrition per serving and per 100 g, typical prices
* /pantry what is in your cupboard, and what you can make from it (signed in)
* /pantry what is in your cupboard, and what you can make from it (signed in)
+ * /recalls the FDA's food recall reports, through OpenVibe.Sources (http/recall-pages.js)
* /updates the update log
* /updates the update log
*
*
* Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
* Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
@@ -34,6 +35,7 @@ const { suggestRecipes } = require('../food/recipes');
@@ -34,6 +35,7 @@ const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const store = require('../food/store');
const delivery = require('../food/delivery');
const delivery = require('../food/delivery');
const householdChat = require('../food/household-chat');
const householdChat = require('../food/household-chat');
+const { date: recallDate } = require('./recall-pages');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { products } = require('openvibe-contracts');
const { products } = require('openvibe-contracts');
@@ -117,6 +119,9 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
@@ -117,6 +119,9 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
lib = { total, cheap };
lib = { total, cheap };
} catch { lib = { total: 0, cheap: [] }; }
} catch { lib = { total: 0, cheap: [] }; }
}
}
+ // The newest FDA recall reports (server/food/recalls.js); none yet or a failed read leaves the block out.
+ let recalls = { total: 0, latest: [] };
+ try { const [total, latest] = await Promise.all([ctx.recalls.count(), ctx.recalls.list({ limit: 4 })]); recalls = { total, latest }; } catch { /* left out */ }
const base = String(config.baseUrl).replace(/\/+$/, '');
const base = String(config.baseUrl).replace(/\/+$/, '');
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
@@ -136,6 +141,7 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
@@ -136,6 +141,7 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
{ value: Object.keys(foods.STORES).length, label: 'budget chains priced', href: '/foods' },
{ value: Object.keys(foods.STORES).length, label: 'budget chains priced', href: '/foods' },
{ value: delivery.APPS.length, label: 'delivery apps in one search', href: '/delivery' },
{ value: delivery.APPS.length, label: 'delivery apps in one search', href: '/delivery' },
{ value: delivery.KITS.length, label: 'meal kits side by side', href: '/delivery#kits' },
{ value: delivery.KITS.length, label: 'meal kits side by side', href: '/delivery#kits' },
+ { value: recalls.total, label: 'FDA food recalls, newest first', href: '/recalls' },
].filter((x) => x.value);
].filter((x) => x.value);
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
@@ -172,6 +178,9 @@ ${lib.cheap.length ? html`<section class="sc-sec" aria-labelledby="h-meals"><h2
@@ -172,6 +178,9 @@ ${lib.cheap.length ? html`<section class="sc-sec" aria-labelledby="h-meals"><h2
<p class="sc-lede">Food banks, soup kitchens, community fridges and free pantry boxes, with hours, from OpenStreetMap. Search any town or postcode, or start with a city.</p>
<p class="sc-lede">Food banks, soup kitchens, community fridges and free pantry boxes, with hours, from OpenStreetMap. Search any town or postcode, or start with a city.</p>
<ul class="home-chips">${NEAR_CITIES.map((c) => html`<li><a href="/near?${new URLSearchParams({ q: c })}">${c.split(',')[0]}</a></li>`)}</ul>
<ul class="home-chips">${NEAR_CITIES.map((c) => html`<li><a href="/near?${new URLSearchParams({ q: c })}">${c.split(',')[0]}</a></li>`)}</ul>
<p><a class="sc-btn sc-primary" href="/near">Search near you</a></p></section>
<p><a class="sc-btn sc-primary" href="/near">Search near you</a></p></section>
+${recalls.latest.length ? html`<section class="sc-sec" aria-labelledby="h-recalls"><h2 id="h-recalls">Food recalls</h2>
+<p class="sc-lede">The newest of the FDA's food recall reports: what was recalled, by whom and how serious it is. <a href="/recalls">All ${recalls.total} recalls</a></p>
+<ul class="home-cards">${recalls.latest.map((x) => html`<li><a href="/recalls/${x.recall_number}"><b>${x.product.length > 90 ? `${x.product.slice(0, 89).replace(/\s+\S*$/, '')}…` : x.product}</b><span class="hc-main"><strong>${x.classification || 'Recall'}</strong> ${x.firm || ''}</span><span class="hc-sub">${[x.reported_on ? `Reported ${recallDate(x.reported_on)}` : null, x.state].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>` : ''}
${raw(showcase.features({
${raw(showcase.features({
title: 'Everything food',
title: 'Everything food',
lede: 'All working today: no card, no account needed to look around.',
lede: 'All working today: no card, no account needed to look around.',
@@ -875,6 +884,8 @@ ${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-
@@ -875,6 +884,8 @@ ${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-
r.use(createDiscoveryRoutes(ctx));
r.use(createDiscoveryRoutes(ctx));
// The recipe library (server/food/library.js, http/library-routes.js).
// The recipe library (server/food/library.js, http/library-routes.js).
require('./library-routes').mountLibrary(r, { ctx, page, config, signedIn, express });
require('./library-routes').mountLibrary(r, { ctx, page, config, signedIn, express });
+ // Food recalls (server/food/recalls.js, http/recall-pages.js).
+ require('./recall-pages').mountRecalls(r, { ctx, page, config, signedIn, publicCache: PUBLIC_CACHE });
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Food recalls (server/food/recalls.js), from the FDA's food enforcement reports through OpenVibe.Sources:
+ *
+ * GET /recalls every recall, newest report first (30 a page), by words, class and state
+ * GET /recalls/:number one recall (F-0123-2027): the product, why, who, where it was sold, its status
+ *
+ * Every page carries openFDA's note (unvalidated, not for medical decisions, no endorsement). The list is indexed;
+ * a single recall is noindex, as the source's registry entry asks (Sources' default_indexability).
+ */
+const showcase = require('openvibe-shared/showcase');
+const { html, raw, notice, badge } = require('../render/html');
+const { CLASSES, CLASS_TEXT, NOTE, FDA_REPORTS } = require('../food/recalls');
+
+const PER_PAGE = 30;
+const CLASS_BADGE = { 'Class I': 'bad', 'Class II': 'warn', 'Class III': '' };
+const str = (v, max = 80) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
+const short = (s, max) => (s && s.length > max ? `${s.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : s || '');
+/** 2026-10-14 → 14 Oct 2026 (the reports give days, not times). */
+const date = (d) => (d ? new Date(`${d}T00:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : '—');
+
+const classBadge = (c) => (c ? badge(c, CLASS_BADGE[c] || '') : '');
+const noteBlock = () => html`<p class="muted small recall-note">${NOTE} <a href="${FDA_REPORTS}" rel="noopener">The FDA's enforcement reports</a> are the record.</p>`;
+
+/** One recall as a list item (the list page and the home page). */
+function recallCard(x, { reason = true } = {}) {
+ return html`<li class="recall"><p class="recall-head">${classBadge(x.classification)} ${x.status ? badge(x.status) : ''} <span class="muted small">Reported ${date(x.reported_on)}</span></p>
+<h2 class="recall-product"><a href="/recalls/${x.recall_number}">${short(x.product, 180)}</a></h2>
+<p class="small">${x.firm ? html`<b>${x.firm}</b>` : ''}${x.state ? html` · ${x.state}` : ''}${x.distribution ? html` · sold in ${short(x.distribution, 120)}` : ''}</p>
+${reason && x.reason ? html`<p class="small muted">${short(x.reason, 220)}</p>` : ''}</li>`;
+}
+
+function mountRecalls(r, { ctx, page, config, signedIn, publicCache }) {
+ const recalls = ctx.recalls;
+
+ r.get('/recalls', async (req, res) => {
+ const q = str(req.query.q);
+ const classification = CLASSES.includes(req.query.class) ? req.query.class : null;
+ const state = typeof req.query.state === 'string' && /^[A-Z]{2}$/.test(req.query.state) ? req.query.state : null;
+ const n = parseInt(req.query.page, 10);
+ const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 200) : 1;
+ const filters = { q, classification, state };
+ const [total, all, rows, states] = await Promise.all([
+ recalls.count(filters), recalls.count(), recalls.list({ ...filters, limit: PER_PAGE, offset: (pageNo - 1) * PER_PAGE }), recalls.states(),
+ ]);
+ const pages = Math.max(1, Math.ceil(total / PER_PAGE));
+ const filtered = Boolean(q || classification || state);
+ const qs = (p) => { const u = new URLSearchParams(); if (q) u.set('q', q); if (classification) u.set('class', classification); if (state) u.set('state', state); if (p > 1) u.set('page', String(p)); const s = u.toString(); return s ? `/recalls?${s}` : '/recalls'; };
+ page(req, res, {
+ title: filtered ? 'Food recalls, filtered' : 'Food recalls from the FDA',
+ description: 'The FDA\'s food recall reports, newest first: what was recalled, by whom, why, where it was sold and how serious it is.',
+ index: !filtered && pageNo === 1, cache: signedIn(req) ? null : publicCache,
+ crumbs: [{ label: 'Food recalls' }], styles: [showcase.STYLESHEET], localStyles: ['css/recalls.css'],
+ body: html`<h1>Food recalls</h1>
+<p class="lede">The FDA's food recall reports, newest first: what was recalled, by whom, why, and where it was sold. ${all ? html`${all} recalls so far.` : ''}</p>
+<form class="ov-form filter-form" method="get" action="/recalls" role="search">
+<label for="rq">Product or company</label>
+<input id="rq" name="q" type="search" value="${q || ''}" maxlength="80" placeholder="cheese, peanut butter, a brand…">
+<label for="rclass">How serious</label>
+<select id="rclass" name="class"><option value="">Every class</option>${CLASSES.map((c) => html`<option value="${c}"${c === classification ? raw(' selected') : ''}>${c}</option>`)}</select>
+<label for="rstate">Company's state</label>
+<select id="rstate" name="state"><option value="">Every state</option>${states.map((s) => html`<option value="${s.state}"${s.state === state ? raw(' selected') : ''}>${s.state} (${s.n})</option>`)}</select>
+<button class="sc-btn" type="submit">Filter</button>
+</form>
+<details class="recall-classes"><summary>What Class I, II and III mean</summary><ul>${CLASSES.map((c) => html`<li>${classBadge(c)} ${CLASS_TEXT[c]}.</li>`)}</ul></details>
+${rows.length ? html`<p class="muted small">${total} ${total === 1 ? 'recall' : 'recalls'}${filtered ? ' match' : ''}.${filtered ? html` <a href="/recalls">Show all</a>` : ''}</p>
+<ol class="recalls">${rows.map((x) => recallCard(x))}</ol>`
+ : all ? notice(html`Nothing matched. <a href="/recalls">Show every recall</a>.`, 'warn')
+ : html`<p class="muted">The recall reports appear here once OpenVibe.Sources has read them. Meanwhile, <a href="${FDA_REPORTS}" rel="noopener">search the FDA's own reports</a>.</p>`}
+${pages > 1 ? html`<nav class="pager" aria-label="Pages">${pageNo > 1 ? html`<a href="${qs(pageNo - 1)}">← Newer</a>` : ''} <span>Page ${pageNo} of ${pages}</span> ${pageNo < pages ? html`<a href="${qs(pageNo + 1)}">Older →</a>` : ''}</nav>` : ''}
+${noteBlock()}`,
+ });
+ });
+
+ r.get('/recalls/:number', async (req, res) => {
+ const x = await recalls.get(req.params.number);
+ if (!x) {
+ return page(req, res, { title: 'No such recall', crumbs: [{ label: 'Food recalls', href: '/recalls' }, { label: 'Not found' }],
+ body: html`<h1>No such recall</h1><p>No recall here is numbered “${String(req.params.number).slice(0, 40)}”. <a href="/recalls">See every recall</a>.</p>` }, 404);
+ }
+ const facts = [
+ ['Recall number', x.recall_number],
+ ['How serious', x.classification ? html`${classBadge(x.classification)} ${x.class_text ? html`<span class="small">${x.class_text}</span>` : ''}` : '—'],
+ ['Status', x.status || '—'],
+ ['Company', x.firm ? html`${x.firm}${x.state ? ` (${x.state})` : ''}` : '—'],
+ ['Where it was sold', x.distribution || '—'],
+ ['Who started it', x.voluntary || '—'],
+ ['Recall started', date(x.initiated_on)],
+ ['Reported', date(x.reported_on)],
+ ];
+ page(req, res, {
+ title: `Recall: ${short(x.product, 70)}`, index: false, cache: signedIn(req) ? null : publicCache,
+ description: short(`${x.firm ? `${x.firm}: ` : ''}${x.reason || x.product}`, 160),
+ crumbs: [{ label: 'Food recalls', href: '/recalls' }, { label: x.recall_number }],
+ styles: [showcase.STYLESHEET], localStyles: ['css/recalls.css'],
+ body: html`<h1 class="recall-title">${short(x.product, 200)}</h1>
+<p class="recall-head">${classBadge(x.classification)} ${x.status ? badge(x.status) : ''}</p>
+${x.product.length > 200 ? html`<p>${x.product}</p>` : ''}
+${x.reason ? html`<h2>Why it was recalled</h2><p>${x.reason}</p>` : ''}
+<h2>The details</h2>
+<dl class="recall-facts">${facts.map(([k, v]) => html`<dt>${k}</dt><dd>${v}</dd>`)}</dl>
+<p class="small">Compare the codes, sizes and dates in the description with the package you have.</p>
+<p><a href="/recalls">Every food recall</a> · <a href="${FDA_REPORTS}" rel="noopener">Search the FDA's reports for ${x.recall_number}</a></p>
+${noteBlock()}`,
+ });
+ });
+}
+
+module.exports = { mountRecalls, recallCard, date };
@@ -44,7 +44,9 @@ async function start() {
@@ -44,7 +44,9 @@ async function start() {
// The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.
// The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.
ctx.library.start();
ctx.library.start();
ctx.recipeSearch.start();
ctx.recipeSearch.start();
- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop(), () => ctx.recipeSearch.stop()];
+ // Food recalls follow OpenVibe.Sources' change feed hourly (server/food/recalls.js).
+ ctx.recalls.start();
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop(), () => ctx.recipeSearch.stop(), () => ctx.recalls.stop()];
createLifecycle({ server, ctx, extra });
createLifecycle({ server, ctx, extra });
return { server, ctx };
return { server, ctx };
}
}
@@ -48,7 +48,7 @@ async function boot(opts = {}) {
@@ -48,7 +48,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai, barcodes: opts.barcodes });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai, barcodes: opts.barcodes, recallSource: opts.recallSource });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+/**
+ * Food recalls (server/food/recalls.js, server/http/recall-pages.js):
+ * - a Sources item of openfda-food-enforcement becomes one row, as the report states it; anything else is held;
+ * - the pull follows the change feed from the cursor, a removed item is deleted, an older revision never wins;
+ * - /recalls lists, filters and pages them with openFDA's note; /recalls/:number shows one (noindex); the home shows
+ * the newest; the sitemap lists /recalls and no single recall.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { rowOf, day } = require('../server/food/recalls');
+
+const item = (n, over = {}, fields = {}) => ({
+ id: `itm_${n}`, source_key: 'openfda-food-enforcement', category: 'food', kind: 'recall', identity: `F-${String(n).padStart(4, '0')}-2027`,
+ canonical_url: null, title: `Frozen pepperoni pizza ${n}, Net Weight 17 oz, UPC 6 66186 27762 ${n}`, summary: `Undeclared milk (${n}).`,
+ fields: { recalling_firm: `Firm ${n}`, classification: n % 3 === 0 ? 'Class I' : 'Class II', status: 'Ongoing', state: n % 2 ? 'OH' : 'TX', distribution_pattern: 'KY, OH', voluntary_mandated: 'Voluntary: Firm initiated', recall_initiation_date: '20261001', report_date: `202610${String(10 + (n % 20)).padStart(2, '0')}`, ...fields },
+ revision: 1, provenance: { retrieved_at: '2026-10-22T10:00:00.000Z' }, removed: null, change_seq: n, ...over,
+});
+
+/** A stand-in for OpenVibe.Sources' change feed: items in change_seq order after the cursor. */
+function feed(items) {
+ const calls = [];
+ return {
+ calls,
+ items,
+ async listItems({ after = 0, limit = 100 }) {
+ calls.push(after);
+ const rest = items.filter((x) => x.change_seq > after).sort((a, b) => a.change_seq - b.change_seq);
+ const page = rest.slice(0, limit);
+ return { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rest.length > limit, sources: {} };
+ },
+ };
+}
+
+(async () => {
+ const items = Array.from({ length: 34 }, (_, i) => item(i + 1));
+ items.push({ ...item(90), kind: 'record', identity: 'not-a-recall', change_seq: 90 });
+ items.push({ ...item(91), source_key: 'fda-food-recalls', change_seq: 91 });
+ const source = feed(items);
+ const t = await boot({ recallSource: source });
+ try {
+ await check('a report becomes one row as stated; a bad number, another kind or another source is not a recall', async () => {
+ assert.strictEqual(day('20261014'), '2026-10-14');
+ assert.strictEqual(day('2026-10-14'), null);
+ assert.strictEqual(day('20261340'), null);
+ const row = rowOf(item(3));
+ assert.strictEqual(row.recall_number, 'F-0003-2027');
+ assert.strictEqual(row.classification, 'Class I');
+ assert.strictEqual(row.firm, 'Firm 3');
+ assert.strictEqual(row.reported_on, '2026-10-13');
+ assert.strictEqual(rowOf({ ...item(4), identity: 'javascript:alert(1)' }), null);
+ assert.strictEqual(rowOf({ ...item(4), title: '' }), null);
+ assert.strictEqual(rowOf(item(5, {}, { classification: 'Class IV' })).classification, null);
+ });
+
+ await check('the pull follows the change feed to its end and keeps the cursor', async () => {
+ const page = await t.get('/recalls');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('once OpenVibe.Sources has read them'), 'before the first pull the page says where recalls come from');
+ const out = await t.ctx.recalls.pull();
+ assert.strictEqual(out.applied, 34);
+ assert.strictEqual(out.hold, 2);
+ assert.strictEqual(await t.ctx.recalls.count(), 34);
+ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT cursor FROM food_ingest_cursor WHERE name = 'sources.food.after'")), 91);
+ const again = await t.ctx.recalls.pull();
+ assert.strictEqual(again.applied, 0, 'nothing new after the cursor');
+ });
+
+ await check('a removed item is deleted; an older revision arriving late changes nothing', async () => {
+ source.items.push({ ...item(2), removed: { at: '2026-10-22T11:00:00.000Z', reason: 'takedown' }, revision: 2, change_seq: 100 });
+ source.items.push({ ...item(5, { revision: 3, summary: 'Undeclared milk and egg.' }), change_seq: 101 });
+ source.items.push({ ...item(5, { revision: 2, summary: 'stale' }), change_seq: 102 });
+ const out = await t.ctx.recalls.pull();
+ assert.strictEqual(out.removed, 1);
+ assert.strictEqual(await t.ctx.recalls.get('F-0002-2027'), null);
+ assert.strictEqual((await t.ctx.recalls.get('F-0005-2027')).reason, 'Undeclared milk and egg.');
+ assert.strictEqual(await t.ctx.recalls.count(), 33);
+ });
+
+ await check('/recalls lists the newest report first with openFDA\'s note, and filters by words, class and state', async () => {
+ const all = await t.get('/recalls');
+ assert.strictEqual(all.status, 200);
+ assert.ok(all.text.includes('33 recalls so far'));
+ assert.ok(all.text.includes('not for medical decisions') && all.text.includes('not endorsed by the FDA'));
+ assert.ok(!/noindex/.test(all.text), 'the list is indexed');
+ assert.ok(all.text.includes('Page 1 of 2'));
+ assert.ok(all.text.indexOf('F-0029-2027') < all.text.indexOf('F-0028-2027'), 'newest report first');
+ const classI = await t.get('/recalls?class=Class+I');
+ const shown = classI.text.match(/\/recalls\/F-\d{4}-2027/g) || [];
+ assert.ok(shown.length && shown.every((h) => Number(h.slice(11, 15)) % 3 === 0), 'only Class I');
+ assert.ok(/noindex/.test(classI.text), 'a filtered list is not indexed');
+ const tx = await t.get('/recalls?state=TX&q=firm+1');
+ assert.ok((tx.text.match(/\/recalls\/F-\d{4}-2027/g) || []).every((h) => Number(h.slice(11, 15)) % 2 === 0));
+ const none = await t.get('/recalls?q=%3Cscript%3E');
+ assert.ok(none.text.includes('Nothing matched') && !none.text.includes('<script>alert'));
+ const odd = await t.get('/recalls?state=%27%3B--&class=Class+IV&page=-3');
+ assert.strictEqual(odd.status, 200);
+ });
+
+ await check('/recalls/:number shows the report in full and is noindex; an unknown number is a 404', async () => {
+ const one = await t.get('/recalls/F-0003-2027');
+ assert.strictEqual(one.status, 200);
+ assert.ok(one.text.includes('Firm 3') && one.text.includes('Undeclared milk (3).') && one.text.includes('Class I'));
+ assert.ok(one.text.includes('serious adverse health consequences or death'), 'the class is explained in the FDA\'s words');
+ assert.ok(/noindex/.test(one.text));
+ assert.ok(one.text.includes('not for medical decisions'));
+ assert.strictEqual((await t.get('/recalls/F-9999-2027')).status, 404);
+ assert.strictEqual((await t.get('/recalls/..%2F..%2Fetc')).status, 404);
+ });
+
+ await check('the home shows the newest recalls and the count; the sitemap lists /recalls only', async () => {
+ const home = await t.get('/');
+ assert.ok(home.text.includes('id="h-recalls"'));
+ assert.ok(home.text.includes('All 33 recalls'));
+ assert.ok(home.text.includes('FDA food recalls, newest first'));
+ const map = await t.get('/sitemap.xml');
+ assert.ok(map.text.includes('https://openvibe.food/recalls</loc>'));
+ assert.ok(!map.text.includes('/recalls/F-'));
+ const llms = await t.get('/llms.txt');
+ assert.ok(llms.text.includes('https://openvibe.food/recalls'));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();