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)

OP OpenVibers committed 27 days ago · 9107d0e746 · 9 files, +306 −12

Changes

ddiff --git a/migrations/0006_seen_ratings.sql b/migrations/0006_seen_ratings.sql
nnew file mode 100644
iindex 0000000..2d0520a
---- /dev/null
++++ b/migrations/0006_seen_ratings.sql
@@@ -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);
ddiff --git a/public/css/reviews.css b/public/css/reviews.css
iindex 5c21f71..c2f5b14 100644
---- a/public/css/reviews.css
++++ b/public/css/reviews.css
@@@ -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; }
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 60b7d41..dc81b1b 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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 },
};
/**
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f6279a1..90707c0 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
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, limi
const 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.`,
ddiff --git a/server/render/views.js b/server/render/views.js
iindex e2c5294..7481090 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -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,
};
ddiff --git a/server/reviews/account-data.js b/server/reviews/account-data.js
iindex 00838eb..cf1bae1 100644
---- a/server/reviews/account-data.js
++++ b/server/reviews/account-data.js
@@@ -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_reviews
WHERE 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.',
});
}
ddiff --git a/server/reviews/service.js b/server/reviews/service.js
iindex 7a68d36..c224f70 100644
---- a/server/reviews/service.js
++++ b/server/reviews/service.js
@@@ -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"),
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.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`),
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.now
if (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 } }));
},
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 328589c..fb3af35 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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', '/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 corrections
const 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 corrections
const 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.
ddiff --git a/test/seen-ratings.test.js b/test/seen-ratings.test.js
nnew file mode 100644
iindex 0000000..68b2426
---- /dev/null
++++ b/test/seen-ratings.test.js
@@@ -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(); }
++});