openvibers/openvibe.reviews Commits
Official records: the CFPB complaint database's count for a company and NYC Health's inspections of a restaurant (from Sources) on the entity's page, in their own section, never as signals or in the aggregate; each finds its entity by alias, name or place, or adds it (every inspected NYC restaurant, companies with 500+ complaints); Search gets a line; communities and platforms can be reviewed (#79)
Changes
nnew file mode 100644@@@ -0,0 +1,25 @@++-- phase: expand++-- Official records (server/reviews/service.js, "Official records"): what a public body published about an entity, read++-- through OpenVibe.Sources — the CFPB complaint database's count for a company (complaint_tally) and NYC Health's++-- inspection results for a restaurant (inspection_violation, one row per violation cited, or per clean inspection).++-- They are records, not reviews: shown in their own section and never part of the aggregate.++CREATE TABLE review_records (++ id text COLLATE "C" PRIMARY KEY, -- rec_<ULID>++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),++ source_key text COLLATE "C" NOT NULL,++ source_item_id text COLLATE "C" NOT NULL UNIQUE, -- itm_<ULID>++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('complaint_tally', 'inspection_violation')),++ occurred_on text COLLATE "C", -- YYYY-MM-DD: the inspection's day++ fields jsonb NOT NULL DEFAULT '{}',++ item_revision bigint NOT NULL,++ retrieved_at text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX review_records_entity ON review_records (entity_id, kind, occurred_on DESC) WHERE status = 'active';++++-- Communities and platforms (the owner's list of what Reviews covers).++ALTER TABLE review_entities DROP CONSTRAINT IF EXISTS review_entities_kind_check;++ALTER TABLE review_entities ADD CONSTRAINT review_entities_kind_check CHECK (kind IN ('product','game','software','service','place','organization','media','other',++ 'employer','landlord','restaurant','business','hosting','app','vehicle','community','platform'));@@@ -134,3 +134,11 @@ code { overflow-wrap: anywhere; }.rv-seen b { font-size: 1.05em; }.rv-row { display: flex; flex-wrap: wrap; gap: 8px 16px; }.rv-row label { flex: 0 1 auto; }++.rv-record + .rv-record { margin-top: 18px; }++.rv-inspections { padding-left: 0; list-style: none; margin: 8px 0; }++.rv-inspections > li { padding: 8px 0; border-bottom: 1px solid var(--rv-line); }++.rv-inspections > li > p { margin: 0 0 4px; }++.rv-violations { margin: 4px 0 0; padding-left: 18px; }++.rv-violations li { margin: 2px 0; }++.rv-critical { background: color-mix(in srgb, #dc2626 16%, transparent); color: inherit; }++.rv-grade { font-weight: 700; }@@@ -35,7 +35,7 @@ const { createIndexSequencer } = require('openvibe-publishing/index-hooks');const seo = require('openvibe-publishing/seo');const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other',-- 'employer', 'landlord', 'restaurant', 'business', 'hosting', 'app', 'vehicle']; // the second row: what members review (0004)++ 'employer', 'landlord', 'restaurant', 'business', 'hosting', 'app', 'vehicle', 'community', 'platform']; // the second row: what members review (0004, 0007)const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];@@@ -283,8 +283,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiconst members = await svc.memberReviews(e, req.actor);const elsewhere = views.elsewhereLinks(e);const seen = await svc.seenRatings(e);++ const records = await svc.records(e);const signedIn = !!req.actor.subject;-- send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, reviewError, reviewValues }), {++ send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, records, reviewError, reviewValues }), {decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,@@@ -17,20 +17,24 @@ const e = encodeURIComponent;const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',-- employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle' };++ employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle',++ community: 'Community', platform: 'Platform' };const KIND_PLURAL = { employer: 'Employers', landlord: 'Landlords', restaurant: 'Restaurants', business: 'Local businesses', hosting: 'Hosting providers', product: 'Products',-- app: 'Apps', software: 'Software', game: 'Games', vehicle: 'Vehicles', service: 'Services', organization: 'Organizations', place: 'Places', media: 'Media', other: 'Other' };++ app: 'Apps', software: 'Software', game: 'Games', vehicle: 'Vehicles', service: 'Services', organization: 'Organizations', place: 'Places', media: 'Media', other: 'Other',++ community: 'Communities', platform: 'Platforms' };// How a member knew it, offered on the form (their choice, or none).const RELATIONS = {employer: ['Current employee', 'Former employee', 'Interviewed there', 'Contractor'], landlord: ['Current tenant', 'Former tenant', 'Applied to rent'],restaurant: ['Ate there', 'Ordered takeout', 'Ordered delivery'], business: ['Customer', 'Former customer'], hosting: ['Customer', 'Former customer', 'Tried it'],product: ['Own it', 'Owned it', 'Tried it'], vehicle: ['Own one', 'Owned one', 'Test-drove it', 'Rented one'], app: ['Use it', 'Used it'], software: ['Use it', 'Used it'],game: ['Played it'], service: ['Customer', 'Former customer'], organization: ['Customer', 'Member', 'Former member'], place: ['Visited'],++ community: ['Member', 'Former member', 'Moderator'], platform: ['Use it', 'Used it', 'Create on it'],};const LOCAL = new Set(['restaurant', 'business', 'landlord', 'place']);// Which sites /everywhere offers for a kind (server/reviews/everywhere.js).const EW_KIND = { employer: 'employer', landlord: 'landlord', restaurant: 'business', business: 'business', place: 'business', hosting: 'hosting', product: 'product',-- vehicle: 'product', app: 'software', software: 'software', game: 'software', service: 'company', organization: 'company', media: 'product', other: 'company' };++ vehicle: 'product', app: 'software', software: 'software', game: 'software', service: 'company', organization: 'company', media: 'product', other: 'company',++ community: 'company', platform: 'software' };const everywhere = require('../reviews/everywhere');const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };const REASON_LABEL = {@@@ -223,6 +227,22 @@ ${entity ? html`<input type="hidden" name="entity" value="${entity.slug}"><p>For</article>`;}++const GRADE_TEXT = { A: 'Grade A', B: 'Grade B', C: 'Grade C', N: 'Not yet graded', Z: 'Grade pending', P: 'Grade pending (reopening)' };++++/** What public bodies published about it: the CFPB's complaint count, NYC Health's inspections. Records, not reviews. */++function recordsSection(rec, entity) {++ if (!rec.complaints && !rec.inspections.length) return '';++ const r = rec.restaurant;++ return html`<section id="records" aria-labelledby="rec-h"><h2 id="rec-h">Official records</h2>++${rec.complaints ? html`<div class="rv-record"><p><span class="rv-big">${n(rec.complaints.count)}</span> complaint${rec.complaints.count === 1 ? '' : 's'} about ${rec.complaints.company} in the Consumer Financial Protection Bureau's complaint database, from every year since 2011 (counted ${t(rec.complaints.as_of)}). <a href="${rec.complaints.url}" target="_blank" rel="noopener">Read them on consumerfinance.gov</a></p>++<p class="rv-muted">These are complaints consumers sent about financial products, not findings: the CFPB does not verify them, and a company with more customers gets more.</p></div>` : ''}++${rec.inspections.length ? html`<div class="rv-record"><p>NYC Health inspections${r && r.address ? html` of ${entity.name} at ${r.address}, ${r.boro || 'New York'}${r.zipcode ? ` ${r.zipcode}` : ''}` : ''}${r && r.cuisine ? html` (${r.cuisine})` : ''}, newest first.</p>++<ol class="rv-inspections">${rec.inspections.map((d) => html`<li><p><b>${d.date ? t(d.date) : 'Date not stated'}</b>${d.grade ? html` · <span class="rv-grade rv-grade-${String(d.grade).toLowerCase()}">${GRADE_TEXT[d.grade] || `Grade ${d.grade}`}</span>` : ''}${d.score != null && !Number.isNaN(d.score) ? html` · score ${String(d.score)}` : ''}${d.type ? html` <span class="rv-muted">· ${d.type}</span>` : ''}</p>++${d.violations.length ? html`<ul class="rv-violations">${d.violations.map((v) => html`<li>${v.critical ? html`<span class="rv-tag rv-critical">Critical</span> ` : ''}${v.text || v.code}</li>`)}</ul>` : html`<p class="rv-muted">${d.action || 'No violations were recorded.'}</p>`}</li>`)}</ol>++<p class="rv-muted">From NYC Health's inspection results on NYC Open Data, as the inspector recorded them. A lower score is better (0 to 13 earns an A), and a letter grade is given only at some inspections. <a href="https://a816-health.nyc.gov/ABCEatsRestaurants/" target="_blank" rel="noopener">NYC Health's restaurant search</a></p></div>` : ''}++</section>`;++}++/** /review: name what to review; found, or added by the member. */function reviewStartPage({ values = {}, kinds, local, actor, error = null }) {const k = values.kind || 'employer';@@@ -254,7 +274,7 @@ ${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a}function home({ entities, total, editor, recent = [], kinds = {} }) {-- const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'product', 'app', 'game', 'vehicle'];++ const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];return html`${raw(reviewsHero())}<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2><ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>@@@ -411,8 +431,10 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>${form}</section>`;}--function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, reviewError = null, reviewValues = null }) {++function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, records = null, reviewError = null, reviewValues = null }) {const en = p.entity;++ // Something of its own to show (members' reviews or official records): the empty source sections stay out.++ const own = (members && members.stats.count) || (records && (records.complaints || records.inspections.length));const limits = [];for (const s of p.sources) {if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);@@@ -426,10 +448,11 @@ function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span>${LOCAL.has(en.kind) && en.description ? html` · ${en.description}` : ''} · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>${flash ? notice('ok', flash) : ''}${en.description && !LOCAL.has(en.kind) ? html`<p>${en.description}</p>` : ''}++${records ? recordsSection(records, en) : ''}${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}--${p.aggregate || p.signals.length || p.sources.length || !(members && members.stats.count) ? aggregateSection(p) : ''}--${p.summary || p.editor || !(members && members.stats.count) ? summarySection(p) : ''}++${p.aggregate || p.signals.length || p.sources.length || !own ? aggregateSection(p) : ''}++${p.summary || p.editor || !own ? summarySection(p) : ''}${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}@@@ -104,6 +104,16 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowkindCounts: db.prepare("SELECT kind, COUNT(*) AS n FROM review_entities WHERE state = 'active' GROUP BY kind"),memberReviewsSince: db.prepare('SELECT COUNT(*) AS n FROM review_member_reviews WHERE author = ? AND created_at > ?'),memberEntitiesSince: db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE created_by = ? AND created_at > ?'),++ // Official records (migrations/0007).++ recordByItem: db.prepare('SELECT * FROM review_records WHERE source_item_id = ?'),++ insertRecord: db.prepare(`INSERT INTO review_records (id, entity_id, source_key, source_item_id, kind, occurred_on, fields, item_revision, retrieved_at, status, created_at, updated_at)++ VALUES (@id, @entity_id, @source_key, @source_item_id, @kind, @occurred_on, @fields, @item_revision, @retrieved_at, 'active', @now, @now)`),++ updateRecord: db.prepare(`UPDATE review_records SET entity_id = @entity_id, occurred_on = @occurred_on, fields = @fields, item_revision = @item_revision,++ retrieved_at = @retrieved_at, status = 'active', updated_at = @now WHERE id = @id`),++ removeRecord: db.prepare("UPDATE review_records SET status = 'removed', updated_at = ? WHERE id = ?"),++ recordsOf: db.prepare(`SELECT kind, occurred_on, fields, retrieved_at, source_key FROM review_records WHERE entity_id = ANY(?::text[]) AND status = 'active'++ ORDER BY kind, occurred_on DESC NULLS LAST, retrieved_at DESC LIMIT 400`),++ externalAlias: db.prepare("SELECT entity_id FROM review_entity_aliases WHERE type = 'external' AND norm = ? AND removed_at IS NULL LIMIT 1"),// Ratings other sites show, as members saw them (migrations/0006).seenRatingsOf: db.prepare(`SELECT site, page_url, rating_value::float8 AS rating_value, rating_best::float8 AS rating_best, rating_count, seen_at, via, id, seen_byFROM review_seen_ratings WHERE entity_id = ? AND status = 'shown' ORDER BY seen_at DESC LIMIT 200`),@@@ -517,7 +527,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now// ── Members' reviews ─────────────────────────────────────// What members may add and review. Local kinds are told apart by their place (a city), kept as the description.-- const MEMBER_KINDS = ['employer', 'landlord', 'restaurant', 'business', 'hosting', 'product', 'app', 'software', 'game', 'vehicle', 'service', 'organization', 'place'];++ const MEMBER_KINDS = ['employer', 'landlord', 'restaurant', 'business', 'hosting', 'product', 'app', 'software', 'game', 'vehicle', 'service', 'organization', 'place', 'community', 'platform'];const LOCAL_KINDS = new Set(['restaurant', 'business', 'landlord', 'place']);const MEMBER_REVIEWS_PER_DAY = 30;const MEMBER_ENTITIES_PER_DAY = 20;@@@ -634,6 +644,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowif (agg && agg.result) lines.push(...agg.result.computation);const mem = live ? await memberStats(await closure(e.id)) : { count: 0 };if (mem.count) lines.push(`OpenVibe members: ${mem.average} out of 5 from ${mem.count} review${mem.count === 1 ? '' : 's'}`);++ if (live) {++ const rec = await recordsView(e.id);++ if (rec.complaints) lines.push(`CFPB consumer complaints: ${rec.complaints.count} in the CFPB's database (as of ${String(rec.complaints.as_of).slice(0, 10)})`);++ const last = rec.inspections[0];++ if (last) lines.push(`NYC Health inspection ${last.date}: ${last.grade ? `grade ${last.grade}, ` : ''}${last.score != null ? `score ${last.score}, ` : ''}${last.violations.length} violation${last.violations.length === 1 ? '' : 's'} (${last.violations.filter((v) => v.critical).length} critical)`);++ }if (live) for (const r of Object.values(await seenBySite(e.id))) lines.push(`${r.name}: ${r.value} out of ${r.best}${r.count != null ? ` from ${r.count} ratings` : ''} (seen ${r.seen_at.slice(0, 10)})`);const doc = hooks.buildIndexDocument({owner: 'reviews', type: 'entity', id: e.id, revision: 0,@@@ -898,6 +914,120 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now}}++ // ── Official records ─────────────────────────────────────++ // What a public body published about an entity (migrations/0007): the CFPB complaint database's count for a++ // company, NYC Health's inspection results for a restaurant. They find their entity by an external alias++ // (cfpb:<company>, nyc-camis:<camis>), else by its name (and place), else add it: every NYC restaurant inspected,++ // and the companies with RECORD_MIN_COMPLAINTS or more complaints. Never signals, never in the aggregate.++ const RECORD_KINDS = new Set(['complaint_tally', 'inspection_violation']);++ const RECORD_MIN_COMPLAINTS = 500;++ const BOROUGHS = { manhattan: 'Manhattan', brooklyn: 'Brooklyn', queens: 'Queens', bronx: 'Bronx', 'staten island': 'Staten Island' };++ const SMALL_WORDS = new Set(['a', 'an', 'and', 'at', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'de', 'la', 'del', 'n']);++ const KEEP_UPPER = /^(LLC|LLP|LP|INC|NA|N\.A\.|USA|US|U\.S\.|NY|NYC|BBQ|II|III|IV|FSB|PLC|AG|SA|BMW|HSBC|TD|USAA|PNC|BBVA|JPMORGAN|NFCU|MBA|IHOP|KFC)$/;++ /** "CAPITAL ONE FINANCIAL CORPORATION" → "Capital One Financial Corporation"; mixed-case names stay as written. */++ function recordName(raw) {++ const t = String(raw || '').replace(/\s+/g, ' ').trim().slice(0, 200);++ if (!t || /[a-z]/.test(t)) return t;++ return t.split(' ').map((w, i) => {++ if (KEEP_UPPER.test(w.replace(/[,.]$/, '').toUpperCase()) && !/^(INC|LLC)\.?,?$/.test(w)) return w;++ const lower = w.toLowerCase();++ if (i > 0 && SMALL_WORDS.has(lower)) return lower;++ return lower.replace(/(^|[-/&(.'])([a-z])/g, (m, p1, c) => p1 + c.toUpperCase()).replace(/^Mc([a-z])/, (m, c) => `Mc${c.toUpperCase()}`).replace(/'S\b/g, "'s");++ }).join(' ').replace(/\bInc\b/g, 'Inc.').replace(/\bInc\.\./g, 'Inc.').replace(/\bLlc\b/g, 'LLC');++ }++ function recordTarget(item) {++ const f = item.fields || {};++ if (item.kind === 'complaint_tally') {++ const company = String(f.company || item.identity || '').trim();++ if (!company) return null;++ return { alias: `cfpb:${company.slice(0, 200)}`, name: recordName(company), kind: 'organization', place: null, create: Number(f.complaints) >= RECORD_MIN_COMPLAINTS };++ }++ const camis = String(f.camis || '').trim();++ if (!/^\d{1,12}$/.test(camis) || !f.dba) return null;++ const boro = BOROUGHS[String(f.boro || '').trim().toLowerCase()];++ return { alias: `nyc-camis:${camis}`, name: recordName(f.dba), kind: 'restaurant', place: boro ? `${boro}, NY` : 'New York, NY', create: true };++ }++ async function recordEntity(target, actor) {++ const norm1 = norm.alias('external', target.alias);++ const hit = await q.externalAlias.get(norm1);++ if (hit) return await canonicalOf(hit.entity_id);++ const kinds = target.kind === 'organization' ? ['organization', 'employer', 'business', 'service'] : [target.kind];++ for (const k of kinds) {++ const same = (await q.sameNamedOfKind.all(norm.alias('name', target.name), k))++ .filter((x) => !LOCAL_KINDS.has(k) || String(x.description || '').toLowerCase() === String(target.place || '').toLowerCase());++ if (same.length) { const id = await canonicalOf(same[0].id); await insertAlias(id, 'external', target.alias, actor); return id; }++ }++ if (!target.create) return null;++ const t = now();++ const id = `ent_${ulid(t)}`;++ const slug = await uniqueSlug(LOCAL_KINDS.has(target.kind) && target.place ? `${target.name} ${target.place}` : target.name);++ await redirects.release(`/e/${slug}`);++ await q.insertEntity.run({ id, slug, name: target.name, kind: target.kind, description: target.place, created_by: 'service:reviews', now: t });++ await insertAlias(id, 'name', target.name, actor);++ await insertAlias(id, 'external', target.alias, actor);++ await audit(actor, 'entity.added_from_records', id, null, { name: target.name, kind: target.kind, place: target.place, alias: target.alias });++ return id;++ }++ const dayOf = (v) => { const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v || '')); return m ? m[1] : null; };++ /** One official-record item → its record row (created, replaced, or removed with its item). */++ async function applyRecord(item, { sourceInfo, actor }) {++ const prov = item.provenance;++ return await tx(async () => {++ await upsertSource(item.source_key, prov, sourceInfo);++ const row = await q.recordByItem.get(item.id);++ const t = now();++ if (item.removed) {++ if (!row || row.status === 'removed') return { outcome: row ? 'unchanged' : 'removed:unknown', item: null, signal: null };++ await q.removeRecord.run(t, row.id);++ await afterSignalChange(row.entity_id, 'record_removed');++ return { outcome: 'removed', item: null, signal: null };++ }++ if (row && item.revision <= Number(row.item_revision)) return { outcome: 'unchanged', item: null, signal: null };++ const target = recordTarget(item);++ const entityId = target ? await recordEntity(target, actor) : null;++ if (!entityId) return { outcome: 'unchanged', item: null, signal: null };++ const f = item.fields || {};++ const keep = item.kind === 'complaint_tally'++ ? { company: String(f.company || item.identity).slice(0, 200), complaints: Number.isFinite(Number(f.complaints)) ? Number(f.complaints) : null }++ : Object.fromEntries(['camis', 'dba', 'boro', 'building', 'street', 'zipcode', 'cuisine', 'inspection_type', 'action', 'violation_code', 'violation', 'critical_flag', 'score', 'grade']++ .map((k) => [k, f[k] == null ? null : String(f[k]).slice(0, 500)]));++ const values = {++ entity_id: entityId, occurred_on: item.kind === 'inspection_violation' ? dayOf(f.inspection_date) : null, fields: JSON.stringify(keep),++ item_revision: item.revision, retrieved_at: new Date(prov.retrieved_at).toISOString(), now: t,++ };++ if (row) await q.updateRecord.run({ ...values, id: row.id });++ else await q.insertRecord.run({ ...values, id: `rec_${ulid(t)}`, source_key: item.source_key, source_item_id: item.id, kind: item.kind });++ await afterSignalChange(entityId, 'record');++ return { outcome: row ? 'updated' : 'created', item: null, signal: null };++ });++ }++++ /** An entity's official records, for its page: the CFPB count, and NYC inspections grouped by day (newest first). */++ async function recordsView(entityId) {++ const rows = await q.recordsOf.all([...await closure(entityId)]);++ const out = { complaints: null, inspections: [], restaurant: null };++ const byDay = new Map();++ for (const r of rows) {++ const f = r.fields && typeof r.fields === 'object' ? r.fields : parse(r.fields, {});++ if (r.kind === 'complaint_tally') {++ if (!out.complaints || Number(f.complaints) > out.complaints.count) {++ out.complaints = { company: f.company, count: Number(f.complaints), as_of: r.retrieved_at,++ url: `https://www.consumerfinance.gov/data-research/consumer-complaints/search/?company=${encodeURIComponent(f.company)}&dateRange=All&searchField=all&tab=Map` };++ }++ continue;++ }++ if (!out.restaurant) out.restaurant = { camis: f.camis, address: [f.building, f.street].filter(Boolean).join(' ') || null, boro: f.boro, zipcode: f.zipcode, cuisine: f.cuisine };++ const day = r.occurred_on || 'unknown';++ if (!byDay.has(day)) byDay.set(day, { date: r.occurred_on, type: f.inspection_type, action: f.action, score: null, grade: null, violations: [] });++ const d = byDay.get(day);++ if (f.score != null && f.score !== '' && d.score == null) d.score = Number(f.score);++ if (f.grade && !d.grade) d.grade = f.grade;++ if (f.violation_code && !d.violations.some((v) => v.code === f.violation_code)) d.violations.push({ code: f.violation_code, text: f.violation, critical: f.critical_flag === 'Critical' });++ }++ out.inspections = [...byDay.values()].slice(0, 8);++ return out;++ }++/*** Apply one Sources item (sources.item@1). Idempotent: the same item revision twice changes* nothing but the last-seen time. Synchronous (runs inside the caller's transaction).@@@ -906,6 +1036,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowasync function applyItem(item, { sourceInfo = null, actor = { kind: 'system', service: 'svc:reviews' } } = {}) {checkItem(item);if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };++ if (RECORD_KINDS.has(item.kind)) return await applyRecord(item, { sourceInfo, actor });const prov = item.provenance;const t = now();return await tx(async () => {@@@ -1328,6 +1459,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now/** The latest look at each site for an entity (merged ones included), with how many members saw that site. */async seenRatings(e) { return await seenBySite(e.id); },++ async records(e) { return await recordsView(e.id); },/*** A member brings the rating a page of another site states: { site?, url, value, best, count?, via } for an@@@ -249,6 +249,6 @@ function suite(name) {module.exports = {boot, req, userToken, editorToken, readerToken, serviceToken, subject, cookieFor, outbox, suite,-- fakeSources, fakeCommunity, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent,++ fakeSources, fakeCommunity, baseItem, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent,IMPORT, EDITOR, READER, WEBHOOK_SECRET, ISSUER, publicKey, privateKey, quiet,};nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * Official records (migrations/0007): what a public body published about an entity, from OpenVibe.Sources —++ * the CFPB complaint database's count per company (complaint_tally) and NYC Health's inspection results++ * (inspection_violation, one row per violation). A record finds its entity by its external alias, else by name (and++ * place), else adds it (every NYC restaurant; companies with 500 complaints or more). The page shows them in their own++ * section, never as signals or in the aggregate; Search gets a line; a removed item takes its record away.++ */++const assert = require('assert');++const { boot, req, suite, baseItem, importItem, userToken, cookieFor, subject, outbox } = require('./helpers');++++const t = suite('official-records');++const cfpb = (company, complaints, extra = {}) => baseItem({ source: 'cfpb-complaints-by-company', kind: 'complaint_tally', identity: company, title: company, fields: { company, complaints }, ...extra });++const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: 'inspection_violation', identity: `row-${Math.random().toString(36).slice(2, 10)}`, title: fields.dba, fields: {++ camis: '50071318', dba: 'THE COFFEE CLUB', boro: 'Brooklyn', building: '9526', street: '4 AVENUE', zipcode: '11209', cuisine: 'Coffee/Tea',++ inspection_type: 'Cycle Inspection / Initial Inspection', action: 'Violations were cited in the following area(s).', critical_flag: 'Not Critical', score: '20', grade: null, ...fields } });++++t('CFPB counts and NYC inspections land on entities, shown as official records, not signals', async () => {++ const h = await boot();++ try {++ // A big company is added with a readable name; a small one with no entity here is left alone.++ await importItem(h, cfpb('CAPITAL ONE FINANCIAL CORPORATION', 34041));++ await importItem(h, cfpb('Small Lender LLC', 3));++ let ents = await h.db.prepare("SELECT slug, name, kind, created_by FROM review_entities ORDER BY created_at").all();++ assert.deepStrictEqual(ents.map((e) => [e.slug, e.name, e.kind, e.created_by]), [['capital-one-financial-corporation', 'Capital One Financial Corporation', 'organization', 'service:reviews']]);++++ // A member's entity with the same name gets the record (and the alias), not a second entity.++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));++ await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'employer', name: 'JPMorgan Chase & Co.' } });++ await importItem(h, cfpb('JPMORGAN CHASE & CO.', 26433));++ ents = await h.db.prepare("SELECT slug FROM review_entities ORDER BY created_at").all();++ assert.strictEqual(ents.length, 2, 'matched the member\'s JPMorgan Chase & Co.');++++ // A later count replaces the earlier one (a newer revision of the same item).++ const later = cfpb('CAPITAL ONE FINANCIAL CORPORATION', 34100);++ await importItem(h, later);++ const page = await req(h, 'GET', '/e/capital-one-financial-corporation');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<h2 id="rec-h">Official records<\/h2>/);++ assert.match(page.text, /<span class="rv-big">34,100<\/span> complaints about CAPITAL ONE FINANCIAL CORPORATION in the Consumer Financial Protection Bureau/);++ assert.match(page.text, /consumerfinance\.gov\/data-research\/consumer-complaints\/search\/\?company=CAPITAL%20ONE%20FINANCIAL%20CORPORATION/);++ assert.match(page.text, /the CFPB does not verify them/);++ assert.ok(!/No aggregate/.test(page.text), 'no empty aggregate section beside the records');++ assert.strictEqual(Number((await h.db.prepare('SELECT COUNT(*) AS n FROM review_signals').get()).n), 0, 'records are never signals');++++ // NYC: two violations and a later clean inspection with a grade make one restaurant with two inspections.++ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not properly washed.', critical_flag: 'Critical' }));++ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '10F', violation: 'Non-food contact surface improperly constructed.' }));++ const clean = nyc({ inspection_date: '2026-10-15T00:00:00.000', inspection_type: 'Cycle Inspection / Re-inspection', action: 'No violations were recorded at the time of this inspection.', score: '7', grade: 'A', critical_flag: 'Not Applicable' });++ await importItem(h, clean);++ const rest = await h.db.prepare("SELECT slug, name, kind, description FROM review_entities WHERE kind = 'restaurant'").all();++ assert.deepStrictEqual(rest.map((e) => [e.slug, e.name, e.description]), [['the-coffee-club-brooklyn-ny', 'The Coffee Club', 'Brooklyn, NY']]);++ const rp = await req(h, 'GET', '/e/the-coffee-club-brooklyn-ny');++ assert.match(rp.text, /NYC Health inspections of The Coffee Club at 9526 4 AVENUE, Brooklyn 11209 \(Coffee\/Tea\), newest first/);++ const firstLi = rp.text.indexOf('rv-grade-a'), olderLi = rp.text.indexOf('Food contact surface not properly washed.');++ assert.ok(firstLi > 0 && olderLi > firstLi, 'the newest inspection (grade A) comes first');++ assert.match(rp.text, /<span class="rv-tag rv-critical">Critical<\/span> Food contact surface not properly washed\./);++ assert.match(rp.text, /score 20/);++ assert.match(rp.text, /No violations were recorded at the time of this inspection\./);++++ // Search carries the facts.++ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted');++ assert.ok(docs.some((d) => /CFPB consumer complaints: 34100/.test(d.payload.body || '')));++ assert.ok(docs.some((d) => /NYC Health inspection 2026-10-15: grade A, score 7, 0 violations/.test(d.payload.body || '')));++++ // The source removes the clean inspection's row: the page goes back to the older inspection.++ h.sources.put({ ...clean, revision: 2, removed: { at: new Date().toISOString(), reason: 'withdrawn by the city' } });++ assert.strictEqual((await req(h, 'POST', '/api/v1/signals/import', { token: require('./helpers').IMPORT(), body: { source_item_id: clean.id } })).status, 200);++ assert.ok(!/rv-grade-a/.test((await req(h, 'GET', '/e/the-coffee-club-brooklyn-ny')).text));++++ // /k/organization and /k/restaurant list them; the home offers both kinds and the new ones.++ assert.match((await req(h, 'GET', '/k/organization')).text, /Capital One Financial Corporation/);++ assert.match((await req(h, 'GET', '/k/restaurant')).text, /The Coffee Club/);++ const home = (await req(h, 'GET', '/')).text;++ for (const k of ['organization', 'community', 'platform']) assert.ok(home.includes(`href="/k/${k}"`), k);++ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'community', name: 'r/selfhosted' } })).status, 303, 'a member can review a community');++ } finally { await h.stop(); }++});@@@ -12,7 +12,7 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 28.5, // measured 27.0 (fresh database; 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)++ htmlRawKB: 30, // measured 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)htmlBrotliKB: 7, // 6.6 (5.9, 4.6 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+-- phase: expand
+-- Official records (server/reviews/service.js, "Official records"): what a public body published about an entity, read
+-- through OpenVibe.Sources — the CFPB complaint database's count for a company (complaint_tally) and NYC Health's
+-- inspection results for a restaurant (inspection_violation, one row per violation cited, or per clean inspection).
+-- They are records, not reviews: shown in their own section and never part of the aggregate.
+CREATE TABLE review_records (
+ id text COLLATE "C" PRIMARY KEY, -- rec_<ULID>
+ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
+ source_key text COLLATE "C" NOT NULL,
+ source_item_id text COLLATE "C" NOT NULL UNIQUE, -- itm_<ULID>
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('complaint_tally', 'inspection_violation')),
+ occurred_on text COLLATE "C", -- YYYY-MM-DD: the inspection's day
+ fields jsonb NOT NULL DEFAULT '{}',
+ item_revision bigint NOT NULL,
+ retrieved_at text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX review_records_entity ON review_records (entity_id, kind, occurred_on DESC) WHERE status = 'active';
+
+-- Communities and platforms (the owner's list of what Reviews covers).
+ALTER TABLE review_entities DROP CONSTRAINT IF EXISTS review_entities_kind_check;
+ALTER TABLE review_entities ADD CONSTRAINT review_entities_kind_check CHECK (kind IN ('product','game','software','service','place','organization','media','other',
+ 'employer','landlord','restaurant','business','hosting','app','vehicle','community','platform'));
@@ -134,3 +134,11 @@ code { overflow-wrap: anywhere; }
@@ -134,3 +134,11 @@ code { overflow-wrap: anywhere; }
.rv-seen b { font-size: 1.05em; }
.rv-seen b { font-size: 1.05em; }
.rv-row { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.rv-row { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.rv-row label { flex: 0 1 auto; }
.rv-row label { flex: 0 1 auto; }
+.rv-record + .rv-record { margin-top: 18px; }
+.rv-inspections { padding-left: 0; list-style: none; margin: 8px 0; }
+.rv-inspections > li { padding: 8px 0; border-bottom: 1px solid var(--rv-line); }
+.rv-inspections > li > p { margin: 0 0 4px; }
+.rv-violations { margin: 4px 0 0; padding-left: 18px; }
+.rv-violations li { margin: 2px 0; }
+.rv-critical { background: color-mix(in srgb, #dc2626 16%, transparent); color: inherit; }
+.rv-grade { font-weight: 700; }
@@ -35,7 +35,7 @@ const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
@@ -35,7 +35,7 @@ const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other',
const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other',
- 'employer', 'landlord', 'restaurant', 'business', 'hosting', 'app', 'vehicle']; // the second row: what members review (0004)
+ 'employer', 'landlord', 'restaurant', 'business', 'hosting', 'app', 'vehicle', 'community', 'platform']; // the second row: what members review (0004, 0007)
const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];
const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];
const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];
const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];
const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];
const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];
@@ -283,8 +283,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -283,8 +283,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const members = await svc.memberReviews(e, req.actor);
const members = await svc.memberReviews(e, req.actor);
const elsewhere = views.elsewhereLinks(e);
const elsewhere = views.elsewhereLinks(e);
const seen = await svc.seenRatings(e);
const seen = await svc.seenRatings(e);
+ const records = await svc.records(e);
const signedIn = !!req.actor.subject;
const signedIn = !!req.actor.subject;
- send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, reviewError, reviewValues }), {
+ send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, records, reviewError, reviewValues }), {
decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),
decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),
description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description
description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description
: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,
: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,
@@ -17,20 +17,24 @@ const e = encodeURIComponent;
@@ -17,20 +17,24 @@ const e = encodeURIComponent;
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',
const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',
- employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle' };
+ employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle',
+ community: 'Community', platform: 'Platform' };
const KIND_PLURAL = { employer: 'Employers', landlord: 'Landlords', restaurant: 'Restaurants', business: 'Local businesses', hosting: 'Hosting providers', product: 'Products',
const KIND_PLURAL = { employer: 'Employers', landlord: 'Landlords', restaurant: 'Restaurants', business: 'Local businesses', hosting: 'Hosting providers', product: 'Products',
- app: 'Apps', software: 'Software', game: 'Games', vehicle: 'Vehicles', service: 'Services', organization: 'Organizations', place: 'Places', media: 'Media', other: 'Other' };
+ app: 'Apps', software: 'Software', game: 'Games', vehicle: 'Vehicles', service: 'Services', organization: 'Organizations', place: 'Places', media: 'Media', other: 'Other',
+ community: 'Communities', platform: 'Platforms' };
// How a member knew it, offered on the form (their choice, or none).
// How a member knew it, offered on the form (their choice, or none).
const RELATIONS = {
const RELATIONS = {
employer: ['Current employee', 'Former employee', 'Interviewed there', 'Contractor'], landlord: ['Current tenant', 'Former tenant', 'Applied to rent'],
employer: ['Current employee', 'Former employee', 'Interviewed there', 'Contractor'], landlord: ['Current tenant', 'Former tenant', 'Applied to rent'],
restaurant: ['Ate there', 'Ordered takeout', 'Ordered delivery'], business: ['Customer', 'Former customer'], hosting: ['Customer', 'Former customer', 'Tried it'],
restaurant: ['Ate there', 'Ordered takeout', 'Ordered delivery'], business: ['Customer', 'Former customer'], hosting: ['Customer', 'Former customer', 'Tried it'],
product: ['Own it', 'Owned it', 'Tried it'], vehicle: ['Own one', 'Owned one', 'Test-drove it', 'Rented one'], app: ['Use it', 'Used it'], software: ['Use it', 'Used it'],
product: ['Own it', 'Owned it', 'Tried it'], vehicle: ['Own one', 'Owned one', 'Test-drove it', 'Rented one'], app: ['Use it', 'Used it'], software: ['Use it', 'Used it'],
game: ['Played it'], service: ['Customer', 'Former customer'], organization: ['Customer', 'Member', 'Former member'], place: ['Visited'],
game: ['Played it'], service: ['Customer', 'Former customer'], organization: ['Customer', 'Member', 'Former member'], place: ['Visited'],
+ community: ['Member', 'Former member', 'Moderator'], platform: ['Use it', 'Used it', 'Create on it'],
};
};
const LOCAL = new Set(['restaurant', 'business', 'landlord', 'place']);
const LOCAL = new Set(['restaurant', 'business', 'landlord', 'place']);
// Which sites /everywhere offers for a kind (server/reviews/everywhere.js).
// Which sites /everywhere offers for a kind (server/reviews/everywhere.js).
const EW_KIND = { employer: 'employer', landlord: 'landlord', restaurant: 'business', business: 'business', place: 'business', hosting: 'hosting', product: 'product',
const EW_KIND = { employer: 'employer', landlord: 'landlord', restaurant: 'business', business: 'business', place: 'business', hosting: 'hosting', product: 'product',
- vehicle: 'product', app: 'software', software: 'software', game: 'software', service: 'company', organization: 'company', media: 'product', other: 'company' };
+ vehicle: 'product', app: 'software', software: 'software', game: 'software', service: 'company', organization: 'company', media: 'product', other: 'company',
+ community: 'company', platform: 'software' };
const everywhere = require('../reviews/everywhere');
const everywhere = require('../reviews/everywhere');
const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };
const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };
const REASON_LABEL = {
const REASON_LABEL = {
@@ -223,6 +227,22 @@ ${entity ? html`<input type="hidden" name="entity" value="${entity.slug}"><p>For
@@ -223,6 +227,22 @@ ${entity ? html`<input type="hidden" name="entity" value="${entity.slug}"><p>For
</article>`;
</article>`;
}
}
+const GRADE_TEXT = { A: 'Grade A', B: 'Grade B', C: 'Grade C', N: 'Not yet graded', Z: 'Grade pending', P: 'Grade pending (reopening)' };
+
+/** What public bodies published about it: the CFPB's complaint count, NYC Health's inspections. Records, not reviews. */
+function recordsSection(rec, entity) {
+ if (!rec.complaints && !rec.inspections.length) return '';
+ const r = rec.restaurant;
+ return html`<section id="records" aria-labelledby="rec-h"><h2 id="rec-h">Official records</h2>
+${rec.complaints ? html`<div class="rv-record"><p><span class="rv-big">${n(rec.complaints.count)}</span> complaint${rec.complaints.count === 1 ? '' : 's'} about ${rec.complaints.company} in the Consumer Financial Protection Bureau's complaint database, from every year since 2011 (counted ${t(rec.complaints.as_of)}). <a href="${rec.complaints.url}" target="_blank" rel="noopener">Read them on consumerfinance.gov</a></p>
+<p class="rv-muted">These are complaints consumers sent about financial products, not findings: the CFPB does not verify them, and a company with more customers gets more.</p></div>` : ''}
+${rec.inspections.length ? html`<div class="rv-record"><p>NYC Health inspections${r && r.address ? html` of ${entity.name} at ${r.address}, ${r.boro || 'New York'}${r.zipcode ? ` ${r.zipcode}` : ''}` : ''}${r && r.cuisine ? html` (${r.cuisine})` : ''}, newest first.</p>
+<ol class="rv-inspections">${rec.inspections.map((d) => html`<li><p><b>${d.date ? t(d.date) : 'Date not stated'}</b>${d.grade ? html` · <span class="rv-grade rv-grade-${String(d.grade).toLowerCase()}">${GRADE_TEXT[d.grade] || `Grade ${d.grade}`}</span>` : ''}${d.score != null && !Number.isNaN(d.score) ? html` · score ${String(d.score)}` : ''}${d.type ? html` <span class="rv-muted">· ${d.type}</span>` : ''}</p>
+${d.violations.length ? html`<ul class="rv-violations">${d.violations.map((v) => html`<li>${v.critical ? html`<span class="rv-tag rv-critical">Critical</span> ` : ''}${v.text || v.code}</li>`)}</ul>` : html`<p class="rv-muted">${d.action || 'No violations were recorded.'}</p>`}</li>`)}</ol>
+<p class="rv-muted">From NYC Health's inspection results on NYC Open Data, as the inspector recorded them. A lower score is better (0 to 13 earns an A), and a letter grade is given only at some inspections. <a href="https://a816-health.nyc.gov/ABCEatsRestaurants/" target="_blank" rel="noopener">NYC Health's restaurant search</a></p></div>` : ''}
+</section>`;
+}
+
/** /review: name what to review; found, or added by the member. */
/** /review: name what to review; found, or added by the member. */
function reviewStartPage({ values = {}, kinds, local, actor, error = null }) {
function reviewStartPage({ values = {}, kinds, local, actor, error = null }) {
const k = values.kind || 'employer';
const k = values.kind || 'employer';
@@ -254,7 +274,7 @@ ${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a
@@ -254,7 +274,7 @@ ${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a
}
}
function home({ entities, total, editor, recent = [], kinds = {} }) {
function home({ entities, total, editor, recent = [], kinds = {} }) {
- const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'product', 'app', 'game', 'vehicle'];
+ const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];
return html`${raw(reviewsHero())}
return html`${raw(reviewsHero())}
<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
@@ -411,8 +431,10 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
@@ -411,8 +431,10 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
${form}</section>`;
${form}</section>`;
}
}
-function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, reviewError = null, reviewValues = null }) {
+function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, records = null, reviewError = null, reviewValues = null }) {
const en = p.entity;
const en = p.entity;
+ // Something of its own to show (members' reviews or official records): the empty source sections stay out.
+ const own = (members && members.stats.count) || (records && (records.complaints || records.inspections.length));
const limits = [];
const limits = [];
for (const s of p.sources) {
for (const s of p.sources) {
if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);
if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);
@@ -426,10 +448,11 @@ function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [
@@ -426,10 +448,11 @@ function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [
<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span>${LOCAL.has(en.kind) && en.description ? html` · ${en.description}` : ''} · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>
<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span>${LOCAL.has(en.kind) && en.description ? html` · ${en.description}` : ''} · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>
${flash ? notice('ok', flash) : ''}
${flash ? notice('ok', flash) : ''}
${en.description && !LOCAL.has(en.kind) ? html`<p>${en.description}</p>` : ''}
${en.description && !LOCAL.has(en.kind) ? html`<p>${en.description}</p>` : ''}
+${records ? recordsSection(records, en) : ''}
${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}
${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
-${p.aggregate || p.signals.length || p.sources.length || !(members && members.stats.count) ? aggregateSection(p) : ''}
-${p.summary || p.editor || !(members && members.stats.count) ? summarySection(p) : ''}
+${p.aggregate || p.signals.length || p.sources.length || !own ? aggregateSection(p) : ''}
+${p.summary || p.editor || !own ? summarySection(p) : ''}
${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>
${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>
${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
@@ -104,6 +104,16 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -104,6 +104,16 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
kindCounts: db.prepare("SELECT kind, COUNT(*) AS n FROM review_entities WHERE state = 'active' GROUP BY kind"),
kindCounts: db.prepare("SELECT kind, COUNT(*) AS n FROM review_entities WHERE state = 'active' GROUP BY kind"),
memberReviewsSince: db.prepare('SELECT COUNT(*) AS n FROM review_member_reviews WHERE author = ? AND created_at > ?'),
memberReviewsSince: db.prepare('SELECT COUNT(*) AS n FROM review_member_reviews WHERE author = ? AND created_at > ?'),
memberEntitiesSince: db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE created_by = ? AND created_at > ?'),
memberEntitiesSince: db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE created_by = ? AND created_at > ?'),
+ // Official records (migrations/0007).
+ recordByItem: db.prepare('SELECT * FROM review_records WHERE source_item_id = ?'),
+ insertRecord: db.prepare(`INSERT INTO review_records (id, entity_id, source_key, source_item_id, kind, occurred_on, fields, item_revision, retrieved_at, status, created_at, updated_at)
+ VALUES (@id, @entity_id, @source_key, @source_item_id, @kind, @occurred_on, @fields, @item_revision, @retrieved_at, 'active', @now, @now)`),
+ updateRecord: db.prepare(`UPDATE review_records SET entity_id = @entity_id, occurred_on = @occurred_on, fields = @fields, item_revision = @item_revision,
+ retrieved_at = @retrieved_at, status = 'active', updated_at = @now WHERE id = @id`),
+ removeRecord: db.prepare("UPDATE review_records SET status = 'removed', updated_at = ? WHERE id = ?"),
+ recordsOf: db.prepare(`SELECT kind, occurred_on, fields, retrieved_at, source_key FROM review_records WHERE entity_id = ANY(?::text[]) AND status = 'active'
+ ORDER BY kind, occurred_on DESC NULLS LAST, retrieved_at DESC LIMIT 400`),
+ externalAlias: db.prepare("SELECT entity_id FROM review_entity_aliases WHERE type = 'external' AND norm = ? AND removed_at IS NULL LIMIT 1"),
// Ratings other sites show, as members saw them (migrations/0006).
// Ratings other sites show, as members saw them (migrations/0006).
seenRatingsOf: db.prepare(`SELECT site, page_url, rating_value::float8 AS rating_value, rating_best::float8 AS rating_best, rating_count, seen_at, via, id, seen_by
seenRatingsOf: db.prepare(`SELECT site, page_url, rating_value::float8 AS rating_value, rating_best::float8 AS rating_best, rating_count, seen_at, via, id, seen_by
FROM review_seen_ratings WHERE entity_id = ? AND status = 'shown' ORDER BY seen_at DESC LIMIT 200`),
FROM review_seen_ratings WHERE entity_id = ? AND status = 'shown' ORDER BY seen_at DESC LIMIT 200`),
@@ -517,7 +527,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -517,7 +527,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
// ── Members' reviews ─────────────────────────────────────
// ── Members' reviews ─────────────────────────────────────
// What members may add and review. Local kinds are told apart by their place (a city), kept as the description.
// What members may add and review. Local kinds are told apart by their place (a city), kept as the description.
- const MEMBER_KINDS = ['employer', 'landlord', 'restaurant', 'business', 'hosting', 'product', 'app', 'software', 'game', 'vehicle', 'service', 'organization', 'place'];
+ const MEMBER_KINDS = ['employer', 'landlord', 'restaurant', 'business', 'hosting', 'product', 'app', 'software', 'game', 'vehicle', 'service', 'organization', 'place', 'community', 'platform'];
const LOCAL_KINDS = new Set(['restaurant', 'business', 'landlord', 'place']);
const LOCAL_KINDS = new Set(['restaurant', 'business', 'landlord', 'place']);
const MEMBER_REVIEWS_PER_DAY = 30;
const MEMBER_REVIEWS_PER_DAY = 30;
const MEMBER_ENTITIES_PER_DAY = 20;
const MEMBER_ENTITIES_PER_DAY = 20;
@@ -634,6 +644,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -634,6 +644,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (agg && agg.result) lines.push(...agg.result.computation);
if (agg && agg.result) lines.push(...agg.result.computation);
const mem = live ? await memberStats(await closure(e.id)) : { count: 0 };
const mem = live ? await memberStats(await closure(e.id)) : { count: 0 };
if (mem.count) lines.push(`OpenVibe members: ${mem.average} out of 5 from ${mem.count} review${mem.count === 1 ? '' : 's'}`);
if (mem.count) lines.push(`OpenVibe members: ${mem.average} out of 5 from ${mem.count} review${mem.count === 1 ? '' : 's'}`);
+ if (live) {
+ const rec = await recordsView(e.id);
+ if (rec.complaints) lines.push(`CFPB consumer complaints: ${rec.complaints.count} in the CFPB's database (as of ${String(rec.complaints.as_of).slice(0, 10)})`);
+ const last = rec.inspections[0];
+ if (last) lines.push(`NYC Health inspection ${last.date}: ${last.grade ? `grade ${last.grade}, ` : ''}${last.score != null ? `score ${last.score}, ` : ''}${last.violations.length} violation${last.violations.length === 1 ? '' : 's'} (${last.violations.filter((v) => v.critical).length} critical)`);
+ }
if (live) for (const r of Object.values(await seenBySite(e.id))) lines.push(`${r.name}: ${r.value} out of ${r.best}${r.count != null ? ` from ${r.count} ratings` : ''} (seen ${r.seen_at.slice(0, 10)})`);
if (live) for (const r of Object.values(await seenBySite(e.id))) lines.push(`${r.name}: ${r.value} out of ${r.best}${r.count != null ? ` from ${r.count} ratings` : ''} (seen ${r.seen_at.slice(0, 10)})`);
const doc = hooks.buildIndexDocument({
const doc = hooks.buildIndexDocument({
owner: 'reviews', type: 'entity', id: e.id, revision: 0,
owner: 'reviews', type: 'entity', id: e.id, revision: 0,
@@ -898,6 +914,120 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -898,6 +914,120 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
}
}
}
+ // ── Official records ─────────────────────────────────────
+ // What a public body published about an entity (migrations/0007): the CFPB complaint database's count for a
+ // company, NYC Health's inspection results for a restaurant. They find their entity by an external alias
+ // (cfpb:<company>, nyc-camis:<camis>), else by its name (and place), else add it: every NYC restaurant inspected,
+ // and the companies with RECORD_MIN_COMPLAINTS or more complaints. Never signals, never in the aggregate.
+ const RECORD_KINDS = new Set(['complaint_tally', 'inspection_violation']);
+ const RECORD_MIN_COMPLAINTS = 500;
+ const BOROUGHS = { manhattan: 'Manhattan', brooklyn: 'Brooklyn', queens: 'Queens', bronx: 'Bronx', 'staten island': 'Staten Island' };
+ const SMALL_WORDS = new Set(['a', 'an', 'and', 'at', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'de', 'la', 'del', 'n']);
+ const KEEP_UPPER = /^(LLC|LLP|LP|INC|NA|N\.A\.|USA|US|U\.S\.|NY|NYC|BBQ|II|III|IV|FSB|PLC|AG|SA|BMW|HSBC|TD|USAA|PNC|BBVA|JPMORGAN|NFCU|MBA|IHOP|KFC)$/;
+ /** "CAPITAL ONE FINANCIAL CORPORATION" → "Capital One Financial Corporation"; mixed-case names stay as written. */
+ function recordName(raw) {
+ const t = String(raw || '').replace(/\s+/g, ' ').trim().slice(0, 200);
+ if (!t || /[a-z]/.test(t)) return t;
+ return t.split(' ').map((w, i) => {
+ if (KEEP_UPPER.test(w.replace(/[,.]$/, '').toUpperCase()) && !/^(INC|LLC)\.?,?$/.test(w)) return w;
+ const lower = w.toLowerCase();
+ if (i > 0 && SMALL_WORDS.has(lower)) return lower;
+ return lower.replace(/(^|[-/&(.'])([a-z])/g, (m, p1, c) => p1 + c.toUpperCase()).replace(/^Mc([a-z])/, (m, c) => `Mc${c.toUpperCase()}`).replace(/'S\b/g, "'s");
+ }).join(' ').replace(/\bInc\b/g, 'Inc.').replace(/\bInc\.\./g, 'Inc.').replace(/\bLlc\b/g, 'LLC');
+ }
+ function recordTarget(item) {
+ const f = item.fields || {};
+ if (item.kind === 'complaint_tally') {
+ const company = String(f.company || item.identity || '').trim();
+ if (!company) return null;
+ return { alias: `cfpb:${company.slice(0, 200)}`, name: recordName(company), kind: 'organization', place: null, create: Number(f.complaints) >= RECORD_MIN_COMPLAINTS };
+ }
+ const camis = String(f.camis || '').trim();
+ if (!/^\d{1,12}$/.test(camis) || !f.dba) return null;
+ const boro = BOROUGHS[String(f.boro || '').trim().toLowerCase()];
+ return { alias: `nyc-camis:${camis}`, name: recordName(f.dba), kind: 'restaurant', place: boro ? `${boro}, NY` : 'New York, NY', create: true };
+ }
+ async function recordEntity(target, actor) {
+ const norm1 = norm.alias('external', target.alias);
+ const hit = await q.externalAlias.get(norm1);
+ if (hit) return await canonicalOf(hit.entity_id);
+ const kinds = target.kind === 'organization' ? ['organization', 'employer', 'business', 'service'] : [target.kind];
+ for (const k of kinds) {
+ const same = (await q.sameNamedOfKind.all(norm.alias('name', target.name), k))
+ .filter((x) => !LOCAL_KINDS.has(k) || String(x.description || '').toLowerCase() === String(target.place || '').toLowerCase());
+ if (same.length) { const id = await canonicalOf(same[0].id); await insertAlias(id, 'external', target.alias, actor); return id; }
+ }
+ if (!target.create) return null;
+ const t = now();
+ const id = `ent_${ulid(t)}`;
+ const slug = await uniqueSlug(LOCAL_KINDS.has(target.kind) && target.place ? `${target.name} ${target.place}` : target.name);
+ await redirects.release(`/e/${slug}`);
+ await q.insertEntity.run({ id, slug, name: target.name, kind: target.kind, description: target.place, created_by: 'service:reviews', now: t });
+ await insertAlias(id, 'name', target.name, actor);
+ await insertAlias(id, 'external', target.alias, actor);
+ await audit(actor, 'entity.added_from_records', id, null, { name: target.name, kind: target.kind, place: target.place, alias: target.alias });
+ return id;
+ }
+ const dayOf = (v) => { const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v || '')); return m ? m[1] : null; };
+ /** One official-record item → its record row (created, replaced, or removed with its item). */
+ async function applyRecord(item, { sourceInfo, actor }) {
+ const prov = item.provenance;
+ return await tx(async () => {
+ await upsertSource(item.source_key, prov, sourceInfo);
+ const row = await q.recordByItem.get(item.id);
+ const t = now();
+ if (item.removed) {
+ if (!row || row.status === 'removed') return { outcome: row ? 'unchanged' : 'removed:unknown', item: null, signal: null };
+ await q.removeRecord.run(t, row.id);
+ await afterSignalChange(row.entity_id, 'record_removed');
+ return { outcome: 'removed', item: null, signal: null };
+ }
+ if (row && item.revision <= Number(row.item_revision)) return { outcome: 'unchanged', item: null, signal: null };
+ const target = recordTarget(item);
+ const entityId = target ? await recordEntity(target, actor) : null;
+ if (!entityId) return { outcome: 'unchanged', item: null, signal: null };
+ const f = item.fields || {};
+ const keep = item.kind === 'complaint_tally'
+ ? { company: String(f.company || item.identity).slice(0, 200), complaints: Number.isFinite(Number(f.complaints)) ? Number(f.complaints) : null }
+ : Object.fromEntries(['camis', 'dba', 'boro', 'building', 'street', 'zipcode', 'cuisine', 'inspection_type', 'action', 'violation_code', 'violation', 'critical_flag', 'score', 'grade']
+ .map((k) => [k, f[k] == null ? null : String(f[k]).slice(0, 500)]));
+ const values = {
+ entity_id: entityId, occurred_on: item.kind === 'inspection_violation' ? dayOf(f.inspection_date) : null, fields: JSON.stringify(keep),
+ item_revision: item.revision, retrieved_at: new Date(prov.retrieved_at).toISOString(), now: t,
+ };
+ if (row) await q.updateRecord.run({ ...values, id: row.id });
+ else await q.insertRecord.run({ ...values, id: `rec_${ulid(t)}`, source_key: item.source_key, source_item_id: item.id, kind: item.kind });
+ await afterSignalChange(entityId, 'record');
+ return { outcome: row ? 'updated' : 'created', item: null, signal: null };
+ });
+ }
+
+ /** An entity's official records, for its page: the CFPB count, and NYC inspections grouped by day (newest first). */
+ async function recordsView(entityId) {
+ const rows = await q.recordsOf.all([...await closure(entityId)]);
+ const out = { complaints: null, inspections: [], restaurant: null };
+ const byDay = new Map();
+ for (const r of rows) {
+ const f = r.fields && typeof r.fields === 'object' ? r.fields : parse(r.fields, {});
+ if (r.kind === 'complaint_tally') {
+ if (!out.complaints || Number(f.complaints) > out.complaints.count) {
+ out.complaints = { company: f.company, count: Number(f.complaints), as_of: r.retrieved_at,
+ url: `https://www.consumerfinance.gov/data-research/consumer-complaints/search/?company=${encodeURIComponent(f.company)}&dateRange=All&searchField=all&tab=Map` };
+ }
+ continue;
+ }
+ if (!out.restaurant) out.restaurant = { camis: f.camis, address: [f.building, f.street].filter(Boolean).join(' ') || null, boro: f.boro, zipcode: f.zipcode, cuisine: f.cuisine };
+ const day = r.occurred_on || 'unknown';
+ if (!byDay.has(day)) byDay.set(day, { date: r.occurred_on, type: f.inspection_type, action: f.action, score: null, grade: null, violations: [] });
+ const d = byDay.get(day);
+ if (f.score != null && f.score !== '' && d.score == null) d.score = Number(f.score);
+ if (f.grade && !d.grade) d.grade = f.grade;
+ if (f.violation_code && !d.violations.some((v) => v.code === f.violation_code)) d.violations.push({ code: f.violation_code, text: f.violation, critical: f.critical_flag === 'Critical' });
+ }
+ out.inspections = [...byDay.values()].slice(0, 8);
+ return out;
+ }
+
/**
/**
* Apply one Sources item (sources.item@1). Idempotent: the same item revision twice changes
* Apply one Sources item (sources.item@1). Idempotent: the same item revision twice changes
* nothing but the last-seen time. Synchronous (runs inside the caller's transaction).
* nothing but the last-seen time. Synchronous (runs inside the caller's transaction).
@@ -906,6 +1036,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -906,6 +1036,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
async function applyItem(item, { sourceInfo = null, actor = { kind: 'system', service: 'svc:reviews' } } = {}) {
async function applyItem(item, { sourceInfo = null, actor = { kind: 'system', service: 'svc:reviews' } } = {}) {
checkItem(item);
checkItem(item);
if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };
if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };
+ if (RECORD_KINDS.has(item.kind)) return await applyRecord(item, { sourceInfo, actor });
const prov = item.provenance;
const prov = item.provenance;
const t = now();
const t = now();
return await tx(async () => {
return await tx(async () => {
@@ -1328,6 +1459,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1328,6 +1459,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
/** The latest look at each site for an entity (merged ones included), with how many members saw that site. */
/** The latest look at each site for an entity (merged ones included), with how many members saw that site. */
async seenRatings(e) { return await seenBySite(e.id); },
async seenRatings(e) { return await seenBySite(e.id); },
+ async records(e) { return await recordsView(e.id); },
/**
/**
* A member brings the rating a page of another site states: { site?, url, value, best, count?, via } for an
* A member brings the rating a page of another site states: { site?, url, value, best, count?, via } for an
@@ -249,6 +249,6 @@ function suite(name) {
@@ -249,6 +249,6 @@ function suite(name) {
module.exports = {
module.exports = {
boot, req, userToken, editorToken, readerToken, serviceToken, subject, cookieFor, outbox, suite,
boot, req, userToken, editorToken, readerToken, serviceToken, subject, cookieFor, outbox, suite,
- fakeSources, fakeCommunity, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent,
+ fakeSources, fakeCommunity, baseItem, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent,
IMPORT, EDITOR, READER, WEBHOOK_SECRET, ISSUER, publicKey, privateKey, quiet,
IMPORT, EDITOR, READER, WEBHOOK_SECRET, ISSUER, publicKey, privateKey, quiet,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * Official records (migrations/0007): what a public body published about an entity, from OpenVibe.Sources —
+ * the CFPB complaint database's count per company (complaint_tally) and NYC Health's inspection results
+ * (inspection_violation, one row per violation). A record finds its entity by its external alias, else by name (and
+ * place), else adds it (every NYC restaurant; companies with 500 complaints or more). The page shows them in their own
+ * section, never as signals or in the aggregate; Search gets a line; a removed item takes its record away.
+ */
+const assert = require('assert');
+const { boot, req, suite, baseItem, importItem, userToken, cookieFor, subject, outbox } = require('./helpers');
+
+const t = suite('official-records');
+const cfpb = (company, complaints, extra = {}) => baseItem({ source: 'cfpb-complaints-by-company', kind: 'complaint_tally', identity: company, title: company, fields: { company, complaints }, ...extra });
+const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: 'inspection_violation', identity: `row-${Math.random().toString(36).slice(2, 10)}`, title: fields.dba, fields: {
+ camis: '50071318', dba: 'THE COFFEE CLUB', boro: 'Brooklyn', building: '9526', street: '4 AVENUE', zipcode: '11209', cuisine: 'Coffee/Tea',
+ inspection_type: 'Cycle Inspection / Initial Inspection', action: 'Violations were cited in the following area(s).', critical_flag: 'Not Critical', score: '20', grade: null, ...fields } });
+
+t('CFPB counts and NYC inspections land on entities, shown as official records, not signals', async () => {
+ const h = await boot();
+ try {
+ // A big company is added with a readable name; a small one with no entity here is left alone.
+ await importItem(h, cfpb('CAPITAL ONE FINANCIAL CORPORATION', 34041));
+ await importItem(h, cfpb('Small Lender LLC', 3));
+ let ents = await h.db.prepare("SELECT slug, name, kind, created_by FROM review_entities ORDER BY created_at").all();
+ assert.deepStrictEqual(ents.map((e) => [e.slug, e.name, e.kind, e.created_by]), [['capital-one-financial-corporation', 'Capital One Financial Corporation', 'organization', 'service:reviews']]);
+
+ // A member's entity with the same name gets the record (and the alias), not a second entity.
+ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
+ await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'employer', name: 'JPMorgan Chase & Co.' } });
+ await importItem(h, cfpb('JPMORGAN CHASE & CO.', 26433));
+ ents = await h.db.prepare("SELECT slug FROM review_entities ORDER BY created_at").all();
+ assert.strictEqual(ents.length, 2, 'matched the member\'s JPMorgan Chase & Co.');
+
+ // A later count replaces the earlier one (a newer revision of the same item).
+ const later = cfpb('CAPITAL ONE FINANCIAL CORPORATION', 34100);
+ await importItem(h, later);
+ const page = await req(h, 'GET', '/e/capital-one-financial-corporation');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<h2 id="rec-h">Official records<\/h2>/);
+ assert.match(page.text, /<span class="rv-big">34,100<\/span> complaints about CAPITAL ONE FINANCIAL CORPORATION in the Consumer Financial Protection Bureau/);
+ assert.match(page.text, /consumerfinance\.gov\/data-research\/consumer-complaints\/search\/\?company=CAPITAL%20ONE%20FINANCIAL%20CORPORATION/);
+ assert.match(page.text, /the CFPB does not verify them/);
+ assert.ok(!/No aggregate/.test(page.text), 'no empty aggregate section beside the records');
+ assert.strictEqual(Number((await h.db.prepare('SELECT COUNT(*) AS n FROM review_signals').get()).n), 0, 'records are never signals');
+
+ // NYC: two violations and a later clean inspection with a grade make one restaurant with two inspections.
+ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not properly washed.', critical_flag: 'Critical' }));
+ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '10F', violation: 'Non-food contact surface improperly constructed.' }));
+ const clean = nyc({ inspection_date: '2026-10-15T00:00:00.000', inspection_type: 'Cycle Inspection / Re-inspection', action: 'No violations were recorded at the time of this inspection.', score: '7', grade: 'A', critical_flag: 'Not Applicable' });
+ await importItem(h, clean);
+ const rest = await h.db.prepare("SELECT slug, name, kind, description FROM review_entities WHERE kind = 'restaurant'").all();
+ assert.deepStrictEqual(rest.map((e) => [e.slug, e.name, e.description]), [['the-coffee-club-brooklyn-ny', 'The Coffee Club', 'Brooklyn, NY']]);
+ const rp = await req(h, 'GET', '/e/the-coffee-club-brooklyn-ny');
+ assert.match(rp.text, /NYC Health inspections of The Coffee Club at 9526 4 AVENUE, Brooklyn 11209 \(Coffee\/Tea\), newest first/);
+ const firstLi = rp.text.indexOf('rv-grade-a'), olderLi = rp.text.indexOf('Food contact surface not properly washed.');
+ assert.ok(firstLi > 0 && olderLi > firstLi, 'the newest inspection (grade A) comes first');
+ assert.match(rp.text, /<span class="rv-tag rv-critical">Critical<\/span> Food contact surface not properly washed\./);
+ assert.match(rp.text, /score 20/);
+ assert.match(rp.text, /No violations were recorded at the time of this inspection\./);
+
+ // Search carries the facts.
+ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted');
+ assert.ok(docs.some((d) => /CFPB consumer complaints: 34100/.test(d.payload.body || '')));
+ assert.ok(docs.some((d) => /NYC Health inspection 2026-10-15: grade A, score 7, 0 violations/.test(d.payload.body || '')));
+
+ // The source removes the clean inspection's row: the page goes back to the older inspection.
+ h.sources.put({ ...clean, revision: 2, removed: { at: new Date().toISOString(), reason: 'withdrawn by the city' } });
+ assert.strictEqual((await req(h, 'POST', '/api/v1/signals/import', { token: require('./helpers').IMPORT(), body: { source_item_id: clean.id } })).status, 200);
+ assert.ok(!/rv-grade-a/.test((await req(h, 'GET', '/e/the-coffee-club-brooklyn-ny')).text));
+
+ // /k/organization and /k/restaurant list them; the home offers both kinds and the new ones.
+ assert.match((await req(h, 'GET', '/k/organization')).text, /Capital One Financial Corporation/);
+ assert.match((await req(h, 'GET', '/k/restaurant')).text, /The Coffee Club/);
+ const home = (await req(h, 'GET', '/')).text;
+ for (const k of ['organization', 'community', 'platform']) assert.ok(home.includes(`href="/k/${k}"`), k);
+ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'community', name: 'r/selfhosted' } })).status, 303, 'a member can review a community');
+ } finally { await h.stop(); }
+});
@@ -12,7 +12,7 @@ const { spawn } = require('child_process');
@@ -12,7 +12,7 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 28.5, // measured 27.0 (fresh database; 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)
+ htmlRawKB: 30, // measured 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)
htmlBrotliKB: 7, // 6.6 (5.9, 4.6 before the showcase)
htmlBrotliKB: 7, // 6.6 (5.9, 4.6 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)