openvibers/openvibe.reviews Commits
NYC restaurants: the street address is part of the place, and a same-named entity takes a record only when it holds no record of that register yet, so a chain's restaurants in one borough stay apart (24 Manhattan/Brooklyn chains were merged on the first import) (#81)
Changes
@@@ -141,6 +141,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowrecordsOf: 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"),++ externalOfScheme: db.prepare("SELECT 1 FROM review_entity_aliases WHERE entity_id = ? AND type = 'external' AND norm LIKE ? 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`),@@@ -959,16 +960,25 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowconst 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 };++ // The street address is part of the place: a chain has many restaurants of one name in one borough.++ const street = [String(f.building || '').trim(), recordName(String(f.street || '').trim())].filter(Boolean).join(' ');++ return { alias: `nyc-camis:${camis}`, name: recordName(f.dba), kind: 'restaurant', place: `${street ? `${street}, ` : ''}${boro || 'New York'}, NY`.slice(0, 120), 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];++ // A same-named entity takes the record only when no record of the same register is on it already: two NYC++ // restaurants (or two CFPB companies) are never one entity.++ const scheme = `${target.alias.slice(0, target.alias.indexOf(':') + 1)}%`;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());++ const same = [];++ for (const x of await q.sameNamedOfKind.all(norm.alias('name', target.name), k)) {++ if (LOCAL_KINDS.has(k) && String(x.description || '').toLowerCase() !== String(target.place || '').toLowerCase()) continue;++ if (await q.externalOfScheme.get(x.id, scheme)) continue;++ same.push(x);++ }if (same.length) { const id = await canonicalOf(same[0].id); await insertAlias(id, 'external', target.alias, actor); return id; }}if (!target.create) return null;@@@ -59,8 +59,8 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,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.deepStrictEqual(rest.map((e) => [e.slug, e.name, e.description]), [['the-coffee-club-9526-4-avenue-brooklyn-ny', 'The Coffee Club', '9526 4 Avenue, Brooklyn, NY']]);++ const rp = await req(h, 'GET', '/e/the-coffee-club-9526-4-avenue-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');@@@ -68,6 +68,15 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,assert.match(rp.text, /score 20/);assert.match(rp.text, /No violations were recorded at the time of this inspection\./);++ // Two restaurants of one chain in one borough stay two (their own address and inspections each).++ await importItem(h, nyc({ camis: '40000001', dba: 'STARBUCKS', boro: 'Manhattan', building: '38', street: 'PARK ROW', zipcode: '10038', inspection_date: '2026-10-14T00:00:00.000', violation_code: '04N', violation: 'Flies.' }));++ await importItem(h, nyc({ camis: '40000002', dba: 'STARBUCKS', boro: 'Manhattan', building: '1', street: 'BROADWAY', zipcode: '10004', inspection_date: '2026-10-13T00:00:00.000', violation_code: '10F', violation: 'Surface.' }));++ await importItem(h, nyc({ camis: '40000001', dba: 'STARBUCKS', boro: 'Manhattan', building: '38', street: 'PARK ROW', zipcode: '10038', inspection_date: '2026-10-14T00:00:00.000', violation_code: '06C', violation: 'Food not protected.' }));++ const sb = await h.db.prepare("SELECT slug, description FROM review_entities WHERE name = 'Starbucks' ORDER BY slug").all();++ assert.deepStrictEqual(sb.map((e) => e.description), ['1 Broadway, Manhattan, NY', '38 Park Row, Manhattan, NY']);++ const parkRow = (await req(h, 'GET', '/e/starbucks-38-park-row-manhattan-ny')).text;++ assert.ok(parkRow.includes('Flies.') && parkRow.includes('Food not protected.') && !parkRow.includes('Surface.'), 'each keeps its own inspections');++// 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 || '')));@@@ -76,7 +85,7 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,// 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));++ assert.ok(!/rv-grade-a/.test((await req(h, 'GET', '/e/the-coffee-club-9526-4-avenue-brooklyn-ny')).text));// /k/organization and /k/restaurant list them; the home offers both kinds and the new ones.// Lists order by members' reviews, then by the records, and say what the record is.
@@ -141,6 +141,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -141,6 +141,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
recordsOf: db.prepare(`SELECT kind, occurred_on, fields, retrieved_at, source_key FROM review_records WHERE entity_id = ANY(?::text[]) AND status = 'active'
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`),
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"),
externalAlias: db.prepare("SELECT entity_id FROM review_entity_aliases WHERE type = 'external' AND norm = ? AND removed_at IS NULL LIMIT 1"),
+ externalOfScheme: db.prepare("SELECT 1 FROM review_entity_aliases WHERE entity_id = ? AND type = 'external' AND norm LIKE ? 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`),
@@ -959,16 +960,25 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -959,16 +960,25 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const camis = String(f.camis || '').trim();
const camis = String(f.camis || '').trim();
if (!/^\d{1,12}$/.test(camis) || !f.dba) return null;
if (!/^\d{1,12}$/.test(camis) || !f.dba) return null;
const boro = BOROUGHS[String(f.boro || '').trim().toLowerCase()];
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 };
+ // The street address is part of the place: a chain has many restaurants of one name in one borough.
+ const street = [String(f.building || '').trim(), recordName(String(f.street || '').trim())].filter(Boolean).join(' ');
+ return { alias: `nyc-camis:${camis}`, name: recordName(f.dba), kind: 'restaurant', place: `${street ? `${street}, ` : ''}${boro || 'New York'}, NY`.slice(0, 120), create: true };
}
}
async function recordEntity(target, actor) {
async function recordEntity(target, actor) {
const norm1 = norm.alias('external', target.alias);
const norm1 = norm.alias('external', target.alias);
const hit = await q.externalAlias.get(norm1);
const hit = await q.externalAlias.get(norm1);
if (hit) return await canonicalOf(hit.entity_id);
if (hit) return await canonicalOf(hit.entity_id);
const kinds = target.kind === 'organization' ? ['organization', 'employer', 'business', 'service'] : [target.kind];
const kinds = target.kind === 'organization' ? ['organization', 'employer', 'business', 'service'] : [target.kind];
+ // A same-named entity takes the record only when no record of the same register is on it already: two NYC
+ // restaurants (or two CFPB companies) are never one entity.
+ const scheme = `${target.alias.slice(0, target.alias.indexOf(':') + 1)}%`;
for (const k of kinds) {
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());
+ const same = [];
+ for (const x of await q.sameNamedOfKind.all(norm.alias('name', target.name), k)) {
+ if (LOCAL_KINDS.has(k) && String(x.description || '').toLowerCase() !== String(target.place || '').toLowerCase()) continue;
+ if (await q.externalOfScheme.get(x.id, scheme)) continue;
+ same.push(x);
+ }
if (same.length) { const id = await canonicalOf(same[0].id); await insertAlias(id, 'external', target.alias, actor); return id; }
if (same.length) { const id = await canonicalOf(same[0].id); await insertAlias(id, 'external', target.alias, actor); return id; }
}
}
if (!target.create) return null;
if (!target.create) return null;
@@ -59,8 +59,8 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
@@ -59,8 +59,8 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
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' });
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);
await importItem(h, clean);
const rest = await h.db.prepare("SELECT slug, name, kind, description FROM review_entities WHERE kind = 'restaurant'").all();
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.deepStrictEqual(rest.map((e) => [e.slug, e.name, e.description]), [['the-coffee-club-9526-4-avenue-brooklyn-ny', 'The Coffee Club', '9526 4 Avenue, Brooklyn, NY']]);
+ const rp = await req(h, 'GET', '/e/the-coffee-club-9526-4-avenue-brooklyn-ny');
assert.match(rp.text, /NYC Health inspections of The Coffee Club at 9526 4 AVENUE, Brooklyn 11209 \(Coffee\/Tea\), newest first/);
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.');
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.ok(firstLi > 0 && olderLi > firstLi, 'the newest inspection (grade A) comes first');
@@ -68,6 +68,15 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
@@ -68,6 +68,15 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
assert.match(rp.text, /score 20/);
assert.match(rp.text, /score 20/);
assert.match(rp.text, /No violations were recorded at the time of this inspection\./);
assert.match(rp.text, /No violations were recorded at the time of this inspection\./);
+ // Two restaurants of one chain in one borough stay two (their own address and inspections each).
+ await importItem(h, nyc({ camis: '40000001', dba: 'STARBUCKS', boro: 'Manhattan', building: '38', street: 'PARK ROW', zipcode: '10038', inspection_date: '2026-10-14T00:00:00.000', violation_code: '04N', violation: 'Flies.' }));
+ await importItem(h, nyc({ camis: '40000002', dba: 'STARBUCKS', boro: 'Manhattan', building: '1', street: 'BROADWAY', zipcode: '10004', inspection_date: '2026-10-13T00:00:00.000', violation_code: '10F', violation: 'Surface.' }));
+ await importItem(h, nyc({ camis: '40000001', dba: 'STARBUCKS', boro: 'Manhattan', building: '38', street: 'PARK ROW', zipcode: '10038', inspection_date: '2026-10-14T00:00:00.000', violation_code: '06C', violation: 'Food not protected.' }));
+ const sb = await h.db.prepare("SELECT slug, description FROM review_entities WHERE name = 'Starbucks' ORDER BY slug").all();
+ assert.deepStrictEqual(sb.map((e) => e.description), ['1 Broadway, Manhattan, NY', '38 Park Row, Manhattan, NY']);
+ const parkRow = (await req(h, 'GET', '/e/starbucks-38-park-row-manhattan-ny')).text;
+ assert.ok(parkRow.includes('Flies.') && parkRow.includes('Food not protected.') && !parkRow.includes('Surface.'), 'each keeps its own inspections');
+
// Search carries the facts.
// Search carries the facts.
const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted');
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) => /CFPB consumer complaints: 34100/.test(d.payload.body || '')));
@@ -76,7 +85,7 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
@@ -76,7 +85,7 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
// The source removes the clean inspection's row: the page goes back to the older inspection.
// 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' } });
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.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));
+ assert.ok(!/rv-grade-a/.test((await req(h, 'GET', '/e/the-coffee-club-9526-4-avenue-brooklyn-ny')).text));
// /k/organization and /k/restaurant list them; the home offers both kinds and the new ones.
// /k/organization and /k/restaurant list them; the home offers both kinds and the new ones.
// Lists order by members' reviews, then by the records, and say what the record is.
// Lists order by members' reviews, then by the records, and say what the record is.