openvibers/openvibe.reviews Commits
Ratings from Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps and Tripadvisor beside their links: a member brings what that site's page shows (/save, filled in by the Companion or typed), and the page shows it with the date, how many members saw it and a link back, never mixed into the members' or the sources' numbers; one per member, site and thing, the member or an editor takes it down; Search gets a line; exported and erased with the account (#78)
Changes
nnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++-- Ratings other review sites show, as members saw them (owner, 2026-10-17: Reviews should combine "glassdoor reviews of++-- employers, yelp, … BBB"). A member on a Glassdoor, Indeed, Yelp, BBB, Trustpilot, Google Maps or Tripadvisor page++-- saves the rating the page states (the OpenVibe Companion reads it from the page they have open, or they type it): the++-- site, the page, the value on its scale and how many ratings it is from, who saw it and when. One row per member, site++-- and entity (the latest look); never mixed into the sources' aggregate or the members' own reviews.++CREATE TABLE review_seen_ratings (++ id text COLLATE "C" PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),++ site text COLLATE "C" NOT NULL CHECK (site IN ('glassdoor','indeed','yelp','bbb','trustpilot','google','tripadvisor')),++ page_url text COLLATE "C" NOT NULL CHECK (length(page_url) <= 1000),++ rating_value numeric NOT NULL CHECK (rating_value >= 0),++ rating_best numeric NOT NULL CHECK (rating_best > 0 AND rating_value <= rating_best),++ rating_count bigint CHECK (rating_count IS NULL OR rating_count >= 0),++ seen_by text COLLATE "C" NOT NULL,++ seen_at bigint NOT NULL,++ via text COLLATE "C" NOT NULL DEFAULT 'form' CHECK (via IN ('companion','form')),++ status text COLLATE "C" NOT NULL DEFAULT 'shown' CHECK (status IN ('shown','removed')),++ removed_by text COLLATE "C",++ removed_reason text COLLATE "C",++ UNIQUE (entity_id, site, seen_by)++);++CREATE INDEX review_seen_ratings_entity ON review_seen_ratings (entity_id, site, seen_at DESC) WHERE status = 'shown';++CREATE INDEX review_seen_ratings_member ON review_seen_ratings (seen_by);@@@ -130,3 +130,7 @@ code { overflow-wrap: anywhere; }.rv-recent { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 10px; }.rv-recent a { display: block; padding: 12px; border: 1px solid var(--rv-line); border-radius: 10px; text-decoration: none; color: inherit; height: 100%; box-sizing: border-box; }.rv-pager { margin-top: 12px; }++.rv-seen { display: inline; }++.rv-seen b { font-size: 1.05em; }++.rv-row { display: flex; flex-wrap: wrap; gap: 8px 16px; }++.rv-row label { flex: 0 1 auto; }@@@ -78,6 +78,8 @@ const BUDGETS = {'reviews.review.write': { minute: 6, hour: 40 },// A member adding something to review (the service caps it at 20 a day).'reviews.entity.suggest': { minute: 4, hour: 25 },++ // A member saving the rating another site shows (the service caps it at 60 a day).++ 'reviews.rating.save': { minute: 10, hour: 60 },};/**@@@ -204,6 +204,42 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiawait svc.removeMemberReview(req.params.id, req.actor, (req.body || {}).reason);res.redirect(303, `${svc.entityPath(e)}#member-reviews`);}));++ // /save: the rating another site's page shows, brought by a member (the Companion fills the query in).++ const saveValues = (src) => {++ const v = {};++ for (const k of ['site', 'url', 'name', 'kind', 'place', 'value', 'best', 'count', 'via', 'entity']) if (src[k] != null) v[k] = String(src[k]).slice(0, k === 'url' ? 1000 : 200);++ if (src.rating != null && v.value == null) v.value = String(src.rating).slice(0, 20);++ if (!v.site && v.url) v.site = svc.seenSiteOf(v.url) || '';++ return v;++ };++ const savePage = async (req, res, status, values, error = null) => {++ const entity = values.entity ? await svc.findEntity(values.entity).catch(() => null) : null;++ send(req, res, status, views.savePage({ values: { ...values, next: req.originalUrl }, entity, sites: svc.SEEN_SITES, kinds: svc.MEMBER_KINDS, local: [...svc.LOCAL_KINDS], actor: req.actor, error }), {++ title: 'Add a rating from another site', robots: 'noindex, nofollow',++ });++ };++ router.get('/save', wrap(async (req, res) => {++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to add a rating from another site.');++ await savePage(req, res, 200, saveValues(req.query));++ }));++ router.post('/save', F('reviews.rating.save'), form, wrap(async (req, res) => {++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to add a rating from another site.');++ const v = saveValues(req.body || {});++ try {++ const target = v.entity ? v.entity : { kind: v.kind, name: v.name, place: v.place };++ const { entity } = await svc.saveSeenRating(target, { url: v.url, value: v.value, best: v.best, count: v.count, via: v.via }, req.actor);++ res.redirect(303, `${svc.entityPath(entity)}?saved=1#elsewhere`);++ } catch (err) {++ if (!err.status || err.status >= 500) throw err;++ await savePage(req, res, err.status, v, err.message);++ }++ }));++ router.post('/save/:id/remove', F('reviews.rating.save'), form, wrap(async (req, res) => {++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account.');++ const out = await svc.removeSeenRating(req.params.id, req.actor, (req.body || {}).reason);++ const e = await svc.findEntity(out.entity_id).catch(() => null);++ res.redirect(303, e ? `${svc.entityPath(e)}#elsewhere` : '/');++ }));// /k/employer, /k/restaurant…: everything of one kind, the most reviewed by members first.router.get('/k/:kind', wrap(async (req, res) => {const kind = String(req.params.kind);@@@ -246,8 +282,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiconst discussion = await discussionFor(e, req.actor);const members = await svc.memberReviews(e, req.actor);const elsewhere = views.elsewhereLinks(e);++ const seen = await svc.seenRatings(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, reviewError, reviewValues }), {++ send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, 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.`,@@@ -182,11 +182,45 @@ ${rels.length ? html`<label>How you know it <select name="relation"><option valu</section>`;}--function elsewhereSection(links, entity) {-- if (!links.length) return '';++function elsewhereSection(links, entity, { seen = {}, actor = null, editor = false } = {}) {++ if (!links.length && !Object.keys(seen).length) return '';++ const known = new Set(links.map((l) => l.id));++ const extra = Object.values(seen).filter((r) => !known.has(r.site)).map((r) => ({ id: r.site, name: r.name, url: r.page_url, best: '' }));++ const anySeen = Object.keys(seen).length > 0;return html`<section id="elsewhere" aria-labelledby="ew-h"><h2 id="ew-h">${entity.name} on other review sites</h2>--<p class="rv-muted">Each opens that site's own page for it, in a new tab.</p>--<ul class="rv-elsewhere">${links.map((l) => html`<li><a href="${l.url}" target="_blank" rel="nofollow noopener noreferrer">${l.name}</a> <span class="rv-muted">${l.best}</span></li>`)}</ul></section>`;++<p class="rv-muted">Each opens that site's own page for it, in a new tab.${anySeen ? ' A rating beside a site is what its page showed when an OpenVibe member looked; it is theirs, not ours, and it is never mixed into the numbers above.' : ''}</p>++<ul class="rv-elsewhere">${[...links, ...extra].map((l) => { const r = seen[l.id]; return html`<li><a href="${r ? r.page_url : l.url}" target="_blank" rel="nofollow noopener noreferrer">${l.name}</a> ${r ? seenLine(r, { entity, actor, editor }) : html`<span class="rv-muted">${l.best}</span>`}</li>`; })}</ul>++<p class="rv-muted">Saw its rating on Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor? <a href="/save?entity=${entity.slug}">Add what that page shows</a>${actor && actor.subject ? '' : ' (with an OpenVibe account)'}, or let the <a href="https://openvibe.coupons/companion">OpenVibe Companion</a> read it for you.</p></section>`;++}++++/** One site's rating as a member saw it, with when, how many members saw it, and a way to take it down. */++function seenLine(r, { entity, actor, editor }) {++ const mine = actor && actor.subject && r.seen_by === actor.subject;++ return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${t(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;++}++++/** /save: a member checks the rating another site's page shows (filled in by the Companion, or typed) and saves it. */++function savePage({ values = {}, entity = null, sites, kinds, local, actor, error = null }) {++ const v = values;++ const site = sites[v.site] ? v.site : '';++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: 'Add a rating from another site' }])}++<article class="rv-article"><h1>Add a rating from another site</h1>++<p>Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps and Tripadvisor each show a rating. Bring what one of their pages shows, and it appears beside that site on the page here, with the date and a link back. Check the numbers against the page first.</p>++${error ? notice('error', error) : ''}++${actor && actor.subject ? html`<form method="post" action="/save" class="rv-form">++${entity ? html`<input type="hidden" name="entity" value="${entity.slug}"><p>For <a href="${epath(entity)}"><b>${entity.name}</b></a>${entity.description && LOCAL.has(entity.kind) ? html` <span class="rv-muted">${entity.description}</span>` : ''}</p>`++ : html`<label>What is it? <select name="kind">${kinds.map((x) => html`<option value="${x}"${x === (v.kind || (site ? sites[site].kind : 'business')) ? raw(' selected') : ''}>${KIND_LABEL[x]}</option>`)}</select></label>++<label>Its name <input type="text" name="name" maxlength="200" required value="${v.name || ''}"></label>++<label>Where it is <input type="text" name="place" maxlength="120" value="${v.place || ''}" placeholder="City, and state or country"> <span class="rv-muted">Needed for ${local.map((x) => KIND_PLURAL[x].toLowerCase()).join(', ')}.</span></label>`}++<label>The page <input type="url" name="url" maxlength="1000" required value="${v.url || ''}" placeholder="https://www.glassdoor.com/Reviews/…"> <span class="rv-muted">Its address on ${Object.values(sites).map((x) => x.name).join(', ')}.</span></label>++<div class="rv-row"><label>Rating <input type="text" inputmode="decimal" name="value" required value="${v.value || ''}" size="5" placeholder="3.9"></label>++<label>Out of <input type="text" inputmode="decimal" name="best" required value="${v.best || '5'}" size="4"></label>++<label>From how many ratings <input type="text" inputmode="numeric" name="count" value="${v.count || ''}" size="9" placeholder="1,234"></label></div>++<input type="hidden" name="via" value="${v.via === 'companion' ? 'companion' : 'form'}">++<button type="submit">Save</button></form>++<p class="rv-muted">Saved under your OpenVibe account; a later look of yours replaces it. Reviews never reads those sites itself.</p>`++ : html`<p><a class="rv-button" href="/auth/login?next=${encodeURIComponent(v.next || '/save')}">Sign in with OpenVibe to add it</a></p>`}++</article>`;}/** /review: name what to review; found, or added by the member. */@@@ -377,7 +411,7 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>${form}</section>`;}--function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], reviewError = null, reviewValues = null }) {++function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, reviewError = null, reviewValues = null }) {const en = p.entity;const limits = [];for (const s of p.sources) {@@@ -405,7 +439,7 @@ ${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name |${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p></section>` : ''}--${elsewhereSection(elsewhere, en)}++${elsewhereSection(elsewhere, en, { seen, actor, editor: p.editor })}${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}${p.links.length ? html`<ul>${p.links.map((l) => html`<li>${l.type.replace(/_/g, ' ')}${l.direction === 'in' ? ' (from)' : ''}: ${l.other ? html`<a href="${epath(l.other)}">${l.other.name}</a>` : html`${l.ref ? `${l.ref.service} ${l.ref.type} ${l.ref.label || l.ref.id}` : ''}`}</li>`)}</ul>` : ''}</section>` : ''}@@@ -595,5 +629,5 @@ module.exports = {everywherePage,home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,-- reviewStartPage, kindPage, elsewhereLinks, KIND_PLURAL,++ reviewStartPage, kindPage, elsewhereLinks, savePage, KIND_PLURAL,};@@@ -2,8 +2,9 @@/*** Account export and deletion → Reviews (ADR-033; openvibe-sdk/account-data). What Reviews holds about a person:*-- * their own, deleted the reviews they wrote here (review_member_reviews); the pages they were on are re-sent to-- * Search without them, in the same transaction++ * their own, deleted the reviews they wrote here (review_member_reviews) and the ratings of other sites they++ * saved (review_seen_ratings); the pages they were on are re-sent to Search without them,++ * in the same transaction* their additions, kept what they added to review (review_entities.created_by) and the corrections they sent* (review_corrections.submitted_by) stay, made authorless ('deleted'): other people's* reviews and the editors' answers hang under them@@@ -20,6 +21,9 @@ async function extraExport(db, subject) {const reviews = await db.many(`SELECT id, entity_id, rating, title, body, relation, status, created_at, updated_at FROM review_member_reviewsWHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);if (reviews.length) files.push({ name: 'reviews.json', content: reviews });++ const seen = await db.many(`SELECT id, entity_id, site, page_url, rating_value::float8 AS rating_value, rating_best::float8 AS rating_best, rating_count, seen_at, via, status++ FROM review_seen_ratings WHERE seen_by = $1 ORDER BY seen_at DESC LIMIT 5000`, [subject]);++ if (seen.length) files.push({ name: 'ratings-from-other-sites.json', content: seen });const added = await db.many('SELECT id, slug, name, kind, description, created_at FROM review_entities WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000', [subject]);if (added.length) files.push({ name: 'added-to-review.json', content: added });const corrections = await db.many(`SELECT id, entity_id, target_type, target_id, body, evidence_url, status, resolution_note, created_at FROM review_corrections@@@ -31,15 +35,17 @@ async function extraExport(db, subject) {/** The account-data handle over Reviews' database; `svc.syncIndex` re-sends a page (it joins the erase transaction). */function create({ db, svc, log = console } = {}) {async function extraErase(t, subjects, counts) {-- const pages = (await t.many('SELECT DISTINCT entity_id FROM review_member_reviews WHERE author = ANY($1::text[])', [subjects])).map((r) => r.entity_id);++ const pages = (await t.many(`SELECT entity_id FROM review_member_reviews WHERE author = ANY($1::text[])++ UNION SELECT entity_id FROM review_seen_ratings WHERE seen_by = ANY($1::text[])`, [subjects])).map((r) => r.entity_id);counts.add(counts.erased, 'review_member_reviews', await t.exec('DELETE FROM review_member_reviews WHERE author = ANY($1::text[])', [subjects]));++ counts.add(counts.erased, 'review_seen_ratings', await t.exec('DELETE FROM review_seen_ratings WHERE seen_by = ANY($1::text[])', [subjects]));counts.add(counts.retained, 'tombstones', await t.exec("UPDATE review_entities SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [subjects]));counts.add(counts.retained, 'tombstones', await t.exec("UPDATE review_corrections SET submitted_by = 'deleted' WHERE submitted_by = ANY($1::text[])", [subjects]));for (const id of pages) await svc.syncIndex(id);}return createAccountData({db, service: 'reviews', tables: TABLES, extraExport, extraErase, log,-- note: 'Your reviews are deleted. What you added to review and the corrections you sent stay without your name.',++ note: 'Your reviews and the ratings you saved are deleted. What you added to review and the corrections you sent stay without your name.',});}@@@ -104,6 +104,17 @@ 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 > ?'),++ // 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++ FROM review_seen_ratings WHERE entity_id = ? AND status = 'shown' ORDER BY seen_at DESC LIMIT 200`),++ seenRatingBy: db.prepare('SELECT * FROM review_seen_ratings WHERE entity_id = ? AND site = ? AND seen_by = ?'),++ seenRating: db.prepare('SELECT * FROM review_seen_ratings WHERE id = ?'),++ insertSeenRating: db.prepare(`INSERT INTO review_seen_ratings (id, entity_id, site, page_url, rating_value, rating_best, rating_count, seen_by, seen_at, via)++ VALUES (@id, @entity_id, @site, @page_url, @rating_value, @rating_best, @rating_count, @seen_by, @now, @via)`),++ updateSeenRating: db.prepare(`UPDATE review_seen_ratings SET page_url = @page_url, rating_value = @rating_value, rating_best = @rating_best, rating_count = @rating_count,++ seen_at = @now, via = @via, status = 'shown', removed_by = NULL, removed_reason = NULL WHERE id = @id`),++ removeSeenRating: db.prepare("UPDATE review_seen_ratings SET status = 'removed', removed_by = ?, removed_reason = ? WHERE id = ? AND status = 'shown'"),++ seenRatingsSince: db.prepare('SELECT COUNT(*) AS n FROM review_seen_ratings WHERE seen_by = ? AND seen_at > ?'),sameNamedOfKind: db.prepare(`SELECT e.* FROM review_entities e JOIN review_entity_aliases a ON a.entity_id = e.idWHERE a.type = 'name' AND a.norm = ? AND a.removed_at IS NULL AND e.kind = ? AND e.state = 'active' ORDER BY e.created_at`),link: db.prepare('SELECT * FROM review_entity_links WHERE id = ?'),@@@ -532,6 +543,26 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now}return { count, average: count ? Math.round((sum / count) * 10) / 10 : null, distribution, words };}++ // Sites whose pages state a rating a member may bring here: the hosts a page must be on, and the kind it usually is.++ const SEEN_SITES = {++ glassdoor: { name: 'Glassdoor', host: /(^|\.)glassdoor\.[a-z.]{2,12}$/, kind: 'employer' },++ indeed: { name: 'Indeed', host: /(^|\.)indeed\.[a-z.]{2,12}$/, kind: 'employer' },++ yelp: { name: 'Yelp', host: /(^|\.)yelp\.[a-z.]{2,12}$/, kind: 'restaurant' },++ bbb: { name: 'Better Business Bureau', host: /(^|\.)bbb\.org$/, kind: 'business' },++ trustpilot: { name: 'Trustpilot', host: /(^|\.)trustpilot\.com$/, kind: 'organization' },++ google: { name: 'Google Maps', host: /(^|\.)(google\.[a-z.]{2,12}|goo\.gl)$/, kind: 'business' },++ tripadvisor: { name: 'Tripadvisor', host: /(^|\.)tripadvisor\.[a-z.]{2,12}$/, kind: 'restaurant' },++ };++ const SEEN_PER_DAY = 60;++ /** The site a page belongs to (by its host), or null. */++ function seenSiteOf(url) {++ let u;++ try { u = new URL(String(url || '')); } catch { return null; }++ if (u.protocol !== 'https:') return null;++ const h = u.hostname.toLowerCase();++ return Object.keys(SEEN_SITES).find((k) => SEEN_SITES[k].host.test(h)) || null;++ }++function memberReviewView(r, actor) {return {id: r.id, rating: Number(r.rating), title: r.title, body: r.body, relation: r.relation || null,@@@ -577,6 +608,20 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now* or a page Search already had that went away) — all on the caller's transaction handle. Returns* the stamped document, or null when nothing changed.*/++ /** Per site, the latest rating a member saw for an entity (merged ones included), with how many members saw it. */++ async function seenBySite(entityId) {++ const rows = [];++ for (const id of await closure(entityId)) rows.push(...await q.seenRatingsOf.all(id));++ const bySite = new Map();++ for (const r of rows.sort((a, b) => Number(b.seen_at) - Number(a.seen_at))) {++ const cur = bySite.get(r.site);++ if (!cur) bySite.set(r.site, { site: r.site, name: SEEN_SITES[r.site].name, id: r.id, page_url: r.page_url, value: Number(r.rating_value), best: Number(r.rating_best),++ count: r.rating_count == null ? null : Number(r.rating_count), seen_at: toIso(Number(r.seen_at)), via: r.via, members: 1, seen_by: r.seen_by });++ else cur.members += 1;++ }++ return Object.fromEntries(bySite);++ }++async function syncEntity(entityId) {const e = await q.entity.get(entityId);if (!e) return null;@@@ -589,6 +634,7 @@ 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) 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,state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',@@@ -1277,6 +1323,62 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now});},++ // Ratings other sites show, as members saw them -------------------------------------------++ SEEN_SITES, seenSiteOf,++++ /** 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); },++++ /**++ * A member brings the rating a page of another site states: { site?, url, value, best, count?, via } for an++ * entity ref (or { kind, name, place?, website? } to find or add one, as /review does) → { entity, seen }. The++ * page must be on that site; one row per member, site and entity (a later look replaces theirs).++ */++ async saveSeenRating(target, { url, value, best = 5, count = null, via = 'form' } = {}, actor) {++ const who = requirePerson(actor, 'Saving a rating');++ const site = seenSiteOf(url);++ if (!site) fail(422, 'seen.invalid_page', `The page must be an https address on ${Object.values(SEEN_SITES).map((x) => x.name).join(', ')}`);++ const v = Number(value);++ const b = Number(best);++ if (!Number.isFinite(b) || b <= 0 || b > 100) fail(422, 'seen.invalid_scale', 'The scale is the best rating the site gives, like 5');++ if (!Number.isFinite(v) || v < 0 || v > b) fail(422, 'seen.invalid_value', `The rating is a number from 0 to ${b}`);++ const n = count == null || count === '' ? null : Number(String(count).replace(/[,\s]/g, ''));++ if (n != null && (!Number.isInteger(n) || n < 0 || n > 1e9)) fail(422, 'seen.invalid_count', 'How many ratings it is from is a whole number');++ const how = via === 'companion' ? 'companion' : 'form';++ const entity = typeof target === 'string' ? await entityOrFail(target) : (await svc.memberEntity({ ...target, kind: target.kind || SEEN_SITES[site].kind, url: target.website || null }, actor)).entity;++ return await tx(async () => {++ const e = await q.entity.get(await canonicalOf(entity.id));++ if (!e || e.state !== 'active') fail(409, 'entity.not_active', 'That is not reviewed here any more');++ const t = now();++ const prior = await q.seenRatingBy.get(e.id, site, who);++ if (!prior && (await q.seenRatingsSince.get(who, t - DAY_MS)).n >= SEEN_PER_DAY) fail(429, 'seen.daily_limit', `You can save ${SEEN_PER_DAY} ratings a day`);++ const row = { page_url: String(url).slice(0, 1000), rating_value: v, rating_best: b, rating_count: n, now: t, via: how };++ let id;++ if (prior) { id = prior.id; await q.updateSeenRating.run({ ...row, id }); }++ else { id = `srt_${ulid(t)}`; await q.insertSeenRating.run({ ...row, id, entity_id: e.id, site, seen_by: who }); }++ await touchEntity(e.id);++ await syncEntity(e.id);++ await audit(actor, 'seen_rating.saved', e.id, id, { site, value: v, best: b, count: n, via: how });++ return { entity: e, seen: { id, site, value: v, best: b, count: n } };++ });++ },++++ /** Take a saved rating down: the member who saved it, or an editor (with the reason, kept in the audit log). */++ async removeSeenRating(id, actor, reason = null) {++ const who = requirePerson(actor, 'Removing a rating');++ return await tx(async () => {++ const r = await q.seenRating.get(String(id || ''));++ if (!r || r.status !== 'shown') fail(404, 'seen.not_found', 'No such rating');++ const own = r.seen_by === who;++ if (!own && !access.isEditor(actor)) fail(403, 'reviews.editor_required', 'Only the member who saved it or an editor can remove it');++ const why = own ? 'removed by the member who saved it' : String(reason || '').trim().slice(0, 300) || 'removed by an editor';++ await q.removeSeenRating.run(who, why, r.id);++ await syncEntity(await canonicalOf(r.entity_id));++ await audit(actor, 'seen_rating.removed', r.entity_id, r.id, { reason: why });++ return { removed: true, entity_id: r.entity_id };++ });++ },++async recentMemberReviews(limit = 8) {return (await q.recentMemberReviews.all(limit)).map((r) => ({ ...memberReviewView(r, null), entity: { slug: r.entity_slug, name: r.entity_name, kind: r.entity_kind, place: r.entity_place } }));},@@@ -24,6 +24,7 @@ t('export, then deletion: reviews gone and re-indexed, additions and correctionsawait req(h, 'POST', '/review', { cookie: anaC, form: { kind: 'employer', name: 'Acme Corp' } });await req(h, 'POST', '/e/acme-corp/reviews', { cookie: anaC, form: { rating: '4', title: 'Solid place to work', body: BODY } });await req(h, 'POST', '/e/acme-corp/reviews', { cookie: benC, form: { rating: '2', title: 'Burned out', body: 'Constant overtime, and the on-call rota was anything but fair to the night team.' } });++ await req(h, 'POST', '/save', { cookie: anaC, form: { entity: 'acme-corp', url: 'https://www.glassdoor.com/Reviews/Acme-E1.htm', value: '3.9', best: '5', count: '120' } });const deliver = async (event, { secret = WEBHOOK_SECRET, headers = {} } = {}) => {const body = JSON.stringify({ event, seq: 1 });const r = await fetch(`${h.base}/internal/account-events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });@@@ -37,6 +38,7 @@ t('export, then deletion: reviews gone and re-indexed, additions and correctionsconst part = calls.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');const files = JSON.stringify(part.body);assert.ok(files.includes('reviews.json') && files.includes('Solid place to work') && files.includes('added-to-review.json'), files.slice(0, 300));++ assert.ok(files.includes('ratings-from-other-sites.json') && files.includes('Acme-E1.htm'), 'the rating she saved');assert.ok(!files.includes('Burned out') && !files.includes(ben), 'nothing of Ben\'s');// Deletion: her review gone, the page re-sent to Search without it; Acme stays, made authorless.@@@ -54,6 +56,8 @@ t('export, then deletion: reviews gone and re-indexed, additions and correctionsconst conf = calls.filter((c) => c.path === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');assert.strictEqual(conf.length, 1);assert.strictEqual(conf[0].body.erased.review_member_reviews, 1);++ assert.strictEqual(conf[0].body.erased.review_seen_ratings, 1);++ assert.ok(!/rv-seen/.test(page.text), 'the rating she saved is gone from the page');// A redelivery erases nothing twice.assert.strictEqual((await deliver(del)).json.outcome, 'unchanged');// Signed wrong or relayed from outside: refused.nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * Ratings other review sites show, as members saw them (owner, 2026-10-17: ".reviews doesn't combine stuff from++ * glassdoor…"): a member brings what a Glassdoor/Yelp/BBB… page states (the Companion fills /save in, or they type++ * it), checks it and saves it; the entity page shows it beside that site's link with the date and how many members++ * saw it, never inside the members' or the sources' numbers; one per member, site and entity; the member or an editor++ * takes it down; Search gets a line for it.++ */++const assert = require('assert');++const { boot, req, suite, userToken, editorToken, cookieFor, subject, outbox } = require('./helpers');++++const t = suite('seen-ratings');++const GD = 'https://www.glassdoor.com/Reviews/Acme-Corp-Reviews-E12345.htm';++++t('a member saves a Glassdoor rating; it shows beside Glassdoor; a later look replaces theirs; removal', async () => {++ const h = await boot();++ try {++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));++ const ben = cookieFor(userToken({ subject: subject(), username: 'ben' }));++++ // Signed out: asked to sign in, and the way back keeps the Companion's values.++ const out = await req(h, 'GET', `/save?site=glassdoor&url=${encodeURIComponent(GD)}&name=Acme%20Corp&rating=3.9&best=5&count=1234`);++ assert.strictEqual(out.status, 401);++ assert.match(out.text, /next=%2Fsave%3F[^"]*rating%3D3\.9/);++ assert.strictEqual((await req(h, 'POST', '/save', { form: { name: 'Acme Corp', url: GD, value: '3.9' } })).status, 401);++++ // The Companion's link prefills the confirm form, kind from the site (Glassdoor → employer).++ const form = await req(h, 'GET', `/save?url=${encodeURIComponent(GD)}&name=Acme%20Corp&rating=3.9&best=5&count=1%2C234&via=companion`, { cookie: ana });++ assert.strictEqual(form.status, 200);++ assert.match(form.text, /<option value="employer" selected>/);++ assert.match(form.text, /name="value" required value="3\.9"/);++ assert.match(form.text, /name="via" value="companion"/);++++ // Only a page on that site, with a rating on its scale.++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: 'https://evil.example/glassdoor.com', value: '3.9', best: '5' } })).status, 422);++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: 'http://www.glassdoor.com/x', value: '3.9', best: '5' } })).status, 422, 'https only');++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: GD, value: '7', best: '5' } })).status, 422);++++ const saved = await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: GD, value: '3.9', best: '5', count: '1,234', via: 'companion' } });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.strictEqual(saved.headers.get('location'), '/e/acme-corp?saved=1#elsewhere');++++ let page = await req(h, 'GET', '/e/acme-corp');++ assert.match(page.text, new RegExp(`<a href="${GD.replace(/[.?]/g, '\\$&')}"[^>]*>Glassdoor</a> <span class="rv-seen"><b>3\\.9</b> <span class="rv-muted">out of 5 from 1,234 ratings · seen`));++ assert.ok(!/Reviews from OpenVibe members \(1\)/.test(page.text), 'not counted as a member review');++++ // Ben adds a newer look at the same site through the entity's own link; the page shows the latest, from 2 members.++ assert.match((await req(h, 'GET', '/save?entity=acme-corp', { cookie: ben })).text, /For <a href="\/e\/acme-corp"><b>Acme Corp<\/b><\/a>/);++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ben, form: { entity: 'acme-corp', url: GD, value: '3.8', best: '5', count: '1300' } })).status, 303);++ page = await req(h, 'GET', '/e/acme-corp');++ assert.match(page.text, /<b>3\.8<\/b> <span class="rv-muted">out of 5 from 1,300 ratings · seen [\s\S]*?\(2 members\)/);++++ // Ana's later look replaces hers (still one row each).++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { entity: 'acme-corp', url: GD, value: '4.0', best: '5', count: '1310' } })).status, 303);++ assert.strictEqual(Number((await h.db.prepare('SELECT COUNT(*) AS n FROM review_seen_ratings').get()).n), 2);++ assert.match((await req(h, 'GET', '/e/acme-corp')).text, /<b>4<\/b> <span class="rv-muted">out of 5 from 1,310 ratings/);++++ // A Yelp page for a restaurant needs its place, like /review.++ const yelp = 'https://www.yelp.com/biz/joes-pizza-new-york';++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", url: yelp, value: '4.5', best: '5' } })).status, 422);++ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", place: 'New York, NY', url: yelp, value: '4.5', best: '5', count: '9800' } })).status, 303);++++ // Search gets a line for it.++ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted' && x.payload.title === 'Acme Corp');++ assert.ok(docs.some((d) => /Glassdoor: 4 out of 5 from 1310 ratings/.test(d.payload.body || '')), 'the Search document carries it');++++ // Ana removes hers: Ben's look stays. Ben cannot remove someone else's; an editor can, with a reason.++ const mine = await req(h, 'GET', '/e/acme-corp', { cookie: ana });++ assert.match(mine.text, /<b>4<\/b>[\s\S]*?Remove mine/, 'the latest look is hers, and she can take it down');++ const rows = await h.db.prepare('SELECT id, rating_value::float8 AS v FROM review_seen_ratings ORDER BY seen_at').all();++ const anaRow = rows.find((r) => Number(r.v) === 4);++ const benRow = rows.find((r) => Number(r.v) === 3.8);++ assert.strictEqual((await req(h, 'POST', `/save/${anaRow.id}/remove`, { cookie: ben })).status, 403);++ assert.strictEqual((await req(h, 'POST', `/save/${anaRow.id}/remove`, { cookie: ana })).status, 303);++ assert.match((await req(h, 'GET', '/e/acme-corp', { cookie: ben })).text, /<b>3\.8<\/b>[\s\S]*?Remove mine/);++ assert.strictEqual((await req(h, 'POST', `/save/${benRow.id}/remove`, { cookie: cookieFor(editorToken()), form: { reason: 'Wrong company' } })).status, 303);++ assert.ok(!/rv-seen/.test((await req(h, 'GET', '/e/acme-corp')).text), 'no rating left beside Glassdoor');++ const audit = await h.db.prepare("SELECT detail FROM review_audit WHERE action = 'seen_rating.removed' ORDER BY id DESC LIMIT 1").get();++ assert.match(audit.detail, /Wrong company/);++ } finally { await h.stop(); }++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+-- Ratings other review sites show, as members saw them (owner, 2026-10-17: Reviews should combine "glassdoor reviews of
+-- employers, yelp, … BBB"). A member on a Glassdoor, Indeed, Yelp, BBB, Trustpilot, Google Maps or Tripadvisor page
+-- saves the rating the page states (the OpenVibe Companion reads it from the page they have open, or they type it): the
+-- site, the page, the value on its scale and how many ratings it is from, who saw it and when. One row per member, site
+-- and entity (the latest look); never mixed into the sources' aggregate or the members' own reviews.
+CREATE TABLE review_seen_ratings (
+ id text COLLATE "C" PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
+ site text COLLATE "C" NOT NULL CHECK (site IN ('glassdoor','indeed','yelp','bbb','trustpilot','google','tripadvisor')),
+ page_url text COLLATE "C" NOT NULL CHECK (length(page_url) <= 1000),
+ rating_value numeric NOT NULL CHECK (rating_value >= 0),
+ rating_best numeric NOT NULL CHECK (rating_best > 0 AND rating_value <= rating_best),
+ rating_count bigint CHECK (rating_count IS NULL OR rating_count >= 0),
+ seen_by text COLLATE "C" NOT NULL,
+ seen_at bigint NOT NULL,
+ via text COLLATE "C" NOT NULL DEFAULT 'form' CHECK (via IN ('companion','form')),
+ status text COLLATE "C" NOT NULL DEFAULT 'shown' CHECK (status IN ('shown','removed')),
+ removed_by text COLLATE "C",
+ removed_reason text COLLATE "C",
+ UNIQUE (entity_id, site, seen_by)
+);
+CREATE INDEX review_seen_ratings_entity ON review_seen_ratings (entity_id, site, seen_at DESC) WHERE status = 'shown';
+CREATE INDEX review_seen_ratings_member ON review_seen_ratings (seen_by);
@@ -130,3 +130,7 @@ code { overflow-wrap: anywhere; }
@@ -130,3 +130,7 @@ code { overflow-wrap: anywhere; }
.rv-recent { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 10px; }
.rv-recent { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 10px; }
.rv-recent a { display: block; padding: 12px; border: 1px solid var(--rv-line); border-radius: 10px; text-decoration: none; color: inherit; height: 100%; box-sizing: border-box; }
.rv-recent a { display: block; padding: 12px; border: 1px solid var(--rv-line); border-radius: 10px; text-decoration: none; color: inherit; height: 100%; box-sizing: border-box; }
.rv-pager { margin-top: 12px; }
.rv-pager { margin-top: 12px; }
+.rv-seen { display: inline; }
+.rv-seen b { font-size: 1.05em; }
+.rv-row { display: flex; flex-wrap: wrap; gap: 8px 16px; }
+.rv-row label { flex: 0 1 auto; }
@@ -78,6 +78,8 @@ const BUDGETS = {
@@ -78,6 +78,8 @@ const BUDGETS = {
'reviews.review.write': { minute: 6, hour: 40 },
'reviews.review.write': { minute: 6, hour: 40 },
// A member adding something to review (the service caps it at 20 a day).
// A member adding something to review (the service caps it at 20 a day).
'reviews.entity.suggest': { minute: 4, hour: 25 },
'reviews.entity.suggest': { minute: 4, hour: 25 },
+ // A member saving the rating another site shows (the service caps it at 60 a day).
+ 'reviews.rating.save': { minute: 10, hour: 60 },
};
};
/**
/**
@@ -204,6 +204,42 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -204,6 +204,42 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
await svc.removeMemberReview(req.params.id, req.actor, (req.body || {}).reason);
await svc.removeMemberReview(req.params.id, req.actor, (req.body || {}).reason);
res.redirect(303, `${svc.entityPath(e)}#member-reviews`);
res.redirect(303, `${svc.entityPath(e)}#member-reviews`);
}));
}));
+ // /save: the rating another site's page shows, brought by a member (the Companion fills the query in).
+ const saveValues = (src) => {
+ const v = {};
+ for (const k of ['site', 'url', 'name', 'kind', 'place', 'value', 'best', 'count', 'via', 'entity']) if (src[k] != null) v[k] = String(src[k]).slice(0, k === 'url' ? 1000 : 200);
+ if (src.rating != null && v.value == null) v.value = String(src.rating).slice(0, 20);
+ if (!v.site && v.url) v.site = svc.seenSiteOf(v.url) || '';
+ return v;
+ };
+ const savePage = async (req, res, status, values, error = null) => {
+ const entity = values.entity ? await svc.findEntity(values.entity).catch(() => null) : null;
+ send(req, res, status, views.savePage({ values: { ...values, next: req.originalUrl }, entity, sites: svc.SEEN_SITES, kinds: svc.MEMBER_KINDS, local: [...svc.LOCAL_KINDS], actor: req.actor, error }), {
+ title: 'Add a rating from another site', robots: 'noindex, nofollow',
+ });
+ };
+ router.get('/save', wrap(async (req, res) => {
+ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to add a rating from another site.');
+ await savePage(req, res, 200, saveValues(req.query));
+ }));
+ router.post('/save', F('reviews.rating.save'), form, wrap(async (req, res) => {
+ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to add a rating from another site.');
+ const v = saveValues(req.body || {});
+ try {
+ const target = v.entity ? v.entity : { kind: v.kind, name: v.name, place: v.place };
+ const { entity } = await svc.saveSeenRating(target, { url: v.url, value: v.value, best: v.best, count: v.count, via: v.via }, req.actor);
+ res.redirect(303, `${svc.entityPath(entity)}?saved=1#elsewhere`);
+ } catch (err) {
+ if (!err.status || err.status >= 500) throw err;
+ await savePage(req, res, err.status, v, err.message);
+ }
+ }));
+ router.post('/save/:id/remove', F('reviews.rating.save'), form, wrap(async (req, res) => {
+ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account.');
+ const out = await svc.removeSeenRating(req.params.id, req.actor, (req.body || {}).reason);
+ const e = await svc.findEntity(out.entity_id).catch(() => null);
+ res.redirect(303, e ? `${svc.entityPath(e)}#elsewhere` : '/');
+ }));
// /k/employer, /k/restaurant…: everything of one kind, the most reviewed by members first.
// /k/employer, /k/restaurant…: everything of one kind, the most reviewed by members first.
router.get('/k/:kind', wrap(async (req, res) => {
router.get('/k/:kind', wrap(async (req, res) => {
const kind = String(req.params.kind);
const kind = String(req.params.kind);
@@ -246,8 +282,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -246,8 +282,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const discussion = await discussionFor(e, req.actor);
const discussion = await discussionFor(e, req.actor);
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 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, reviewError, reviewValues }), {
+ send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, 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.`,
@@ -182,11 +182,45 @@ ${rels.length ? html`<label>How you know it <select name="relation"><option valu
@@ -182,11 +182,45 @@ ${rels.length ? html`<label>How you know it <select name="relation"><option valu
</section>`;
</section>`;
}
}
-function elsewhereSection(links, entity) {
- if (!links.length) return '';
+function elsewhereSection(links, entity, { seen = {}, actor = null, editor = false } = {}) {
+ if (!links.length && !Object.keys(seen).length) return '';
+ const known = new Set(links.map((l) => l.id));
+ const extra = Object.values(seen).filter((r) => !known.has(r.site)).map((r) => ({ id: r.site, name: r.name, url: r.page_url, best: '' }));
+ const anySeen = Object.keys(seen).length > 0;
return html`<section id="elsewhere" aria-labelledby="ew-h"><h2 id="ew-h">${entity.name} on other review sites</h2>
return html`<section id="elsewhere" aria-labelledby="ew-h"><h2 id="ew-h">${entity.name} on other review sites</h2>
-<p class="rv-muted">Each opens that site's own page for it, in a new tab.</p>
-<ul class="rv-elsewhere">${links.map((l) => html`<li><a href="${l.url}" target="_blank" rel="nofollow noopener noreferrer">${l.name}</a> <span class="rv-muted">${l.best}</span></li>`)}</ul></section>`;
+<p class="rv-muted">Each opens that site's own page for it, in a new tab.${anySeen ? ' A rating beside a site is what its page showed when an OpenVibe member looked; it is theirs, not ours, and it is never mixed into the numbers above.' : ''}</p>
+<ul class="rv-elsewhere">${[...links, ...extra].map((l) => { const r = seen[l.id]; return html`<li><a href="${r ? r.page_url : l.url}" target="_blank" rel="nofollow noopener noreferrer">${l.name}</a> ${r ? seenLine(r, { entity, actor, editor }) : html`<span class="rv-muted">${l.best}</span>`}</li>`; })}</ul>
+<p class="rv-muted">Saw its rating on Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor? <a href="/save?entity=${entity.slug}">Add what that page shows</a>${actor && actor.subject ? '' : ' (with an OpenVibe account)'}, or let the <a href="https://openvibe.coupons/companion">OpenVibe Companion</a> read it for you.</p></section>`;
+}
+
+/** One site's rating as a member saw it, with when, how many members saw it, and a way to take it down. */
+function seenLine(r, { entity, actor, editor }) {
+ const mine = actor && actor.subject && r.seen_by === actor.subject;
+ return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${t(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;
+}
+
+/** /save: a member checks the rating another site's page shows (filled in by the Companion, or typed) and saves it. */
+function savePage({ values = {}, entity = null, sites, kinds, local, actor, error = null }) {
+ const v = values;
+ const site = sites[v.site] ? v.site : '';
+ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: 'Add a rating from another site' }])}
+<article class="rv-article"><h1>Add a rating from another site</h1>
+<p>Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps and Tripadvisor each show a rating. Bring what one of their pages shows, and it appears beside that site on the page here, with the date and a link back. Check the numbers against the page first.</p>
+${error ? notice('error', error) : ''}
+${actor && actor.subject ? html`<form method="post" action="/save" class="rv-form">
+${entity ? html`<input type="hidden" name="entity" value="${entity.slug}"><p>For <a href="${epath(entity)}"><b>${entity.name}</b></a>${entity.description && LOCAL.has(entity.kind) ? html` <span class="rv-muted">${entity.description}</span>` : ''}</p>`
+ : html`<label>What is it? <select name="kind">${kinds.map((x) => html`<option value="${x}"${x === (v.kind || (site ? sites[site].kind : 'business')) ? raw(' selected') : ''}>${KIND_LABEL[x]}</option>`)}</select></label>
+<label>Its name <input type="text" name="name" maxlength="200" required value="${v.name || ''}"></label>
+<label>Where it is <input type="text" name="place" maxlength="120" value="${v.place || ''}" placeholder="City, and state or country"> <span class="rv-muted">Needed for ${local.map((x) => KIND_PLURAL[x].toLowerCase()).join(', ')}.</span></label>`}
+<label>The page <input type="url" name="url" maxlength="1000" required value="${v.url || ''}" placeholder="https://www.glassdoor.com/Reviews/…"> <span class="rv-muted">Its address on ${Object.values(sites).map((x) => x.name).join(', ')}.</span></label>
+<div class="rv-row"><label>Rating <input type="text" inputmode="decimal" name="value" required value="${v.value || ''}" size="5" placeholder="3.9"></label>
+<label>Out of <input type="text" inputmode="decimal" name="best" required value="${v.best || '5'}" size="4"></label>
+<label>From how many ratings <input type="text" inputmode="numeric" name="count" value="${v.count || ''}" size="9" placeholder="1,234"></label></div>
+<input type="hidden" name="via" value="${v.via === 'companion' ? 'companion' : 'form'}">
+<button type="submit">Save</button></form>
+<p class="rv-muted">Saved under your OpenVibe account; a later look of yours replaces it. Reviews never reads those sites itself.</p>`
+ : html`<p><a class="rv-button" href="/auth/login?next=${encodeURIComponent(v.next || '/save')}">Sign in with OpenVibe to add it</a></p>`}
+</article>`;
}
}
/** /review: name what to review; found, or added by the member. */
/** /review: name what to review; found, or added by the member. */
@@ -377,7 +411,7 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
@@ -377,7 +411,7 @@ ${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 = [], reviewError = null, reviewValues = null }) {
+function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, reviewError = null, reviewValues = null }) {
const en = p.entity;
const en = p.entity;
const limits = [];
const limits = [];
for (const s of p.sources) {
for (const s of p.sources) {
@@ -405,7 +439,7 @@ ${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name |
@@ -405,7 +439,7 @@ ${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name |
${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}
${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}
<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
</section>` : ''}
</section>` : ''}
-${elsewhereSection(elsewhere, en)}
+${elsewhereSection(elsewhere, en, { seen, actor, editor: p.editor })}
${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>
${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>
${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}
${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}
${p.links.length ? html`<ul>${p.links.map((l) => html`<li>${l.type.replace(/_/g, ' ')}${l.direction === 'in' ? ' (from)' : ''}: ${l.other ? html`<a href="${epath(l.other)}">${l.other.name}</a>` : html`${l.ref ? `${l.ref.service} ${l.ref.type} ${l.ref.label || l.ref.id}` : ''}`}</li>`)}</ul>` : ''}</section>` : ''}
${p.links.length ? html`<ul>${p.links.map((l) => html`<li>${l.type.replace(/_/g, ' ')}${l.direction === 'in' ? ' (from)' : ''}: ${l.other ? html`<a href="${epath(l.other)}">${l.other.name}</a>` : html`${l.ref ? `${l.ref.service} ${l.ref.type} ${l.ref.label || l.ref.id}` : ''}`}</li>`)}</ul>` : ''}</section>` : ''}
@@ -595,5 +629,5 @@ module.exports = {
@@ -595,5 +629,5 @@ module.exports = {
everywherePage,
everywherePage,
home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
- reviewStartPage, kindPage, elsewhereLinks, KIND_PLURAL,
+ reviewStartPage, kindPage, elsewhereLinks, savePage, KIND_PLURAL,
};
};
@@ -2,8 +2,9 @@
@@ -2,8 +2,9 @@
/**
/**
* Account export and deletion → Reviews (ADR-033; openvibe-sdk/account-data). What Reviews holds about a person:
* Account export and deletion → Reviews (ADR-033; openvibe-sdk/account-data). What Reviews holds about a person:
*
*
- * their own, deleted the reviews they wrote here (review_member_reviews); the pages they were on are re-sent to
- * Search without them, in the same transaction
+ * their own, deleted the reviews they wrote here (review_member_reviews) and the ratings of other sites they
+ * saved (review_seen_ratings); the pages they were on are re-sent to Search without them,
+ * in the same transaction
* their additions, kept what they added to review (review_entities.created_by) and the corrections they sent
* their additions, kept what they added to review (review_entities.created_by) and the corrections they sent
* (review_corrections.submitted_by) stay, made authorless ('deleted'): other people's
* (review_corrections.submitted_by) stay, made authorless ('deleted'): other people's
* reviews and the editors' answers hang under them
* reviews and the editors' answers hang under them
@@ -20,6 +21,9 @@ async function extraExport(db, subject) {
@@ -20,6 +21,9 @@ async function extraExport(db, subject) {
const reviews = await db.many(`SELECT id, entity_id, rating, title, body, relation, status, created_at, updated_at FROM review_member_reviews
const reviews = await db.many(`SELECT id, entity_id, rating, title, body, relation, status, created_at, updated_at FROM review_member_reviews
WHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
WHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
if (reviews.length) files.push({ name: 'reviews.json', content: reviews });
if (reviews.length) files.push({ name: 'reviews.json', content: reviews });
+ const seen = await db.many(`SELECT id, entity_id, site, page_url, rating_value::float8 AS rating_value, rating_best::float8 AS rating_best, rating_count, seen_at, via, status
+ FROM review_seen_ratings WHERE seen_by = $1 ORDER BY seen_at DESC LIMIT 5000`, [subject]);
+ if (seen.length) files.push({ name: 'ratings-from-other-sites.json', content: seen });
const added = await db.many('SELECT id, slug, name, kind, description, created_at FROM review_entities WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000', [subject]);
const added = await db.many('SELECT id, slug, name, kind, description, created_at FROM review_entities WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000', [subject]);
if (added.length) files.push({ name: 'added-to-review.json', content: added });
if (added.length) files.push({ name: 'added-to-review.json', content: added });
const corrections = await db.many(`SELECT id, entity_id, target_type, target_id, body, evidence_url, status, resolution_note, created_at FROM review_corrections
const corrections = await db.many(`SELECT id, entity_id, target_type, target_id, body, evidence_url, status, resolution_note, created_at FROM review_corrections
@@ -31,15 +35,17 @@ async function extraExport(db, subject) {
@@ -31,15 +35,17 @@ async function extraExport(db, subject) {
/** The account-data handle over Reviews' database; `svc.syncIndex` re-sends a page (it joins the erase transaction). */
/** The account-data handle over Reviews' database; `svc.syncIndex` re-sends a page (it joins the erase transaction). */
function create({ db, svc, log = console } = {}) {
function create({ db, svc, log = console } = {}) {
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
- const pages = (await t.many('SELECT DISTINCT entity_id FROM review_member_reviews WHERE author = ANY($1::text[])', [subjects])).map((r) => r.entity_id);
+ const pages = (await t.many(`SELECT entity_id FROM review_member_reviews WHERE author = ANY($1::text[])
+ UNION SELECT entity_id FROM review_seen_ratings WHERE seen_by = ANY($1::text[])`, [subjects])).map((r) => r.entity_id);
counts.add(counts.erased, 'review_member_reviews', await t.exec('DELETE FROM review_member_reviews WHERE author = ANY($1::text[])', [subjects]));
counts.add(counts.erased, 'review_member_reviews', await t.exec('DELETE FROM review_member_reviews WHERE author = ANY($1::text[])', [subjects]));
+ counts.add(counts.erased, 'review_seen_ratings', await t.exec('DELETE FROM review_seen_ratings WHERE seen_by = ANY($1::text[])', [subjects]));
counts.add(counts.retained, 'tombstones', await t.exec("UPDATE review_entities SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [subjects]));
counts.add(counts.retained, 'tombstones', await t.exec("UPDATE review_entities SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [subjects]));
counts.add(counts.retained, 'tombstones', await t.exec("UPDATE review_corrections SET submitted_by = 'deleted' WHERE submitted_by = ANY($1::text[])", [subjects]));
counts.add(counts.retained, 'tombstones', await t.exec("UPDATE review_corrections SET submitted_by = 'deleted' WHERE submitted_by = ANY($1::text[])", [subjects]));
for (const id of pages) await svc.syncIndex(id);
for (const id of pages) await svc.syncIndex(id);
}
}
return createAccountData({
return createAccountData({
db, service: 'reviews', tables: TABLES, extraExport, extraErase, log,
db, service: 'reviews', tables: TABLES, extraExport, extraErase, log,
- note: 'Your reviews are deleted. What you added to review and the corrections you sent stay without your name.',
+ note: 'Your reviews and the ratings you saved are deleted. What you added to review and the corrections you sent stay without your name.',
});
});
}
}
@@ -104,6 +104,17 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -104,6 +104,17 @@ 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 > ?'),
+ // 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
+ FROM review_seen_ratings WHERE entity_id = ? AND status = 'shown' ORDER BY seen_at DESC LIMIT 200`),
+ seenRatingBy: db.prepare('SELECT * FROM review_seen_ratings WHERE entity_id = ? AND site = ? AND seen_by = ?'),
+ seenRating: db.prepare('SELECT * FROM review_seen_ratings WHERE id = ?'),
+ insertSeenRating: db.prepare(`INSERT INTO review_seen_ratings (id, entity_id, site, page_url, rating_value, rating_best, rating_count, seen_by, seen_at, via)
+ VALUES (@id, @entity_id, @site, @page_url, @rating_value, @rating_best, @rating_count, @seen_by, @now, @via)`),
+ updateSeenRating: db.prepare(`UPDATE review_seen_ratings SET page_url = @page_url, rating_value = @rating_value, rating_best = @rating_best, rating_count = @rating_count,
+ seen_at = @now, via = @via, status = 'shown', removed_by = NULL, removed_reason = NULL WHERE id = @id`),
+ removeSeenRating: db.prepare("UPDATE review_seen_ratings SET status = 'removed', removed_by = ?, removed_reason = ? WHERE id = ? AND status = 'shown'"),
+ seenRatingsSince: db.prepare('SELECT COUNT(*) AS n FROM review_seen_ratings WHERE seen_by = ? AND seen_at > ?'),
sameNamedOfKind: db.prepare(`SELECT e.* FROM review_entities e JOIN review_entity_aliases a ON a.entity_id = e.id
sameNamedOfKind: db.prepare(`SELECT e.* FROM review_entities e JOIN review_entity_aliases a ON a.entity_id = e.id
WHERE a.type = 'name' AND a.norm = ? AND a.removed_at IS NULL AND e.kind = ? AND e.state = 'active' ORDER BY e.created_at`),
WHERE a.type = 'name' AND a.norm = ? AND a.removed_at IS NULL AND e.kind = ? AND e.state = 'active' ORDER BY e.created_at`),
link: db.prepare('SELECT * FROM review_entity_links WHERE id = ?'),
link: db.prepare('SELECT * FROM review_entity_links WHERE id = ?'),
@@ -532,6 +543,26 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -532,6 +543,26 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
}
return { count, average: count ? Math.round((sum / count) * 10) / 10 : null, distribution, words };
return { count, average: count ? Math.round((sum / count) * 10) / 10 : null, distribution, words };
}
}
+ // Sites whose pages state a rating a member may bring here: the hosts a page must be on, and the kind it usually is.
+ const SEEN_SITES = {
+ glassdoor: { name: 'Glassdoor', host: /(^|\.)glassdoor\.[a-z.]{2,12}$/, kind: 'employer' },
+ indeed: { name: 'Indeed', host: /(^|\.)indeed\.[a-z.]{2,12}$/, kind: 'employer' },
+ yelp: { name: 'Yelp', host: /(^|\.)yelp\.[a-z.]{2,12}$/, kind: 'restaurant' },
+ bbb: { name: 'Better Business Bureau', host: /(^|\.)bbb\.org$/, kind: 'business' },
+ trustpilot: { name: 'Trustpilot', host: /(^|\.)trustpilot\.com$/, kind: 'organization' },
+ google: { name: 'Google Maps', host: /(^|\.)(google\.[a-z.]{2,12}|goo\.gl)$/, kind: 'business' },
+ tripadvisor: { name: 'Tripadvisor', host: /(^|\.)tripadvisor\.[a-z.]{2,12}$/, kind: 'restaurant' },
+ };
+ const SEEN_PER_DAY = 60;
+ /** The site a page belongs to (by its host), or null. */
+ function seenSiteOf(url) {
+ let u;
+ try { u = new URL(String(url || '')); } catch { return null; }
+ if (u.protocol !== 'https:') return null;
+ const h = u.hostname.toLowerCase();
+ return Object.keys(SEEN_SITES).find((k) => SEEN_SITES[k].host.test(h)) || null;
+ }
+
function memberReviewView(r, actor) {
function memberReviewView(r, actor) {
return {
return {
id: r.id, rating: Number(r.rating), title: r.title, body: r.body, relation: r.relation || null,
id: r.id, rating: Number(r.rating), title: r.title, body: r.body, relation: r.relation || null,
@@ -577,6 +608,20 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -577,6 +608,20 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* or a page Search already had that went away) — all on the caller's transaction handle. Returns
* or a page Search already had that went away) — all on the caller's transaction handle. Returns
* the stamped document, or null when nothing changed.
* the stamped document, or null when nothing changed.
*/
*/
+ /** Per site, the latest rating a member saw for an entity (merged ones included), with how many members saw it. */
+ async function seenBySite(entityId) {
+ const rows = [];
+ for (const id of await closure(entityId)) rows.push(...await q.seenRatingsOf.all(id));
+ const bySite = new Map();
+ for (const r of rows.sort((a, b) => Number(b.seen_at) - Number(a.seen_at))) {
+ const cur = bySite.get(r.site);
+ if (!cur) bySite.set(r.site, { site: r.site, name: SEEN_SITES[r.site].name, id: r.id, page_url: r.page_url, value: Number(r.rating_value), best: Number(r.rating_best),
+ count: r.rating_count == null ? null : Number(r.rating_count), seen_at: toIso(Number(r.seen_at)), via: r.via, members: 1, seen_by: r.seen_by });
+ else cur.members += 1;
+ }
+ return Object.fromEntries(bySite);
+ }
+
async function syncEntity(entityId) {
async function syncEntity(entityId) {
const e = await q.entity.get(entityId);
const e = await q.entity.get(entityId);
if (!e) return null;
if (!e) return null;
@@ -589,6 +634,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -589,6 +634,7 @@ 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) 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,
state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',
state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',
@@ -1277,6 +1323,62 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1277,6 +1323,62 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
});
});
},
},
+ // Ratings other sites show, as members saw them -------------------------------------------
+ SEEN_SITES, seenSiteOf,
+
+ /** 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); },
+
+ /**
+ * A member brings the rating a page of another site states: { site?, url, value, best, count?, via } for an
+ * entity ref (or { kind, name, place?, website? } to find or add one, as /review does) → { entity, seen }. The
+ * page must be on that site; one row per member, site and entity (a later look replaces theirs).
+ */
+ async saveSeenRating(target, { url, value, best = 5, count = null, via = 'form' } = {}, actor) {
+ const who = requirePerson(actor, 'Saving a rating');
+ const site = seenSiteOf(url);
+ if (!site) fail(422, 'seen.invalid_page', `The page must be an https address on ${Object.values(SEEN_SITES).map((x) => x.name).join(', ')}`);
+ const v = Number(value);
+ const b = Number(best);
+ if (!Number.isFinite(b) || b <= 0 || b > 100) fail(422, 'seen.invalid_scale', 'The scale is the best rating the site gives, like 5');
+ if (!Number.isFinite(v) || v < 0 || v > b) fail(422, 'seen.invalid_value', `The rating is a number from 0 to ${b}`);
+ const n = count == null || count === '' ? null : Number(String(count).replace(/[,\s]/g, ''));
+ if (n != null && (!Number.isInteger(n) || n < 0 || n > 1e9)) fail(422, 'seen.invalid_count', 'How many ratings it is from is a whole number');
+ const how = via === 'companion' ? 'companion' : 'form';
+ const entity = typeof target === 'string' ? await entityOrFail(target) : (await svc.memberEntity({ ...target, kind: target.kind || SEEN_SITES[site].kind, url: target.website || null }, actor)).entity;
+ return await tx(async () => {
+ const e = await q.entity.get(await canonicalOf(entity.id));
+ if (!e || e.state !== 'active') fail(409, 'entity.not_active', 'That is not reviewed here any more');
+ const t = now();
+ const prior = await q.seenRatingBy.get(e.id, site, who);
+ if (!prior && (await q.seenRatingsSince.get(who, t - DAY_MS)).n >= SEEN_PER_DAY) fail(429, 'seen.daily_limit', `You can save ${SEEN_PER_DAY} ratings a day`);
+ const row = { page_url: String(url).slice(0, 1000), rating_value: v, rating_best: b, rating_count: n, now: t, via: how };
+ let id;
+ if (prior) { id = prior.id; await q.updateSeenRating.run({ ...row, id }); }
+ else { id = `srt_${ulid(t)}`; await q.insertSeenRating.run({ ...row, id, entity_id: e.id, site, seen_by: who }); }
+ await touchEntity(e.id);
+ await syncEntity(e.id);
+ await audit(actor, 'seen_rating.saved', e.id, id, { site, value: v, best: b, count: n, via: how });
+ return { entity: e, seen: { id, site, value: v, best: b, count: n } };
+ });
+ },
+
+ /** Take a saved rating down: the member who saved it, or an editor (with the reason, kept in the audit log). */
+ async removeSeenRating(id, actor, reason = null) {
+ const who = requirePerson(actor, 'Removing a rating');
+ return await tx(async () => {
+ const r = await q.seenRating.get(String(id || ''));
+ if (!r || r.status !== 'shown') fail(404, 'seen.not_found', 'No such rating');
+ const own = r.seen_by === who;
+ if (!own && !access.isEditor(actor)) fail(403, 'reviews.editor_required', 'Only the member who saved it or an editor can remove it');
+ const why = own ? 'removed by the member who saved it' : String(reason || '').trim().slice(0, 300) || 'removed by an editor';
+ await q.removeSeenRating.run(who, why, r.id);
+ await syncEntity(await canonicalOf(r.entity_id));
+ await audit(actor, 'seen_rating.removed', r.entity_id, r.id, { reason: why });
+ return { removed: true, entity_id: r.entity_id };
+ });
+ },
+
async recentMemberReviews(limit = 8) {
async recentMemberReviews(limit = 8) {
return (await q.recentMemberReviews.all(limit)).map((r) => ({ ...memberReviewView(r, null), entity: { slug: r.entity_slug, name: r.entity_name, kind: r.entity_kind, place: r.entity_place } }));
return (await q.recentMemberReviews.all(limit)).map((r) => ({ ...memberReviewView(r, null), entity: { slug: r.entity_slug, name: r.entity_name, kind: r.entity_kind, place: r.entity_place } }));
},
},
@@ -24,6 +24,7 @@ t('export, then deletion: reviews gone and re-indexed, additions and corrections
@@ -24,6 +24,7 @@ t('export, then deletion: reviews gone and re-indexed, additions and corrections
await req(h, 'POST', '/review', { cookie: anaC, form: { kind: 'employer', name: 'Acme Corp' } });
await req(h, 'POST', '/review', { cookie: anaC, form: { kind: 'employer', name: 'Acme Corp' } });
await req(h, 'POST', '/e/acme-corp/reviews', { cookie: anaC, form: { rating: '4', title: 'Solid place to work', body: BODY } });
await req(h, 'POST', '/e/acme-corp/reviews', { cookie: anaC, form: { rating: '4', title: 'Solid place to work', body: BODY } });
await req(h, 'POST', '/e/acme-corp/reviews', { cookie: benC, form: { rating: '2', title: 'Burned out', body: 'Constant overtime, and the on-call rota was anything but fair to the night team.' } });
await req(h, 'POST', '/e/acme-corp/reviews', { cookie: benC, form: { rating: '2', title: 'Burned out', body: 'Constant overtime, and the on-call rota was anything but fair to the night team.' } });
+ await req(h, 'POST', '/save', { cookie: anaC, form: { entity: 'acme-corp', url: 'https://www.glassdoor.com/Reviews/Acme-E1.htm', value: '3.9', best: '5', count: '120' } });
const deliver = async (event, { secret = WEBHOOK_SECRET, headers = {} } = {}) => {
const deliver = async (event, { secret = WEBHOOK_SECRET, headers = {} } = {}) => {
const body = JSON.stringify({ event, seq: 1 });
const body = JSON.stringify({ event, seq: 1 });
const r = await fetch(`${h.base}/internal/account-events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });
const r = await fetch(`${h.base}/internal/account-events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });
@@ -37,6 +38,7 @@ t('export, then deletion: reviews gone and re-indexed, additions and corrections
@@ -37,6 +38,7 @@ t('export, then deletion: reviews gone and re-indexed, additions and corrections
const part = calls.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
const part = calls.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
const files = JSON.stringify(part.body);
const files = JSON.stringify(part.body);
assert.ok(files.includes('reviews.json') && files.includes('Solid place to work') && files.includes('added-to-review.json'), files.slice(0, 300));
assert.ok(files.includes('reviews.json') && files.includes('Solid place to work') && files.includes('added-to-review.json'), files.slice(0, 300));
+ assert.ok(files.includes('ratings-from-other-sites.json') && files.includes('Acme-E1.htm'), 'the rating she saved');
assert.ok(!files.includes('Burned out') && !files.includes(ben), 'nothing of Ben\'s');
assert.ok(!files.includes('Burned out') && !files.includes(ben), 'nothing of Ben\'s');
// Deletion: her review gone, the page re-sent to Search without it; Acme stays, made authorless.
// Deletion: her review gone, the page re-sent to Search without it; Acme stays, made authorless.
@@ -54,6 +56,8 @@ t('export, then deletion: reviews gone and re-indexed, additions and corrections
@@ -54,6 +56,8 @@ t('export, then deletion: reviews gone and re-indexed, additions and corrections
const conf = calls.filter((c) => c.path === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
const conf = calls.filter((c) => c.path === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
assert.strictEqual(conf.length, 1);
assert.strictEqual(conf.length, 1);
assert.strictEqual(conf[0].body.erased.review_member_reviews, 1);
assert.strictEqual(conf[0].body.erased.review_member_reviews, 1);
+ assert.strictEqual(conf[0].body.erased.review_seen_ratings, 1);
+ assert.ok(!/rv-seen/.test(page.text), 'the rating she saved is gone from the page');
// A redelivery erases nothing twice.
// A redelivery erases nothing twice.
assert.strictEqual((await deliver(del)).json.outcome, 'unchanged');
assert.strictEqual((await deliver(del)).json.outcome, 'unchanged');
// Signed wrong or relayed from outside: refused.
// Signed wrong or relayed from outside: refused.
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * Ratings other review sites show, as members saw them (owner, 2026-10-17: ".reviews doesn't combine stuff from
+ * glassdoor…"): a member brings what a Glassdoor/Yelp/BBB… page states (the Companion fills /save in, or they type
+ * it), checks it and saves it; the entity page shows it beside that site's link with the date and how many members
+ * saw it, never inside the members' or the sources' numbers; one per member, site and entity; the member or an editor
+ * takes it down; Search gets a line for it.
+ */
+const assert = require('assert');
+const { boot, req, suite, userToken, editorToken, cookieFor, subject, outbox } = require('./helpers');
+
+const t = suite('seen-ratings');
+const GD = 'https://www.glassdoor.com/Reviews/Acme-Corp-Reviews-E12345.htm';
+
+t('a member saves a Glassdoor rating; it shows beside Glassdoor; a later look replaces theirs; removal', async () => {
+ const h = await boot();
+ try {
+ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
+ const ben = cookieFor(userToken({ subject: subject(), username: 'ben' }));
+
+ // Signed out: asked to sign in, and the way back keeps the Companion's values.
+ const out = await req(h, 'GET', `/save?site=glassdoor&url=${encodeURIComponent(GD)}&name=Acme%20Corp&rating=3.9&best=5&count=1234`);
+ assert.strictEqual(out.status, 401);
+ assert.match(out.text, /next=%2Fsave%3F[^"]*rating%3D3\.9/);
+ assert.strictEqual((await req(h, 'POST', '/save', { form: { name: 'Acme Corp', url: GD, value: '3.9' } })).status, 401);
+
+ // The Companion's link prefills the confirm form, kind from the site (Glassdoor → employer).
+ const form = await req(h, 'GET', `/save?url=${encodeURIComponent(GD)}&name=Acme%20Corp&rating=3.9&best=5&count=1%2C234&via=companion`, { cookie: ana });
+ assert.strictEqual(form.status, 200);
+ assert.match(form.text, /<option value="employer" selected>/);
+ assert.match(form.text, /name="value" required value="3\.9"/);
+ assert.match(form.text, /name="via" value="companion"/);
+
+ // Only a page on that site, with a rating on its scale.
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: 'https://evil.example/glassdoor.com', value: '3.9', best: '5' } })).status, 422);
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: 'http://www.glassdoor.com/x', value: '3.9', best: '5' } })).status, 422, 'https only');
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: GD, value: '7', best: '5' } })).status, 422);
+
+ const saved = await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: GD, value: '3.9', best: '5', count: '1,234', via: 'companion' } });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.strictEqual(saved.headers.get('location'), '/e/acme-corp?saved=1#elsewhere');
+
+ let page = await req(h, 'GET', '/e/acme-corp');
+ assert.match(page.text, new RegExp(`<a href="${GD.replace(/[.?]/g, '\\$&')}"[^>]*>Glassdoor</a> <span class="rv-seen"><b>3\\.9</b> <span class="rv-muted">out of 5 from 1,234 ratings · seen`));
+ assert.ok(!/Reviews from OpenVibe members \(1\)/.test(page.text), 'not counted as a member review');
+
+ // Ben adds a newer look at the same site through the entity's own link; the page shows the latest, from 2 members.
+ assert.match((await req(h, 'GET', '/save?entity=acme-corp', { cookie: ben })).text, /For <a href="\/e\/acme-corp"><b>Acme Corp<\/b><\/a>/);
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ben, form: { entity: 'acme-corp', url: GD, value: '3.8', best: '5', count: '1300' } })).status, 303);
+ page = await req(h, 'GET', '/e/acme-corp');
+ assert.match(page.text, /<b>3\.8<\/b> <span class="rv-muted">out of 5 from 1,300 ratings · seen [\s\S]*?\(2 members\)/);
+
+ // Ana's later look replaces hers (still one row each).
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { entity: 'acme-corp', url: GD, value: '4.0', best: '5', count: '1310' } })).status, 303);
+ assert.strictEqual(Number((await h.db.prepare('SELECT COUNT(*) AS n FROM review_seen_ratings').get()).n), 2);
+ assert.match((await req(h, 'GET', '/e/acme-corp')).text, /<b>4<\/b> <span class="rv-muted">out of 5 from 1,310 ratings/);
+
+ // A Yelp page for a restaurant needs its place, like /review.
+ const yelp = 'https://www.yelp.com/biz/joes-pizza-new-york';
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", url: yelp, value: '4.5', best: '5' } })).status, 422);
+ assert.strictEqual((await req(h, 'POST', '/save', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", place: 'New York, NY', url: yelp, value: '4.5', best: '5', count: '9800' } })).status, 303);
+
+ // Search gets a line for it.
+ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted' && x.payload.title === 'Acme Corp');
+ assert.ok(docs.some((d) => /Glassdoor: 4 out of 5 from 1310 ratings/.test(d.payload.body || '')), 'the Search document carries it');
+
+ // Ana removes hers: Ben's look stays. Ben cannot remove someone else's; an editor can, with a reason.
+ const mine = await req(h, 'GET', '/e/acme-corp', { cookie: ana });
+ assert.match(mine.text, /<b>4<\/b>[\s\S]*?Remove mine/, 'the latest look is hers, and she can take it down');
+ const rows = await h.db.prepare('SELECT id, rating_value::float8 AS v FROM review_seen_ratings ORDER BY seen_at').all();
+ const anaRow = rows.find((r) => Number(r.v) === 4);
+ const benRow = rows.find((r) => Number(r.v) === 3.8);
+ assert.strictEqual((await req(h, 'POST', `/save/${anaRow.id}/remove`, { cookie: ben })).status, 403);
+ assert.strictEqual((await req(h, 'POST', `/save/${anaRow.id}/remove`, { cookie: ana })).status, 303);
+ assert.match((await req(h, 'GET', '/e/acme-corp', { cookie: ben })).text, /<b>3\.8<\/b>[\s\S]*?Remove mine/);
+ assert.strictEqual((await req(h, 'POST', `/save/${benRow.id}/remove`, { cookie: cookieFor(editorToken()), form: { reason: 'Wrong company' } })).status, 303);
+ assert.ok(!/rv-seen/.test((await req(h, 'GET', '/e/acme-corp')).text), 'no rating left beside Glassdoor');
+ const audit = await h.db.prepare("SELECT detail FROM review_audit WHERE action = 'seen_rating.removed' ORDER BY id DESC LIMIT 1").get();
+ assert.match(audit.detail, /Wrong company/);
+ } finally { await h.stop(); }
+});