openvibers/openvibe.reviews Commits

Kind lists order by members' reviews, then official records (most CFPB complaints, latest NYC inspection), and say the record in a few words; company names read as companies write them (JPMorgan, TransUnion, SLM) (#80)

OP OpenVibers committed 27 days ago · 8158bdad1e · 3 files, +57 −21

Changes

ddiff --git a/server/render/views.js b/server/render/views.js
iindex 54e1b96..be76783 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -260,14 +260,21 @@ ${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>`;
}
--/** /k/:kind — everything of one kind, the most reviewed by members first. */
++/** A list entry's official record, in a few words: its CFPB complaints, 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.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 '';
++}
++
++/** /k/:kind — everything of one kind, the most reviewed by members first, then by their official records. */
function kindPage({ kind, page, total, entities }) {
const pages = Math.max(1, Math.ceil(total / 60));
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: KIND_PLURAL[kind] }])}
<article class="rv-article"><h1>${KIND_PLURAL[kind]}</h1>
<p><a class="rv-button" href="/review?kind=${kind}">Review ${LOCAL.has(kind) ? 'one near you' : `a${/^[aeiou]/i.test(KIND_LABEL[kind]) ? 'n' : ''} ${KIND_LABEL[kind].toLowerCase()}`}</a></p>
${entities.length ? html`<ul class="rv-kind-list">${entities.map((x) => html`<li><a href="${epath(x)}"><b>${x.name}</b></a>${x.description && LOCAL.has(x.kind) ? html` <span class="rv-muted">${x.description}</span>` : ''}
--<br>${x.member_count ? html`${stars(x.member_avg)} <span class="rv-muted">${String(x.member_avg)} from ${n(x.member_count)} review${x.member_count === 1 ? '' : 's'}</span>` : html`<span class="rv-muted">No member reviews yet</span>`}</li>`)}</ul>`
++<br>${x.member_count ? html`${stars(x.member_avg)} <span class="rv-muted">${String(x.member_avg)} from ${n(x.member_count)} review${x.member_count === 1 ? '' : 's'}</span>` : html`<span class="rv-muted">No member reviews yet</span>`}${recordLine(x)}</li>`)}</ul>`
: html`<p class="rv-muted">None yet. The first review adds one.</p>`}
${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a href="/k/${kind}?page=${String(page - 1)}">Newer</a> ` : ''}Page ${String(page)} of ${String(pages)}${page < pages ? html` <a href="/k/${kind}?page=${String(page + 1)}">More</a>` : ''}</nav>` : ''}
</article>`;
ddiff --git a/server/reviews/service.js b/server/reviews/service.js
iindex 73194fa..5e255a0 100644
---- a/server/reviews/service.js
++++ b/server/reviews/service.js
@@@ -56,6 +56,28 @@ const RATING_TEXT_RE = new RegExp([
const toIso = (ms) => (ms == null ? null : new Date(ms).toISOString());
++// Official records' names (CFPB lists companies in capitals): readable, with companies' own spellings.
++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|NA|N\.A\.|USA|US|U\.S\.|NY|NYC|BBQ|II|III|IV|FSB|PLC|AG|SA|BMW|HSBC|TD|USAA|PNC|BBVA|NFCU|MBA|IHOP|KFC|PLLC|PC|CO-OP|ATM|ACE|AAA|CFPB)$/;
++// Names a company spells its own way.
++const OWN_SPELLING = { JPMORGAN: 'JPMorgan', TRANSUNION: 'TransUnion', PAYPAL: 'PayPal', EBAY: 'eBay', SOFI: 'SoFi', MONEYGRAM: 'MoneyGram', LENDINGCLUB: 'LendingClub',
++ MCDONALDS: 'McDonald\'s', "MCDONALD'S": "McDonald's", IPHONE: 'iPhone', LOANDEPOT: 'loanDepot', NAVIENT: 'Navient', DITECH: 'Ditech', CASHAPP: 'Cash App', CREDITONE: 'Credit One' };
++/** "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) => {
++ const bare = w.replace(/[,.]$/, '');
++ if (OWN_SPELLING[bare]) return OWN_SPELLING[bare] + w.slice(bare.length);
++ if (KEEP_UPPER.test(bare)) return w;
++ // A short word without vowels is an abbreviation (SLM, PNC, TD Bank's TD).
++ if (bare.length <= 4 && !/[AEIOUY]/.test(bare) && /^[A-Z&]+$/.test(bare)) 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 createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console, indexnow = null }) {
const { revisions, reviews, redirects, discussions, sequencer } = stores;
const access = createAccess(config);
@@@ -98,8 +120,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
FROM review_member_reviews r JOIN review_entities e ON e.id = r.entity_id
WHERE r.status = 'published' AND e.state = 'active' ORDER BY r.created_at DESC LIMIT ?`),
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
-- FROM review_entities e WHERE e.state = 'active' AND e.kind = ? ORDER BY member_count DESC, e.updated_at DESC LIMIT ? OFFSET ?`),
++ (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.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 ?`),
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 > ?'),
@@@ -922,19 +949,6 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
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') {
@@@ -1519,7 +1533,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const rows = await q.entitiesOfKind.all(kind, limit, offset);
return {
total: Number((await q.countOfKind.get(kind)).n),
-- entities: rows.map((x) => ({ ...x, member_count: Number(x.member_count) || 0, member_avg: x.member_avg == null ? null : Math.round(Number(x.member_avg) * 10) / 10 })),
++ entities: rows.map((x) => ({ ...x, member_count: Number(x.member_count) || 0, member_avg: x.member_avg == null ? null : Math.round(Number(x.member_avg) * 10) / 10,
++ complaints: x.complaints == null ? null : Number(x.complaints) })),
};
},
async kindCounts() { return Object.fromEntries((await q.kindCounts.all()).map((x) => [x.kind, Number(x.n)])); },
@@@ -2070,4 +2085,4 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return svc;
}
--module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE };
++module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE, recordName };
ddiff --git a/test/official-records.test.js b/test/official-records.test.js
iindex 12a755d..f10a922 100644
---- a/test/official-records.test.js
++++ b/test/official-records.test.js
@@@ -15,6 +15,16 @@ 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 } });
++t('company names read as the companies write them', () => {
++ const { recordName } = require('../server/reviews/service');
++ assert.strictEqual(recordName('JPMORGAN CHASE & CO.'), 'JPMorgan Chase & Co.');
++ assert.strictEqual(recordName('SLM CORPORATION'), 'SLM Corporation');
++ assert.strictEqual(recordName('TRANSUNION INTERMEDIATE HOLDINGS, INC.'), 'TransUnion Intermediate Holdings, Inc.');
++ assert.strictEqual(recordName('BANK OF AMERICA, NATIONAL ASSOCIATION'), 'Bank of America, National Association');
++ assert.strictEqual(recordName("JOE'S PIZZA"), "Joe's Pizza");
++ assert.strictEqual(recordName('Experian Information Solutions Inc.'), 'Experian Information Solutions Inc.', 'mixed case stays as written');
++});
++
t('CFPB counts and NYC inspections land on entities, shown as official records, not signals', async () => {
const h = await boot();
try {
@@@ -69,8 +79,12 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
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/);
++ // Lists order by members' reviews, then by the records, and say what the record is.
++ await importItem(h, cfpb('EQUIFAX, INC.', 1991097));
++ const orgs = (await req(h, 'GET', '/k/organization')).text;
++ assert.ok(orgs.indexOf('Equifax, Inc.') < orgs.indexOf('Capital One Financial Corporation'), 'more complaints first when nobody reviewed either');
++ assert.match(orgs, /Capital One Financial Corporation[\s\S]*?34,100 CFPB complaints/);
++ assert.match((await req(h, 'GET', '/k/restaurant')).text, /The Coffee Club[\s\S]*?inspected <time[^>]*>[^<]*<\/time>/);
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');