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)
Changes
@@@ -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>`;@@@ -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.nowFROM review_member_reviews r JOIN review_entities e ON e.id = r.entity_idWHERE 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.nowconst 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.nowconst 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.nowreturn svc;}--module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE };++module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE, recordName };@@@ -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');
@@ -260,14 +260,21 @@ ${actor && actor.subject ? html`<form method="post" action="/review" class="rv-f
@@ -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>`;
<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 }) {
function kindPage({ kind, page, total, entities }) {
const pages = Math.max(1, Math.ceil(total / 60));
const pages = Math.max(1, Math.ceil(total / 60));
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: KIND_PLURAL[kind] }])}
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: KIND_PLURAL[kind] }])}
<article class="rv-article"><h1>${KIND_PLURAL[kind]}</h1>
<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>
<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>` : ''}
${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>`}
: 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>` : ''}
${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>`;
</article>`;
@@ -56,6 +56,28 @@ const RATING_TEXT_RE = new RegExp([
@@ -56,6 +56,28 @@ const RATING_TEXT_RE = new RegExp([
const toIso = (ms) => (ms == null ? null : new Date(ms).toISOString());
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 }) {
function createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console, indexnow = null }) {
const { revisions, reviews, redirects, discussions, sequencer } = stores;
const { revisions, reviews, redirects, discussions, sequencer } = stores;
const access = createAccess(config);
const access = createAccess(config);
@@ -98,8 +120,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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
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 ?`),
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,
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 = ?"),
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"),
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 > ?'),
@@ -922,19 +949,6 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -922,19 +949,6 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const RECORD_KINDS = new Set(['complaint_tally', 'inspection_violation']);
const RECORD_KINDS = new Set(['complaint_tally', 'inspection_violation']);
const RECORD_MIN_COMPLAINTS = 500;
const RECORD_MIN_COMPLAINTS = 500;
const BOROUGHS = { manhattan: 'Manhattan', brooklyn: 'Brooklyn', queens: 'Queens', bronx: 'Bronx', 'staten island': 'Staten Island' };
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) {
function recordTarget(item) {
const f = item.fields || {};
const f = item.fields || {};
if (item.kind === 'complaint_tally') {
if (item.kind === 'complaint_tally') {
@@ -1519,7 +1533,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1519,7 +1533,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const rows = await q.entitiesOfKind.all(kind, limit, offset);
const rows = await q.entitiesOfKind.all(kind, limit, offset);
return {
return {
total: Number((await q.countOfKind.get(kind)).n),
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)])); },
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
@@ -2070,4 +2085,4 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return svc;
return svc;
}
}
-module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE };
+module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE, recordName };
@@ -15,6 +15,16 @@ const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: '
@@ -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',
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 } });
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 () => {
t('CFPB counts and NYC inspections land on entities, shown as official records, not signals', async () => {
const h = await boot();
const h = await boot();
try {
try {
@@ -69,8 +79,12 @@ t('CFPB counts and NYC inspections land on entities, shown as official records,
@@ -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));
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.
// /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;
const home = (await req(h, 'GET', '/')).text;
for (const k of ['organization', 'community', 'platform']) assert.ok(home.includes(`href="/k/${k}"`), k);
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');
assert.strictEqual((await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'community', name: 'r/selfhosted' } })).status, 303, 'a member can review a community');