openvibers/openvibe.reviews Commits

Landlords: NYC HPD's open housing-code violations per building (by class, with HPD Online's page) on a landlord page named by its address; README covers ratings from other sites and official records (#82)

OP OpenVibers committed 26 days ago · 588cb8b75a · 6 files, +117 −17

Changes

ddiff --git a/README.md b/README.md
iindex 0ffe505..e3835ea 100644
---- a/README.md
++++ b/README.md
@@@ -27,6 +27,31 @@ Glassdoor, Indeed, Blind, LowEndTalk, RTINGS, YouTube reviews, Amazon, G2, Capte
forms were checked in a real browser on 2026-10-15 (WebHostingTalk, ApartmentRatings and Rate My Landlord have no
search address that works). Reviews fetches and stores nothing from them; a result page is `noindex`.
++## What other sites show, brought by members
++
++A member can save the rating another site shows for something: Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google
++Maps or Tripadvisor. They do it at `/save` (the page's address, the rating, its scale, how many ratings) or with the
++OpenVibe Companion extension's "Save its rating to OpenVibe Reviews" button (0.5.0). That button reads the page's own
++structured data or star labels, only when pressed. The entity page shows the latest look per site, with the date and how
++many members saved it, beside that site's link (`review_seen_ratings`, migrations/0006). These are members' notes of
++what a page said, never signals and never in the aggregate. Capped at 60 a day per person; the person or an editor
++removes one.
++
++## Official records
++
++What a public body published about an entity, read through OpenVibe.Sources (`review_records`, migrations/0007, 0008).
++Each register has its own section on the entity page, never as signals or in the aggregate:
++
++| Source | Record | Entity |
++|---|---|---|
++| `cfpb-complaints-by-company` (weekly, CC0) | the CFPB complaint database's count for a company | an organization; added at 500 complaints or more |
++| `nyc-restaurant-inspections` (every 6 h) | NYC Health's inspection results: grade, score, violations (critical marked) | a restaurant per CAMIS id, named with its address |
++| `nyc-hpd-open-violations` (weekly) | NYC HPD's open housing-code violations by class (C immediately hazardous, B hazardous, A non-hazardous) | a landlord page per building, named by its address; added at 10 or more open class C |
++
++A record finds its entity by its external alias (`cfpb:`, `nyc-camis:`, `nyc-hpd:`). Otherwise it uses a same-named
++entity of that kind and place that holds no record of the same register, otherwise it adds one. So two restaurants of a
++chain, or two buildings, are never one page. Search gets one line per register.
++
## Owns
The nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_reviews` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
@@@ -43,7 +68,8 @@ The nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_r
| `review_trust_metadata` | editor trust decisions per source / signal / entity (exclude from the aggregate with a visible reason, limitation and verification notes), history kept |
| `review_entity_links` | typed links between entities or to another service's EntityRef; a merge is a `merged_into` link (who, when, why), a split ends it |
--Helper tables: `review_aggregates` (every aggregate revision, immutable), `review_summary_citations`,
++Helper tables: `review_aggregates` (every aggregate revision, immutable), `review_member_reviews`, `review_seen_ratings`,
++`review_records` (official records), `review_summary_citations`,
`review_corrections`, `review_audit` (append-only editorial log), `review_import_queue`,
`review_sync_state`, plus the Publishing packages' `review_summary_reviews`, `review_summary_drafts`,
`review_entity_redirects`, `review_discussion_refs`, `review_index_revisions` and the SDK's
ddiff --git a/migrations/0008_housing_records.sql b/migrations/0008_housing_records.sql
nnew file mode 100644
iindex 0000000..7517e23
---- /dev/null
++++ b/migrations/0008_housing_records.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- NYC HPD housing-code violations (server/reviews/service.js, "Official records"): one tally per apartment building of
++-- the violations HPD lists as open, by class (C immediately hazardous, B hazardous, A non-hazardous), read through
++-- OpenVibe.Sources (housing_violation_tally). The building is a landlord entity named by its address.
++ALTER TABLE review_records DROP CONSTRAINT IF EXISTS review_records_kind_check;
++ALTER TABLE review_records ADD CONSTRAINT review_records_kind_check CHECK (kind IN ('complaint_tally', 'inspection_violation', 'housing_violation_tally'));
ddiff --git a/public/css/reviews.css b/public/css/reviews.css
iindex 7177c9a..3d1e8d2 100644
---- a/public/css/reviews.css
++++ b/public/css/reviews.css
@@@ -135,6 +135,7 @@ code { overflow-wrap: anywhere; }
.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-classes { padding-left: 0; list-style: none; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px 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; }
ddiff --git a/server/render/views.js b/server/render/views.js
iindex be76783..b28653e 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -229,13 +229,18 @@ ${entity ? html`<input type="hidden" name="entity" value="${entity.slug}"><p>For
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. */
++/** What public bodies published about it: the CFPB's complaint count, NYC HPD's open violations, NYC Health's inspections. Records, not reviews. */
function recordsSection(rec, entity) {
-- if (!rec.complaints && !rec.inspections.length) return '';
++ if (!rec.complaints && !rec.housing && !rec.inspections.length) return '';
++ const h = rec.housing;
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>` : ''}
++${h ? html`<div class="rv-record"><p><span class="rv-big">${n(h.open)}</span> open housing-code violation${h.open === 1 ? '' : 's'} at ${h.address || entity.name}, ${h.boro ? String(h.boro).toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()) : 'New York'}${h.zip ? ` ${h.zip}` : ''}, in the NYC Department of Housing Preservation and Development's records (counted ${t(h.as_of)}${h.last ? html`; latest inspection ${t(h.last)}` : ''}).</p>
++<ul class="rv-classes"><li><b>${n(h.c)}</b> class C: immediately hazardous</li><li><b>${n(h.b)}</b> class B: hazardous</li><li><b>${n(h.a)}</b> class A: non-hazardous</li></ul>
++<p><a href="${h.url}" target="_blank" rel="noopener">The building on HPD Online</a></p>
++<p class="rv-muted">Open means HPD has not recorded the violation as corrected. Some stay open after a repair until the owner certifies it, so the count can run ahead of the building's condition today.</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>
@@@ -260,9 +265,10 @@ ${actor && actor.subject ? html`<form method="post" action="/review" class="rv-f
<p class="rv-muted">Looking for what other sites say? <a href="/everywhere">Search every review site</a>.</p></article>`;
}
--/** A list entry's official record, in a few words: its CFPB complaints, or its latest NYC inspection. */
++/** A list entry's official record, in a few words: its CFPB complaints, its open HPD class C violations, or its latest NYC inspection. */
function recordLine(x) {
if (x.complaints != null) return html`<span class="rv-muted"> · ${n(x.complaints)} CFPB complaint${x.complaints === 1 ? '' : 's'}</span>`;
++ if (x.hazardous != null) return html`<span class="rv-muted"> · ${n(x.hazardous)} open hazardous HPD violation${Number(x.hazardous) === 1 ? '' : 's'}</span>`;
if (x.inspected_on) return html`<span class="rv-muted"> · inspected ${t(x.inspected_on)}${x.grade ? html`, ${GRADE_TEXT[x.grade] || `grade ${x.grade}`}` : ''}</span>`;
return '';
}
@@@ -441,7 +447,7 @@ ${form}</section>`;
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 own = (members && members.stats.count) || (records && (records.complaints || records.housing || 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)})` : ''}.`);
ddiff --git a/server/reviews/service.js b/server/reviews/service.js
iindex 054ea45..323273a 100644
---- a/server/reviews/service.js
++++ b/server/reviews/service.js
@@@ -122,11 +122,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
entitiesOfKind: db.prepare(`SELECT e.*, (SELECT COUNT(*) FROM review_member_reviews m WHERE m.entity_id = e.id AND m.status = 'published') AS member_count,
(SELECT AVG(m.rating)::float8 FROM review_member_reviews m WHERE m.entity_id = e.id AND m.status = 'published') AS member_avg,
(SELECT MAX((r.fields->>'complaints')::bigint) FROM review_records r WHERE r.entity_id = e.id AND r.status = 'active' AND r.kind = 'complaint_tally') AS complaints,
++ (SELECT MAX((r.fields->>'c')::bigint) FROM review_records r WHERE r.entity_id = e.id AND r.status = 'active' AND r.kind = 'housing_violation_tally') AS hazardous,
(SELECT MAX(r.occurred_on) FROM review_records r WHERE r.entity_id = e.id AND r.status = 'active' AND r.kind = 'inspection_violation') AS inspected_on,
(SELECT r.fields->>'grade' FROM review_records r WHERE r.entity_id = e.id AND r.status = 'active' AND r.kind = 'inspection_violation' AND r.fields->>'grade' IS NOT NULL
ORDER BY r.occurred_on DESC LIMIT 1) AS grade
FROM review_entities e WHERE e.state = 'active' AND e.kind = ?
-- ORDER BY member_count DESC, complaints DESC NULLS LAST, inspected_on DESC NULLS LAST, e.updated_at DESC LIMIT ? OFFSET ?`),
++ ORDER BY member_count DESC, complaints DESC NULLS LAST, hazardous DESC NULLS LAST, inspected_on DESC NULLS LAST, e.updated_at DESC LIMIT ? OFFSET ?`),
countOfKind: db.prepare("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active' AND 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 > ?'),
@@@ -675,6 +676,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
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 h = rec.housing;
++ if (h) lines.push(`NYC HPD open housing-code violations: ${h.open} (class C immediately hazardous ${h.c}, B hazardous ${h.b}, A non-hazardous ${h.a}; as of ${String(h.as_of).slice(0, 10)}${h.last ? `, latest inspection ${h.last}` : ''})`);
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)`);
}
@@@ -943,12 +946,15 @@ 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']);
++ // What a public body published about an entity (migrations/0007, 0008): the CFPB complaint database's count for a
++ // company, NYC Health's inspection results for a restaurant, NYC HPD's open housing-code violations for an apartment
++ // building. They find their entity by an external alias (cfpb:<company>, nyc-camis:<camis>, nyc-hpd:<building>),
++ // else by its name (and place), else add it: every NYC restaurant inspected, the companies with
++ // RECORD_MIN_COMPLAINTS or more complaints, the buildings with RECORD_MIN_HAZARDOUS or more open class C
++ // violations (a landlord entity named by its address). Never signals, never in the aggregate.
++ const RECORD_KINDS = new Set(['complaint_tally', 'inspection_violation', 'housing_violation_tally']);
const RECORD_MIN_COMPLAINTS = 500;
++ const RECORD_MIN_HAZARDOUS = 10;
const BOROUGHS = { manhattan: 'Manhattan', brooklyn: 'Brooklyn', queens: 'Queens', bronx: 'Bronx', 'staten island': 'Staten Island' };
function recordTarget(item) {
const f = item.fields || {};
@@@ -957,6 +963,14 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
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 };
}
++ if (item.kind === 'housing_violation_tally') {
++ const building = String(f.buildingid || item.identity || '').trim();
++ const number = String(f.housenumber || '').trim();
++ const street = recordName(String(f.streetname || '').trim());
++ if (!/^\d{1,12}$/.test(building) || !number || !street) return null;
++ const boro = BOROUGHS[String(f.boro || '').trim().toLowerCase()] || 'New York';
++ return { alias: `nyc-hpd:${building}`, name: `${number} ${street}`.slice(0, 200), kind: 'landlord', place: `${boro}, NY`, create: Number(f.c) >= RECORD_MIN_HAZARDOUS };
++ }
const camis = String(f.camis || '').trim();
if (!/^\d{1,12}$/.test(camis) || !f.dba) return null;
const boro = BOROUGHS[String(f.boro || '').trim().toLowerCase()];
@@@ -1011,12 +1025,16 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const entityId = target ? await recordEntity(target, actor) : null;
if (!entityId) return { outcome: 'unchanged', item: null, signal: null };
const f = item.fields || {};
++ const count = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
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 }
++ ? { company: String(f.company || item.identity).slice(0, 200), complaints: count(f.complaints) }
++ : item.kind === 'housing_violation_tally'
++ ? { buildingid: String(f.buildingid || item.identity).slice(0, 12), boro: f.boro == null ? null : String(f.boro).slice(0, 40), housenumber: f.housenumber == null ? null : String(f.housenumber).slice(0, 20),
++ streetname: f.streetname == null ? null : String(f.streetname).slice(0, 120), zip: f.zip == null ? null : String(f.zip).slice(0, 10), open: count(f.open), c: count(f.c), b: count(f.b), a: count(f.a) }
: 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),
++ entity_id: entityId, occurred_on: item.kind === 'inspection_violation' ? dayOf(f.inspection_date) : item.kind === 'housing_violation_tally' ? dayOf(f.last) : 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 });
@@@ -1026,10 +1044,10 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
});
}
-- /** An entity's official records, for its page: the CFPB count, and NYC inspections grouped by day (newest first). */
++ /** An entity's official records, for its page: the CFPB count, HPD's open violations, NYC inspections 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 out = { complaints: null, housing: 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, {});
@@@ -1040,6 +1058,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
continue;
}
++ if (r.kind === 'housing_violation_tally') {
++ if (!out.housing || Number(f.c) > out.housing.c) {
++ out.housing = { building: f.buildingid, address: [f.housenumber, f.streetname].filter(Boolean).join(' ') || null, boro: f.boro, zip: f.zip,
++ open: f.open, c: f.c, b: f.b, a: f.a, last: r.occurred_on, as_of: r.retrieved_at, url: `https://hpdonline.nyc.gov/hpdonline/building/${encodeURIComponent(f.buildingid)}` };
++ }
++ 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: [] });
ddiff --git a/test/official-records.test.js b/test/official-records.test.js
iindex a269e27..081d59d 100644
---- a/test/official-records.test.js
++++ b/test/official-records.test.js
@@@ -2,8 +2,9 @@
/**
* 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
++ * (inspection_violation, one row per violation), and NYC HPD's open housing-code violations per building
++ * (housing_violation_tally). 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; buildings with 10 or more open class C violations). 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');
@@@ -15,6 +16,41 @@ const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: '
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 } });
++const hpd = (fields) => baseItem({ source: 'nyc-hpd-open-violations', kind: 'housing_violation_tally', identity: fields.buildingid, title: fields.streetname, fields: {
++ boro: 'BROOKLYN', zip: '11208', last: '2026-10-12T00:00:00.000', ...fields } });
++
++t('NYC HPD: a building with open hazardous violations is a landlord page with the counts by class', async () => {
++ const h = await boot();
++ try {
++ const eldert = hpd({ buildingid: '288857', housenumber: '760', streetname: 'ELDERT LANE', open: '1794', c: '423', b: '1118', a: '252' });
++ await importItem(h, eldert);
++ // Below RECORD_MIN_HAZARDOUS open class C violations, and nobody added the building: nothing is created.
++ await importItem(h, hpd({ buildingid: '11', housenumber: '1', streetname: 'QUIET PLACE', open: '12', c: '3', b: '6', a: '3' }));
++ const ents = await h.db.prepare("SELECT slug, name, kind, description FROM review_entities WHERE kind = 'landlord'").all();
++ assert.deepStrictEqual(ents.map((e) => [e.slug, e.name, e.description]), [['760-eldert-lane-brooklyn-ny', '760 Eldert Lane', 'Brooklyn, NY']]);
++
++ const page = (await req(h, 'GET', '/e/760-eldert-lane-brooklyn-ny')).text;
++ assert.match(page, /<span class="rv-big">1,794<\/span> open housing-code violations at 760 ELDERT LANE, Brooklyn 11208/);
++ assert.match(page, /<b>423<\/b> class C: immediately hazardous<\/li><li><b>1,118<\/b> class B: hazardous<\/li><li><b>252<\/b> class A: non-hazardous/);
++ assert.match(page, /href="https:\/\/hpdonline\.nyc\.gov\/hpdonline\/building\/288857"/);
++ assert.match(page, /Open means HPD has not recorded the violation as corrected/);
++ assert.ok(!/No aggregate/.test(page), 'no empty aggregate section beside the records');
++
++ // A newer week's count replaces the old one; a member's review of the same building lands on the same page.
++ await importItem(h, { ...eldert, revision: 2, fields: { ...eldert.fields, open: '1700', c: '400' } });
++ assert.match((await req(h, 'GET', '/e/760-eldert-lane-brooklyn-ny')).text, /<span class="rv-big">1,700<\/span> open/);
++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
++ const r = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'landlord', name: '760 Eldert Lane', place: 'Brooklyn, NY' } });
++ assert.strictEqual(r.status, 303);
++ assert.match(String(r.headers.get('location')), /\/e\/760-eldert-lane-brooklyn-ny#write$/, 'found, not added again');
++
++ // The list and Search say it in a few words.
++ assert.match((await req(h, 'GET', '/k/landlord')).text, /760 Eldert Lane[\s\S]*?400 open hazardous HPD violations/);
++ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted');
++ assert.ok(docs.some((d) => /NYC HPD open housing-code violations: 1700 \(class C immediately hazardous 400, B hazardous 1118, A non-hazardous 252; as of \d{4}-\d{2}-\d{2}, latest inspection 2026-10-12\)/.test(d.payload.body || '')));
++ } finally { await h.stop(); }
++});
++
t('company names read as the companies write them', () => {
const { recordName } = require('../server/reviews/service');
assert.strictEqual(recordName('JPMORGAN CHASE & CO.'), 'JPMorgan Chase & Co.');