openvibers/openvibe.reviews Commits
Reviews by OpenVibe members: employers, landlords, restaurants, local businesses, hosting providers, products, apps and vehicles, added by the people who review them; 1–5 stars with text, beside (never inside) the sources' aggregate, with every other review site for that kind on each page (#76)
Changes
nnew file mode 100644@@@ -0,0 +1,31 @@++-- phase: expand++-- Reviews by OpenVibe members (owner, 2026-10-17: Reviews collects reviews of "all kinds of things like glassdoor++-- reviews of employers, yelp, server hosting providers, BBB…"). One review per person per entity: a 1–5 rating, a title++-- and text, kept beside the source signals and never mixed into their aggregate. Members may add what they review, so++-- the kinds people review join the list: employers, landlords, restaurants, local businesses, hosting providers, apps++-- and vehicles. Widening the check is safe for the release before this one (it never writes the new kinds).++ALTER TABLE review_entities DROP CONSTRAINT IF EXISTS review_entities_kind_check;++ALTER TABLE review_entities ADD CONSTRAINT review_entities_kind_check CHECK (kind IN ('product','game','software','service','place','organization','media','other',++ 'employer','landlord','restaurant','business','hosting','app','vehicle'));++++CREATE TABLE review_member_reviews (++ id text COLLATE "C" PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),++ author text COLLATE "C" NOT NULL,++ author_name text COLLATE "C",++ rating smallint NOT NULL CHECK (rating BETWEEN 1 AND 5),++ title text COLLATE "C" NOT NULL CHECK (length(title) BETWEEN 1 AND 120),++ body text COLLATE "C" NOT NULL CHECK (length(body) BETWEEN 1 AND 5000),++ relation text COLLATE "C" CHECK (relation IS NULL OR length(relation) <= 60),++ status text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (status IN ('published','removed')),++ removed_by text COLLATE "C",++ removed_reason text COLLATE "C",++ removed_at bigint,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (entity_id, author)++);++CREATE INDEX review_member_reviews_entity ON review_member_reviews (entity_id, status, created_at DESC);++CREATE INDEX review_member_reviews_author ON review_member_reviews (author);++CREATE INDEX review_member_reviews_recent ON review_member_reviews (created_at DESC) WHERE status = 'published';++CREATE INDEX review_entities_kind ON review_entities (kind, updated_at DESC) WHERE state = 'active';@@@ -104,3 +104,29 @@ table { border-collapse: collapse; }.rv-audit { font-size: 14px; }details summary { cursor: pointer; }code { overflow-wrap: anywhere; }++++/* Members' reviews (1–5 stars), other review sites, kinds */++.rv-stars { color: #f5b301; letter-spacing: 1px; white-space: nowrap; }++.rv-stars-off { color: var(--rv-line); }++.rv-mr-summary { display: flex; flex-wrap: wrap; gap: 12px 32px; align-items: center; margin: 8px 0 16px; }++.rv-dist { list-style: none; margin: 0; padding: 0; min-width: min(100%, 260px); }++.rv-dist li { display: grid; grid-template-columns: 2.2em minmax(0, 1fr) 3em; gap: 8px; align-items: center; font-size: .85rem; }++.rv-mr-list { list-style: none; margin: 0; padding: 0; }++.rv-mr { border-top: 1px solid var(--rv-line); padding: 14px 0; }++.rv-mr header { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }++.rv-mr .rv-muted { margin: 4px 0 8px; font-size: .85rem; }++.rv-mr-body p { margin: 0 0 8px; overflow-wrap: anywhere; }++.rv-mr-write { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--rv-line); max-width: 720px; }++.rv-rate { border: 0; padding: 0; margin: 10px 0; display: flex; flex-wrap: wrap; gap: 4px 14px; }++.rv-rate legend { padding: 0; margin-bottom: 4px; }++.rv-rate label { display: inline-flex; gap: 4px; align-items: center; margin: 0; }++.rv-form textarea { display: block; width: 100%; box-sizing: border-box; margin-top: 4px; font: inherit; }++.rv-link-button { background: none; border: 0; padding: 0; color: var(--rv-accent); font-weight: 500; cursor: pointer; text-decoration: underline; }++.rv-elsewhere, .rv-kind-list, .rv-recent { list-style: none; margin: 0; padding: 0; }++.rv-elsewhere li, .rv-kind-list li { padding: 6px 0; border-bottom: 1px solid var(--rv-line); }++.rv-kinds { list-style: none; margin: 0 0 12px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 140px), 1fr)); gap: 8px; }++.rv-kinds a { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border: 1px solid var(--rv-line); border-radius: 10px; text-decoration: none; color: inherit; }++.rv-kinds a:hover, .rv-kinds a:focus-visible { border-color: var(--rv-accent); }++.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; }@@@ -34,7 +34,8 @@ const { createReviewLog } = require('openvibe-publishing/authorship');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');const seo = require('openvibe-publishing/seo');--const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other'];++const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other',++ 'employer', 'landlord', 'restaurant', 'business', 'hosting', 'app', 'vehicle']; // the second row: what members review (0004)const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];@@@ -74,6 +74,10 @@ const BUDGETS = {'reviews.correction.resolve': { minute: 30, hour: 300 },// A discussion comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).'reviews.discussion.comment': { minute: 20, hour: 300 },++ // A member's own review, written or rewritten (the service also caps new ones at 30 a day).++ '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 },};/**@@@ -31,18 +31,21 @@ const views = require('../render/views');const { ratingForStructuredData } = require('../reviews/aggregate');const { actorMiddleware, crossSite } = require('./common');--const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing' };++const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing',++ employer: 'Organization', landlord: 'Organization', restaurant: 'Restaurant', business: 'LocalBusiness', hosting: 'Organization', app: 'SoftwareApplication', vehicle: 'Product' };/*** JSON-LD for an entity page. Product / Thing with an AggregateRating, and a Review for the* summary, exist ONLY when the aggregate exists — i.e. when real signals back it. Without* signals the page carries breadcrumbs and nothing that could be read as a rating.*/--function structuredData(p, config) {++function structuredData(p, config, members = null) {const en = p.entity;const origin = config.baseUrl;const out = [seo.structuredData.breadcrumbs([{ name: 'OpenVibe.Reviews', url: `${origin}/` }, { name: en.name, url: en.url }])];-- const rating = ratingForStructuredData(p.aggregate && p.aggregate.result);++ // The sources' aggregate, else the members' own reviews shown on the page (1–5 stars, their real count).++ const rating = ratingForStructuredData(p.aggregate && p.aggregate.result)++ || (members && members.stats.count ? { value: members.stats.average, best: 5, worst: 1, count: members.stats.count } : null);if (!rating) return out;const type = SCHEMA_TYPE[en.kind] || 'Thing';out.push(type === 'Product'@@@ -146,7 +149,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi// ── Public ───────────────────────────────────────────────router.get('/', async (req, res) => {const all = await svc.listEntities({ limit: 1 });-- send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req) }), {++ send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req), recent: await svc.recentMemberReviews(6), kinds: await svc.kindCounts() }), {robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET],jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),});@@@ -162,6 +165,55 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limititle: q ? `“${q}” on every review site` : 'Every review site, one search', robots: q ? 'noindex, follow' : 'index, follow', cache: 'public', active: 'everywhere', localStyles: ['css/everywhere.css'],});});++ // ── Members' reviews ─────────────────────────────────────++ // /review: name what you want to review (found, or added by you), then write about it on its page.++ router.get('/review', (req, res) => {++ const kind = svc.MEMBER_KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'employer';++ send(req, res, 200, views.reviewStartPage({ values: { kind, name: String(req.query.name || '').slice(0, 200) }, kinds: svc.MEMBER_KINDS, local: [...svc.LOCAL_KINDS], actor: req.actor }), {++ title: 'Write a review', robots: 'index, follow', cache: req.actor.kind === 'anonymous' ? 'public' : null, active: 'review',++ });++ });++ router.post('/review', F('reviews.entity.suggest'), form, wrap(async (req, res) => {++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to write a review.');++ const b = req.body || {};++ try {++ const { entity } = await svc.memberEntity({ kind: b.kind, name: b.name, place: b.place, url: b.url }, req.actor);++ res.redirect(303, `${svc.entityPath(entity)}#write`);++ } catch (err) {++ if (!err.status || err.status >= 500) throw err;++ send(req, res, err.status, views.reviewStartPage({ values: b, kinds: svc.MEMBER_KINDS, local: [...svc.LOCAL_KINDS], actor: req.actor, error: err.message }), { title: 'Write a review', robots: 'noindex, nofollow' });++ }++ }));++ router.post('/e/:slug/reviews', F('reviews.review.write'), form, wrap(async (req, res) => {++ const e = await locate(req, res, '/reviews');++ if (!e) return;++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to write a review.');++ const b = req.body || {};++ try {++ await svc.writeMemberReview(e.id, { rating: b.rating, title: b.title, body: b.body, relation: b.relation }, req.actor);++ res.redirect(303, `${svc.entityPath(e)}?saved=1#member-reviews`);++ } catch (err) {++ if (!err.status || err.status >= 500) throw err;++ await sendEntity(req, res, e, { status: err.status, reviewError: err.message, reviewValues: b });++ }++ }));++ router.post('/e/:slug/reviews/:id/remove', F('reviews.review.write'), form, wrap(async (req, res) => {++ const e = await locate(req, res, `/reviews/${req.params.id}/remove`);++ if (!e) return;++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account.');++ await svc.removeMemberReview(req.params.id, req.actor, (req.body || {}).reason);++ res.redirect(303, `${svc.entityPath(e)}#member-reviews`);++ }));++ // /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);++ if (!svc.MEMBER_KINDS.includes(kind)) return errorPage(req, res, 404, 'Not found', 'There is no such kind.');++ const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));++ const data = await svc.entitiesOfKind(kind, { limit: 60, offset: (page - 1) * 60 });++ send(req, res, 200, views.kindPage({ kind, page, ...data }), {++ title: views.KIND_PLURAL[kind], robots: data.total ? 'index, follow' : 'noindex, follow', cache: 'public', path: `/k/${kind}`,++ });++ }));router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));router.get('/search', async (req, res) => {const query = String(req.query.q || '').slice(0, 200);@@@ -188,16 +240,24 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi}}-- router.get('/e/:slug', wrap(async (req, res) => {-- const e = await locate(req, res);-- if (!e) return;++ // The entity page: source signals, the members' reviews (with the viewer's own form) and where else to read about it.++ async function sendEntity(req, res, e, { status = 200, reviewError = null, reviewValues = null } = {}) {const p = await svc.page(e, req.actor);const discussion = await discussionFor(e, req.actor);-- send(req, res, 200, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null }), {-- decision: p.decision, title: e.name, cache: discussion.state === 'ok' ? null : 'public', path: svc.entityPath(e),-- description: e.description || `${e.name}: review signals from ${p.sources.length ? p.sources.map((s) => s.name || s.key).join(', ') : 'no source yet'}, with provenance.`,-- canonical: p.entity.url, jsonLd: structuredData(p, config),++ const members = await svc.memberReviews(e, req.actor);++ const elsewhere = views.elsewhereLinks(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 }), {++ 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.`,++ canonical: p.entity.url, jsonLd: structuredData(p, config, members),});++ }++ router.get('/e/:slug', wrap(async (req, res) => {++ const e = await locate(req, res);++ if (!e) return;++ await sendEntity(req, res, e);}));router.get('/e/:slug/history', async (req, res) => {@@@ -38,7 +38,8 @@ let RELEASE = 'dev';function setRelease(id) { if (id) RELEASE = String(id); }const LINKS = [-- { label: 'Entities', href: '/', key: 'home' },++ { label: 'Reviews', href: '/', key: 'home' },++ { label: 'Write a review', href: '/review', key: 'review' },{ label: 'Every review site', href: '/everywhere', key: 'everywhere' },{ label: 'How it works', href: '/about', key: 'about' },];@@@ -3,9 +3,9 @@* Page bodies. Every interpolated value goes through openvibe-publishing/ssr's html`` (escaped* unless raw()). Forms are plain HTML posts; nothing here needs JavaScript.*-- * Numbers shown here come from review_signals (with their provenance) or from the aggregate-- * computed over them. There is no place in these templates that prints a rating from anywhere-- * else, and no star glyphs: values are printed as the source stated them.++ * Numbers shown here come from review_signals (with their provenance), from the aggregate computed over them, or from++ * reviews OpenVibe members wrote here (1 to 5 stars, always labelled as theirs and never mixed into the sources'++ * aggregate). Source values are printed as the source stated them, without star glyphs.*/const ssr = require('openvibe-publishing/ssr');const showcase = require('openvibe-shared/showcase');@@@ -16,7 +16,22 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().replconst e = encodeURIComponent;const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));--const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other' };++const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',++ employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle' };++const KIND_PLURAL = { employer: 'Employers', landlord: 'Landlords', restaurant: 'Restaurants', business: 'Local businesses', hosting: 'Hosting providers', product: 'Products',++ app: 'Apps', software: 'Software', game: 'Games', vehicle: 'Vehicles', service: 'Services', organization: 'Organizations', place: 'Places', media: 'Media', other: 'Other' };++// How a member knew it, offered on the form (their choice, or none).++const RELATIONS = {++ employer: ['Current employee', 'Former employee', 'Interviewed there', 'Contractor'], landlord: ['Current tenant', 'Former tenant', 'Applied to rent'],++ restaurant: ['Ate there', 'Ordered takeout', 'Ordered delivery'], business: ['Customer', 'Former customer'], hosting: ['Customer', 'Former customer', 'Tried it'],++ product: ['Own it', 'Owned it', 'Tried it'], vehicle: ['Own one', 'Owned one', 'Test-drove it', 'Rented one'], app: ['Use it', 'Used it'], software: ['Use it', 'Used it'],++ game: ['Played it'], service: ['Customer', 'Former customer'], organization: ['Customer', 'Member', 'Former member'], place: ['Visited'],++};++const LOCAL = new Set(['restaurant', 'business', 'landlord', 'place']);++// Which sites /everywhere offers for a kind (server/reviews/everywhere.js).++const EW_KIND = { employer: 'employer', landlord: 'landlord', restaurant: 'business', business: 'business', place: 'business', hosting: 'hosting', product: 'product',++ vehicle: 'product', app: 'software', software: 'software', game: 'software', service: 'company', organization: 'company', media: 'product', other: 'company' };++const everywhere = require('../reviews/everywhere');const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };const REASON_LABEL = {scale_not_stated: 'the source did not state the rating scale',@@@ -73,13 +88,16 @@ function aggregateLine(agg) {}/** The front page opens with what OpenVibe.Reviews is for (openvibe-shared/showcase), then the entities. */--function reviewsShowcase() {++function reviewsHero() {return showcase.hero({eyebrow: 'OpenVibe.Reviews',-- title: 'Review scores you can', accent: 'trace',-- lede: 'Ratings and recommendations gathered from named sources. Every number shows where it came from and when it was read, and when no source has said anything there is no score at all.',-- actions: [{ label: 'Browse what is reviewed', href: '#entities', primary: true }, { label: 'Search every review site', href: '/everywhere' }, { label: 'How it works', href: '/about' }],-- }) + showcase.features({++ title: 'Employers, landlords, restaurants, hosts.', accent: 'Every review in one place.',++ lede: 'Read and write reviews of where you work, rent, eat and host, beside the scores other sites publish and every review site\'s own page for it. Every number says where it came from.',++ actions: [{ label: 'Write a review', href: '/review', primary: true }, { label: 'Search every review site', href: '/everywhere' }, { label: 'How it works', href: '/about' }],++ });++}++function reviewsFeatures() {++ return showcase.features({title: 'What every page shows',items: [{ icon: 'ov:search', title: 'Where each number came from', text: 'Every signal keeps its source, the item it came from, and when it was read.' },@@@ -118,9 +136,99 @@ function boardCard(x) {: html`<span class="rv-basis">No score yet: no source has said enough.</span>`}</a></li>`;}--function home({ entities, total, editor }) {-- return html`${raw(reviewsShowcase())}--<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">What is reviewed</h2>++/** Every other review site's own page for an entity (the /everywhere links for its kind, and its place). */++function elsewhereLinks(entity) {++ return everywhere.linksFor(entity.name, { kind: EW_KIND[entity.kind] || 'company', near: LOCAL.has(entity.kind) ? entity.description || '' : '' });++}++++function stars(n) {++ const v = Math.max(0, Math.min(5, Math.round(Number(n) || 0)));++ return html`<span class="rv-stars" role="img" aria-label="${v} out of 5 stars">${'★'.repeat(v)}<span class="rv-stars-off">${'★'.repeat(5 - v)}</span></span>`;++}++function paragraphs(text) {++ return String(text || '').split(/\n{2,}/).map((para) => html`<p>${raw(para.split('\n').map((line) => ssr.escapeHtml(line)).join('<br>'))}</p>`);++}++++function memberReviewItem(r, { entity, editor }) {++ return html`<li><article class="rv-mr" id="${r.id}"><header>${stars(r.rating)} <b>${r.title}</b></header>++<p class="rv-muted">${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${t(r.created_at)}${r.edited ? ' (edited)' : ''}</p>++<div class="rv-mr-body">${paragraphs(r.body)}</div>++${r.mine ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><button type="submit" class="rv-link-button">Delete my review</button></form>`++ : editor ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><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">Remove</button></form>` : ''}++</article></li>`;++}++++/** The members' reviews of an entity: their numbers, the reviews, and the viewer's own form. */++function memberSection(members, { entity, actor, editor, error, values }) {++ const st = members.stats;++ const mine = members.mine;++ const v = values || (mine ? { rating: mine.rating, title: mine.title, body: mine.body, relation: mine.relation } : {});++ const rels = RELATIONS[entity.kind] || [];++ return html`<section id="member-reviews" aria-labelledby="mr-h"><h2 id="mr-h">Reviews from OpenVibe members${st.count ? ` (${st.count})` : ''}</h2>++${st.count ? html`<div class="rv-mr-summary"><p><span class="rv-big">${String(st.average)}</span> <span class="rv-muted">out of 5</span> ${stars(st.average)}<br><span class="rv-muted">from ${n(st.count)} review${st.count === 1 ? '' : 's'} written here by people with an OpenVibe account</span></p>++<ul class="rv-dist" aria-label="How many members gave each rating">${[5, 4, 3, 2, 1].map((k) => { const c = st.distribution[k - 1]; return html`<li><span>${String(k)}★</span><span class="rv-meter"><span style="width:${(st.count ? (100 * c) / st.count : 0).toFixed(1)}%"></span></span><span class="rv-muted">${n(c)}</span></li>`; })}</ul></div>++<ol class="rv-mr-list">${members.reviews.map((r) => memberReviewItem(r, { entity, editor }))}</ol>`++ : html`<p class="rv-muted">No one has reviewed ${entity.name} here yet.${actor && actor.subject ? ' Be the first.' : ''}</p>`}++<div id="write" class="rv-mr-write">${actor && actor.subject ? html`<h3>${mine ? 'Your review' : `Review ${entity.name}`}</h3>++${error ? notice('error', error) : ''}++<form method="post" action="${epath(entity)}/reviews" class="rv-form">++<fieldset class="rv-rate"><legend>Your rating</legend>${[1, 2, 3, 4, 5].map((k) => html`<label><input type="radio" name="rating" value="${String(k)}" required${String(v.rating) === String(k) ? raw(' checked') : ''}> ${String(k)} star${k === 1 ? '' : 's'}</label>`)}</fieldset>++${rels.length ? html`<label>How you know it <select name="relation"><option value="">Prefer not to say</option>${rels.map((x) => html`<option${v.relation === x ? raw(' selected') : ''}>${x}</option>`)}</select></label>` : ''}++<label>Title <input type="text" name="title" maxlength="120" required value="${v.title || ''}" placeholder="One line about it"></label>++<label>Your review <textarea name="body" rows="6" minlength="30" maxlength="5000" required placeholder="What was it like? What happened?">${v.body || ''}</textarea></label>++<p class="rv-muted">Posted under your OpenVibe name. Say what happened and what it was like; reviews that name private people, threaten anyone or are not about ${entity.name} are removed.</p>++<button type="submit">${mine ? 'Update your review' : 'Post review'}</button></form>`++ : html`<p><a class="rv-button" href="/auth/login?next=${encodeURIComponent(`${epath(entity)}#write`)}">Sign in to review ${entity.name}</a></p>`}</div>++</section>`;++}++++function elsewhereSection(links, entity) {++ if (!links.length) return '';++ 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>`;++}++++/** /review: name what to review; found, or added by the member. */++function reviewStartPage({ values = {}, kinds, local, actor, error = null }) {++ const k = values.kind || 'employer';++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: 'Write a review' }])}++<article class="rv-article"><h1>Write a review</h1>++<p>Employers, landlords, restaurants, hosting providers, products, apps: name it, and if it is already here you go straight to its page; if not, you add it.</p>++${error ? notice('error', error) : ''}++${actor && actor.subject ? html`<form method="post" action="/review" class="rv-form">++<label>What is it? <select name="kind">${kinds.map((x) => html`<option value="${x}"${x === k ? raw(' selected') : ''}>${KIND_LABEL[x]}</option>`)}</select></label>++<label>Its name <input type="text" name="name" maxlength="200" required value="${values.name || ''}" placeholder="e.g. Acme Corp, Joe's Pizza, Hetzner"></label>++<label>Where it is <input type="text" name="place" maxlength="120" value="${values.place || ''}" placeholder="City, and state or country"> <span class="rv-muted">Needed for ${local.map((x) => KIND_PLURAL[x].toLowerCase()).join(', ')}.</span></label>++<label>Its website (optional) <input type="text" name="url" maxlength="300" value="${values.url || ''}" placeholder="example.com"> <span class="rv-muted">So the same one is found again.</span></label>++<button type="submit">Continue</button></form>`++ : html`<p><a class="rv-button" href="/auth/login?next=%2Freview">Sign in with OpenVibe to write a review</a></p><p class="rv-muted">Reading needs no account.</p>`}++<p class="rv-muted">Looking for what other sites say? <a href="/everywhere">Search every review site</a>.</p></article>`;++}++++/** /k/:kind — everything of one kind, the most reviewed by members first. */++function kindPage({ kind, page, total, entities }) {++ const pages = Math.max(1, Math.ceil(total / 60));++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: KIND_PLURAL[kind] }])}++<article class="rv-article"><h1>${KIND_PLURAL[kind]}</h1>++<p><a class="rv-button" href="/review?kind=${kind}">Review ${LOCAL.has(kind) ? 'one near you' : `a${/^[aeiou]/i.test(KIND_LABEL[kind]) ? 'n' : ''} ${KIND_LABEL[kind].toLowerCase()}`}</a></p>++${entities.length ? html`<ul class="rv-kind-list">${entities.map((x) => html`<li><a href="${epath(x)}"><b>${x.name}</b></a>${x.description && LOCAL.has(x.kind) ? html` <span class="rv-muted">${x.description}</span>` : ''}++<br>${x.member_count ? html`${stars(x.member_avg)} <span class="rv-muted">${String(x.member_avg)} from ${n(x.member_count)} review${x.member_count === 1 ? '' : 's'}</span>` : html`<span class="rv-muted">No member reviews yet</span>`}</li>`)}</ul>`++ : html`<p class="rv-muted">None yet. The first review adds one.</p>`}++${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a href="/k/${kind}?page=${String(page - 1)}">Newer</a> ` : ''}Page ${String(page)} of ${String(pages)}${page < pages ? html` <a href="/k/${kind}?page=${String(page + 1)}">More</a>` : ''}</nav>` : ''}++</article>`;++}++++function home({ entities, total, editor, recent = [], kinds = {} }) {++ const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'product', 'app', 'game', 'vehicle'];++ return html`${raw(reviewsHero())}++<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>++<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>++<p><a class="rv-button" href="/review">Write a review</a> <a href="/everywhere">Search every review site</a></p></section>++${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2 id="rv-recent-h">Latest reviews from members</h2>++<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span></a></li>`)}</ul></section>` : ''}++${raw(reviewsFeatures())}++<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">Scores from sources</h2>${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;@@@ -269,7 +377,7 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>${form}</section>`;}--function entityPage(p, { discussion, actor, flash }) {++function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], reviewError = null, reviewValues = null }) {const en = p.entity;const limits = [];for (const s of p.sources) {@@@ -281,13 +389,14 @@ function entityPage(p, { discussion, actor, flash }) {const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}<article class="rv-entity">--<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span> · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>++<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span>${LOCAL.has(en.kind) && en.description ? html` · ${en.description}` : ''} · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>${flash ? notice('ok', flash) : ''}--${en.description ? html`<p>${en.description}</p>` : ''}++${en.description && !LOCAL.has(en.kind) ? html`<p>${en.description}</p>` : ''}++${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}--${aggregateSection(p)}--${summarySection(p)}--<section><h2 id="signals">Signals (${String(p.signals.length)})</h2>++${p.aggregate || p.signals.length || p.sources.length || !(members && members.stats.count) ? aggregateSection(p) : ''}++${p.summary || p.editor || !(members && members.stats.count) ? summarySection(p) : ''}++${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}</section>@@@ -295,7 +404,8 @@ ${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signa${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}${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>++</section>` : ''}++${elsewhereSection(elsewhere, en)}${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>` : ''}@@@ -485,4 +595,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,};@@@ -80,6 +80,32 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nownameAliases: db.prepare("SELECT * FROM review_entity_aliases WHERE type = 'name' AND norm = ? AND removed_at IS NULL"),insertAlias: db.prepare(`INSERT INTO review_entity_aliases (id, entity_id, type, value, norm, created_by, created_at) VALUES (@id, @entity_id, @type, @value, @norm, @by, @now)`),removeAlias: db.prepare('UPDATE review_entity_aliases SET removed_at = ?, removed_by = ? WHERE id = ? AND removed_at IS NULL'),++ // Members' own reviews (migrations/0004): one per person per entity, beside the source signals.++ memberReviewsOf: db.prepare("SELECT * FROM review_member_reviews WHERE entity_id = ? AND status = 'published' ORDER BY created_at DESC LIMIT ?"),++ memberStatsOf: db.prepare(`SELECT COUNT(*) AS n, AVG(rating)::float8 AS avg,++ COUNT(*) FILTER (WHERE rating = 1) AS r1, COUNT(*) FILTER (WHERE rating = 2) AS r2, COUNT(*) FILTER (WHERE rating = 3) AS r3,++ COUNT(*) FILTER (WHERE rating = 4) AS r4, COUNT(*) FILTER (WHERE rating = 5) AS r5,++ COALESCE(SUM(array_length(regexp_split_to_array(trim(body), '\\s+'), 1)), 0) AS words++ FROM review_member_reviews WHERE entity_id = ? AND status = 'published'`),++ memberReview: db.prepare('SELECT * FROM review_member_reviews WHERE id = ?'),++ memberReviewBy: db.prepare('SELECT * FROM review_member_reviews WHERE entity_id = ? AND author = ?'),++ insertMemberReview: db.prepare(`INSERT INTO review_member_reviews (id, entity_id, author, author_name, rating, title, body, relation, created_at, updated_at)++ VALUES (@id, @entity_id, @author, @author_name, @rating, @title, @body, @relation, @now, @now)`),++ updateMemberReview: db.prepare(`UPDATE review_member_reviews SET rating = @rating, title = @title, body = @body, relation = @relation, author_name = @author_name,++ status = 'published', removed_by = NULL, removed_reason = NULL, removed_at = NULL, updated_at = @now WHERE id = @id`),++ removeMemberReview: db.prepare("UPDATE review_member_reviews SET status = 'removed', removed_by = ?, removed_reason = ?, removed_at = ?, updated_at = ? WHERE id = ? AND status = 'published'"),++ recentMemberReviews: db.prepare(`SELECT r.*, e.slug AS entity_slug, e.name AS entity_name, e.kind AS entity_kind, e.description AS entity_place++ FROM review_member_reviews r JOIN review_entities e ON e.id = r.entity_id++ WHERE r.status = 'published' AND e.state = 'active' ORDER BY r.created_at DESC LIMIT ?`),++ entitiesOfKind: db.prepare(`SELECT e.*, (SELECT COUNT(*) FROM review_member_reviews m WHERE m.entity_id = e.id AND m.status = 'published') AS member_count,++ (SELECT AVG(m.rating)::float8 FROM review_member_reviews m WHERE m.entity_id = e.id AND m.status = 'published') AS member_avg++ FROM review_entities e WHERE e.state = 'active' AND e.kind = ? ORDER BY member_count DESC, e.updated_at DESC LIMIT ? OFFSET ?`),++ countOfKind: db.prepare("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active' AND kind = ?"),++ kindCounts: db.prepare("SELECT kind, COUNT(*) AS n FROM review_entities WHERE state = 'active' GROUP BY kind"),++ memberReviewsSince: db.prepare('SELECT COUNT(*) AS n FROM review_member_reviews WHERE author = ? AND created_at > ?'),++ memberEntitiesSince: db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE created_by = ? AND created_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 = ?'),linksFrom: db.prepare("SELECT * FROM review_entity_links WHERE from_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),linksTo: db.prepare("SELECT * FROM review_entity_links WHERE to_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),@@@ -478,6 +504,42 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowreturn out;}++ // ── Members' reviews ─────────────────────────────────────++ // What members may add and review. Local kinds are told apart by their place (a city), kept as the description.++ const MEMBER_KINDS = ['employer', 'landlord', 'restaurant', 'business', 'hosting', 'product', 'app', 'software', 'game', 'vehicle', 'service', 'organization', 'place'];++ const LOCAL_KINDS = new Set(['restaurant', 'business', 'landlord', 'place']);++ const MEMBER_REVIEWS_PER_DAY = 30;++ const MEMBER_ENTITIES_PER_DAY = 20;++++ function requirePerson(actor, what) {++ if (!access.isPerson(actor)) fail(403, 'reviews.person_required', `${what} needs a signed-in OpenVibe account`);++ return actor.subject;++ }++ function memberName(actor) {++ const u = actor && actor.user;++ return u ? String(u.display_name || u.username || '').trim().slice(0, 60) || null : null;++ }++ /** Members' numbers over a set of entity ids (a canonical entity and the ones merged into it). */++ async function memberStats(ids) {++ let count = 0, sum = 0, words = 0;++ const distribution = [0, 0, 0, 0, 0];++ for (const id of ids) {++ const r = await q.memberStatsOf.get(id);++ const n = Number(r.n) || 0;++ if (!n) continue;++ count += n; sum += (Number(r.avg) || 0) * n; words += Number(r.words) || 0;++ [r.r1, r.r2, r.r3, r.r4, r.r5].forEach((v, i) => { distribution[i] += Number(v) || 0; });++ }++ return { count, average: count ? Math.round((sum / count) * 10) / 10 : null, distribution, words };++ }++ function memberReviewView(r, actor) {++ return {++ id: r.id, rating: Number(r.rating), title: r.title, body: r.body, relation: r.relation || null,++ author_name: r.author_name || 'An OpenVibe member', created_at: toIso(Number(r.created_at)), updated_at: toIso(Number(r.updated_at)),++ edited: Number(r.updated_at) > Number(r.created_at) + 60000, mine: !!(actor && actor.subject && actor.subject === r.author),++ };++ }++// ── The gate, the Search document ────────────────────────async function publishedSummaryRevision(entityId) {const s = await q.summary.get(entityId);@@@ -490,12 +552,14 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowconst active = await activeSignalsIn(members);const { summary, rev } = await publishedSummaryRevision(e.id);const rec = rev && rev.meta && rev.meta.authorship;++ // Members' reviews count as substance: their words (from three reviews up) and each review as a citation.++ const mem = await memberStats(members);return {state: e.state === 'deleted' ? 'deleted' : 'published',visibility: 'public',canonicalUrl: entityUrl(e),-- wordCount: rev ? ssr.wordCount(summaryText(rev)) : 0,-- citationCount: active.length,++ wordCount: (rev ? ssr.wordCount(summaryText(rev)) : 0) + (mem.count >= 3 ? mem.words : 0),++ citationCount: active.length + mem.count,unsupportedClaims: rev ? await unsupportedCount(e.id, rev) : 0,noindex: !!e.noindex,...(rec ? authorship.gateFacts(rec, await reviews.latest(summary.id, rev.number)) : {}),@@@ -523,6 +587,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowconst signals = live ? await activeSignalsIn(await closure(e.id)) : [];const lines = [];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'}`);const doc = hooks.buildIndexDocument({owner: 'reviews', type: 'entity', id: e.id, revision: 0,state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',@@@ -1112,6 +1178,118 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now});},++ // Members' reviews ---------------------------------------------------------------------++ MEMBER_KINDS, LOCAL_KINDS,++++ /** Reviews by OpenVibe members of an entity (merged ones included): their numbers, the newest first, the viewer's own. */++ async memberReviews(e, actor, { limit = 50 } = {}) {++ const ids = await closure(e.id);++ const rows = [];++ for (const id of ids) rows.push(...await q.memberReviewsOf.all(id, limit));++ rows.sort((a, b) => Number(b.created_at) - Number(a.created_at));++ const mine = actor && actor.subject ? await q.memberReviewBy.get(e.id, actor.subject) : null;++ return { stats: await memberStats(ids), reviews: rows.slice(0, limit).map((r) => memberReviewView(r, actor)), mine: mine && mine.status === 'published' ? memberReviewView(mine, actor) : null };++ },++++ /**++ * What a member wants to review, found or added: { kind, name, place?, url? } → { entity, created }. The same++ * thing is found by its website, else by its name and kind (and place, for local kinds); otherwise it is added++ * with the member as its creator, and an editor can merge a duplicate later.++ */++ async memberEntity({ kind, name, place = null, url = null } = {}, actor) {++ const who = requirePerson(actor, 'Adding something to review');++ if (!MEMBER_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${MEMBER_KINDS.join(', ')}`);++ const clean = checkName(name);++ const where = place ? String(place).replace(/\s+/g, ' ').trim().slice(0, 120) || null : null;++ if (LOCAL_KINDS.has(kind) && !where) fail(422, 'entity.place_required', 'Say where it is: a city, and a state or country');++ let site = null;++ if (url && String(url).trim()) {++ try {++ const raw = String(url).trim();++ const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);++ if (!/^https?:$/.test(u.protocol) || !u.hostname.includes('.')) throw new Error('not a web address');++ site = `https://${u.hostname.toLowerCase().replace(/^www\./, '')}/`;++ } catch { fail(422, 'entity.invalid_url', 'The website is a web address like example.com'); }++ }++ return await tx(async () => {++ if (site) {++ const a = await q.strongAlias.get('url', norm.alias('url', site));++ if (a) return { entity: await q.entity.get(await canonicalOf(a.entity_id)), created: false };++ }++ const same = (await q.sameNamedOfKind.all(norm.alias('name', clean), kind))++ .filter((x) => !LOCAL_KINDS.has(kind) || String(x.description || '').toLowerCase() === String(where || '').toLowerCase());++ if (same.length) return { entity: await q.entity.get(await canonicalOf(same[0].id)), created: false };++ const t = now();++ if ((await q.memberEntitiesSince.get(who, t - DAY_MS)).n >= MEMBER_ENTITIES_PER_DAY) fail(429, 'entity.member_limit', `You can add ${MEMBER_ENTITIES_PER_DAY} things a day`);++ const id = `ent_${ulid(t)}`;++ const slug = await uniqueSlug(LOCAL_KINDS.has(kind) ? `${clean} ${where}` : clean);++ await redirects.release(`/e/${slug}`);++ await q.insertEntity.run({ id, slug, name: clean, kind, description: where, created_by: who, now: t });++ await insertAlias(id, 'name', clean, actor);++ if (site) await insertAlias(id, 'url', site, actor);++ await audit(actor, 'entity.added_by_member', id, null, { name: clean, kind, place: where, url: site });++ await syncEntity(id);++ return { entity: await q.entity.get(id), created: true };++ });++ },++++ /** Write, or rewrite, the viewer's review of an entity: { rating 1–5, title, body, relation? }. */++ async writeMemberReview(ref, { rating, title, body, relation = null } = {}, actor) {++ const who = requirePerson(actor, 'Writing a review');++ const r = Number(rating);++ if (!Number.isInteger(r) || r < 1 || r > 5) fail(422, 'review.invalid_rating', 'Pick a rating from 1 to 5 stars');++ const tt = String(title == null ? '' : title).replace(/\s+/g, ' ').trim();++ if (!tt || tt.length > 120) fail(422, 'review.invalid_title', 'Give it a title of up to 120 characters');++ const bb = String(body == null ? '' : body).replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();++ if (bb.length < 30 || bb.length > 5000) fail(422, 'review.invalid_body', 'Say what it was like in 30 to 5,000 characters');++ const rel = relation ? String(relation).replace(/\s+/g, ' ').trim().slice(0, 60) || null : null;++ return await tx(async () => {++ const e = await entityOrFail(ref);++ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);++ const t = now();++ const prior = await q.memberReviewBy.get(e.id, who);++ if (!prior && (await q.memberReviewsSince.get(who, t - DAY_MS)).n >= MEMBER_REVIEWS_PER_DAY) fail(429, 'review.daily_limit', `You can write ${MEMBER_REVIEWS_PER_DAY} reviews a day`);++ const row = { rating: r, title: tt, body: bb, relation: rel, author_name: memberName(actor), now: t };++ let id;++ if (prior) { id = prior.id; await q.updateMemberReview.run({ ...row, id }); }++ else { id = `mrv_${ulid(t)}`; await q.insertMemberReview.run({ ...row, id, entity_id: e.id, author: who }); }++ await touchEntity(e.id);++ await audit(actor, prior ? 'member_review.edited' : 'member_review.written', e.id, id, { rating: r });++ await syncEntity(await canonicalOf(e.id));++ return memberReviewView(await q.memberReview.get(id), actor);++ });++ },++++ /** Take a review down: its author, or an editor (with the reason, kept in the audit log). */++ async removeMemberReview(id, actor, reason = null) {++ const who = requirePerson(actor, 'Removing a review');++ return await tx(async () => {++ const r = await q.memberReview.get(String(id || ''));++ if (!r || r.status !== 'published') fail(404, 'review.not_found', 'No such review');++ const own = r.author === who;++ if (!own && !access.isEditor(actor)) fail(403, 'reviews.editor_required', 'Only its author or an editor can remove a review');++ const why = own ? 'removed by its author' : String(reason || '').trim().slice(0, 300) || 'removed by an editor';++ const t = now();++ await q.removeMemberReview.run(who, why, t, t, r.id);++ await audit(actor, 'member_review.removed', r.entity_id, r.id, { reason: why });++ await syncEntity(await canonicalOf(r.entity_id));++ 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 } }));++ },++ async entitiesOfKind(kind, { limit = 60, offset = 0 } = {}) {++ if (!ENTITY_KINDS.includes(kind)) fail(404, 'kind.not_found', 'No such kind');++ const rows = await q.entitiesOfKind.all(kind, limit, offset);++ return {++ total: Number((await q.countOfKind.get(kind)).n),++ entities: rows.map((x) => ({ ...x, member_count: Number(x.member_count) || 0, member_avg: x.member_avg == null ? null : Math.round(Number(x.member_avg) * 10) / 10 })),++ };++ },++ async kindCounts() { return Object.fromEntries((await q.kindCounts.all()).map((x) => [x.kind, Number(x.n)])); },++async updateEntity(ref, { name, kind, description, slug, noindex } = {}, actor) {requireEditorPerson(actor, 'Editing an entity');return await tx(async () => {nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * Reviews by OpenVibe members (owner, 2026-10-17: "collect reviews for all kinds of things like glassdoor reviews of++ * employers, yelp, server hosting providers, BBB…"): a signed-in person names what to review (found, or added by them),++ * writes one review of it (1–5 stars, a title, text), rewrites or deletes it; editors remove one with a reason. The++ * page shows the members' numbers beside (never inside) the sources' aggregate, links every other review site for that++ * kind, and the home and /k/<kind> list them.++ */++const assert = require('assert');++const { boot, req, suite, userToken, editorToken, cookieFor, subject, outbox } = require('./helpers');++++const t = suite('member-reviews');++const BODY = 'Managers listened, the pay was on time, and the on-call rota was fair. Promotions were slow though.';++++t('a member adds an employer, reviews it, rewrites it; others see it; the numbers and the other sites are on the page', async () => {++ const h = await boot();++ try {++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));++ const ben = cookieFor(userToken({ subject: subject(), username: 'ben' }));++++ // Signed out: the form asks to sign in, posting adds nothing.++ assert.match((await req(h, 'GET', '/review')).text, /Sign in with OpenVibe to write a review/);++ assert.strictEqual((await req(h, 'POST', '/review', { form: { kind: 'employer', name: 'Acme Corp' } })).status, 401);++++ // Added once; the same name (or website) finds it again.++ const add = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: 'https://www.acme.example/jobs' } });++ assert.strictEqual(add.status, 303, add.text);++ assert.match(add.headers.get('location'), /^\/e\/acme-corp#write$/);++ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ben, form: { kind: 'employer', name: 'acme corp' } })).headers.get('location'), '/e/acme-corp#write', 'the same name finds it');++ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ben, form: { kind: 'employer', name: 'ACME Corporation', url: 'acme.example' } })).headers.get('location'), '/e/acme-corp#write', 'its website finds it');++++ // A local business needs its place; the same name elsewhere is another business.++ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza" } })).status, 422);++ const nyc = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", place: 'New York, NY' } });++ const atx = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", place: 'Austin, TX' } });++ assert.notStrictEqual(nyc.headers.get('location'), atx.headers.get('location'));++++ // Reviews: validation, then posted; one per person, rewritten in place.++ assert.strictEqual((await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '6', title: 'x', body: BODY } })).status, 422);++ assert.strictEqual((await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '4', title: 'Fine', body: 'too short' } })).status, 422);++ const posted = await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '4', title: 'Solid place to work', body: BODY, relation: 'Former employee' } });++ assert.strictEqual(posted.status, 303, posted.text);++ await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ben, 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: ana, form: { rating: '5', title: 'Solid place to work', body: BODY, relation: 'Former employee' } });++++ const page = await req(h, 'GET', '/e/acme-corp');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /Reviews from OpenVibe members \(2\)/);++ assert.match(page.text, /<span class="rv-big">3\.5<\/span>/, 'average of 5 and 2');++ assert.match(page.text, /Solid place to work/);++ assert.match(page.text, /ana · Former employee/);++ assert.match(page.text, /Sign in to review Acme Corp/, 'a signed-out reader is offered sign-in, not the form');++ // Every other review site for an employer, opening its own search.++ for (const site of ['Glassdoor', 'Indeed', 'Blind', 'Reddit']) assert.ok(page.text.includes(`>${site}</a>`), site);++ assert.match(page.text, /https:\/\/www\.glassdoor\.com\/Search\/results\.htm\?keyword=Acme%20Corp/);++ assert.ok(!/Signals from sources/.test(page.text), 'no empty sources section on a members-only page');++ // JSON-LD carries the members' rating (it is on the page).++ assert.match(page.text, /"ratingValue":\s*3\.5/);++++ // Ana sees her own review in the form, and can delete it.++ const mine = await req(h, 'GET', '/e/acme-corp', { cookie: ana });++ assert.match(mine.text, /Update your review/);++ const id = /<article class="rv-mr" id="(mrv_[0-9A-Z]{26})">(?:(?!<\/article>)[\s\S])*Delete my review/.exec(mine.text)[1];++ assert.strictEqual((await req(h, 'POST', `/e/acme-corp/reviews/${id}/remove`, { cookie: ben })).status, 403, 'not someone else\'s');++ assert.strictEqual((await req(h, 'POST', `/e/acme-corp/reviews/${id}/remove`, { cookie: ana })).status, 303);++ assert.match((await req(h, 'GET', '/e/acme-corp')).text, /Reviews from OpenVibe members \(1\)/);++++ // An editor removes Ben's with a reason (kept in the audit log, not shown as a review).++ const page2 = await req(h, 'GET', '/e/acme-corp', { cookie: cookieFor(editorToken()) });++ const benId = /id="(mrv_[0-9A-Z]{26})"/.exec(page2.text)[1];++ assert.strictEqual((await req(h, 'POST', `/e/acme-corp/reviews/${benId}/remove`, { cookie: cookieFor(editorToken()), form: { reason: 'Not about this employer' } })).status, 303);++ const audit = await h.db.prepare("SELECT detail FROM review_audit WHERE action = 'member_review.removed' ORDER BY id DESC LIMIT 1").get();++ assert.match(audit.detail, /Not about this employer/);++++ // Lists: /k/employer and the home's latest reviews; Search gets the members' line.++ await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '4', title: 'Back, and still good', body: BODY } });++ assert.match((await req(h, 'GET', '/k/employer')).text, /Acme Corp[\s\S]*4 from 1 review/);++ const home = await req(h, 'GET', '/');++ assert.match(home.text, /Latest reviews from members[\s\S]*Back, and still good/);++ assert.match(home.text, /href="\/k\/employer"/);++ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted' && x.payload.id && x.payload.title === 'Acme Corp');++ assert.ok(docs.some((d) => /OpenVibe members: 4 out of 5 from 1 review/.test(d.payload.body || '')), 'the Search document says what members gave it');++ assert.strictEqual((await req(h, 'GET', '/k/nonsense')).status, 404);++ } finally { await h.stop(); }++});@@@ -18,8 +18,8 @@ const BUDGETS = {jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)-- cssRawKB: 20.5, // 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)-- cssBrotliKB: 5, // 4.6 (4.3, 1.7 before showcase.css)++ cssRawKB: 23, // 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)++ cssBrotliKB: 5.5, // 5.1 with members' reviews (2026-10-17); 4.6 (4.3, 1.7 before showcase.css)externalFiles: 1, // 0};
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+-- phase: expand
+-- Reviews by OpenVibe members (owner, 2026-10-17: Reviews collects reviews of "all kinds of things like glassdoor
+-- reviews of employers, yelp, server hosting providers, BBB…"). One review per person per entity: a 1–5 rating, a title
+-- and text, kept beside the source signals and never mixed into their aggregate. Members may add what they review, so
+-- the kinds people review join the list: employers, landlords, restaurants, local businesses, hosting providers, apps
+-- and vehicles. Widening the check is safe for the release before this one (it never writes the new kinds).
+ALTER TABLE review_entities DROP CONSTRAINT IF EXISTS review_entities_kind_check;
+ALTER TABLE review_entities ADD CONSTRAINT review_entities_kind_check CHECK (kind IN ('product','game','software','service','place','organization','media','other',
+ 'employer','landlord','restaurant','business','hosting','app','vehicle'));
+
+CREATE TABLE review_member_reviews (
+ id text COLLATE "C" PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
+ author text COLLATE "C" NOT NULL,
+ author_name text COLLATE "C",
+ rating smallint NOT NULL CHECK (rating BETWEEN 1 AND 5),
+ title text COLLATE "C" NOT NULL CHECK (length(title) BETWEEN 1 AND 120),
+ body text COLLATE "C" NOT NULL CHECK (length(body) BETWEEN 1 AND 5000),
+ relation text COLLATE "C" CHECK (relation IS NULL OR length(relation) <= 60),
+ status text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (status IN ('published','removed')),
+ removed_by text COLLATE "C",
+ removed_reason text COLLATE "C",
+ removed_at bigint,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (entity_id, author)
+);
+CREATE INDEX review_member_reviews_entity ON review_member_reviews (entity_id, status, created_at DESC);
+CREATE INDEX review_member_reviews_author ON review_member_reviews (author);
+CREATE INDEX review_member_reviews_recent ON review_member_reviews (created_at DESC) WHERE status = 'published';
+CREATE INDEX review_entities_kind ON review_entities (kind, updated_at DESC) WHERE state = 'active';
@@ -104,3 +104,29 @@ table { border-collapse: collapse; }
@@ -104,3 +104,29 @@ table { border-collapse: collapse; }
.rv-audit { font-size: 14px; }
.rv-audit { font-size: 14px; }
details summary { cursor: pointer; }
details summary { cursor: pointer; }
code { overflow-wrap: anywhere; }
code { overflow-wrap: anywhere; }
+
+/* Members' reviews (1–5 stars), other review sites, kinds */
+.rv-stars { color: #f5b301; letter-spacing: 1px; white-space: nowrap; }
+.rv-stars-off { color: var(--rv-line); }
+.rv-mr-summary { display: flex; flex-wrap: wrap; gap: 12px 32px; align-items: center; margin: 8px 0 16px; }
+.rv-dist { list-style: none; margin: 0; padding: 0; min-width: min(100%, 260px); }
+.rv-dist li { display: grid; grid-template-columns: 2.2em minmax(0, 1fr) 3em; gap: 8px; align-items: center; font-size: .85rem; }
+.rv-mr-list { list-style: none; margin: 0; padding: 0; }
+.rv-mr { border-top: 1px solid var(--rv-line); padding: 14px 0; }
+.rv-mr header { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
+.rv-mr .rv-muted { margin: 4px 0 8px; font-size: .85rem; }
+.rv-mr-body p { margin: 0 0 8px; overflow-wrap: anywhere; }
+.rv-mr-write { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--rv-line); max-width: 720px; }
+.rv-rate { border: 0; padding: 0; margin: 10px 0; display: flex; flex-wrap: wrap; gap: 4px 14px; }
+.rv-rate legend { padding: 0; margin-bottom: 4px; }
+.rv-rate label { display: inline-flex; gap: 4px; align-items: center; margin: 0; }
+.rv-form textarea { display: block; width: 100%; box-sizing: border-box; margin-top: 4px; font: inherit; }
+.rv-link-button { background: none; border: 0; padding: 0; color: var(--rv-accent); font-weight: 500; cursor: pointer; text-decoration: underline; }
+.rv-elsewhere, .rv-kind-list, .rv-recent { list-style: none; margin: 0; padding: 0; }
+.rv-elsewhere li, .rv-kind-list li { padding: 6px 0; border-bottom: 1px solid var(--rv-line); }
+.rv-kinds { list-style: none; margin: 0 0 12px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 140px), 1fr)); gap: 8px; }
+.rv-kinds a { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border: 1px solid var(--rv-line); border-radius: 10px; text-decoration: none; color: inherit; }
+.rv-kinds a:hover, .rv-kinds a:focus-visible { border-color: var(--rv-accent); }
+.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; }
@@ -34,7 +34,8 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
@@ -34,7 +34,8 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
-const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other'];
+const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other',
+ 'employer', 'landlord', 'restaurant', 'business', 'hosting', 'app', 'vehicle']; // the second row: what members review (0004)
const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];
const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];
const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];
const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];
const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];
const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];
@@ -74,6 +74,10 @@ const BUDGETS = {
@@ -74,6 +74,10 @@ const BUDGETS = {
'reviews.correction.resolve': { minute: 30, hour: 300 },
'reviews.correction.resolve': { minute: 30, hour: 300 },
// A discussion comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
// A discussion comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
'reviews.discussion.comment': { minute: 20, hour: 300 },
'reviews.discussion.comment': { minute: 20, hour: 300 },
+ // A member's own review, written or rewritten (the service also caps new ones at 30 a day).
+ '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 },
};
};
/**
/**
@@ -31,18 +31,21 @@ const views = require('../render/views');
@@ -31,18 +31,21 @@ const views = require('../render/views');
const { ratingForStructuredData } = require('../reviews/aggregate');
const { ratingForStructuredData } = require('../reviews/aggregate');
const { actorMiddleware, crossSite } = require('./common');
const { actorMiddleware, crossSite } = require('./common');
-const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing' };
+const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing',
+ employer: 'Organization', landlord: 'Organization', restaurant: 'Restaurant', business: 'LocalBusiness', hosting: 'Organization', app: 'SoftwareApplication', vehicle: 'Product' };
/**
/**
* JSON-LD for an entity page. Product / Thing with an AggregateRating, and a Review for the
* JSON-LD for an entity page. Product / Thing with an AggregateRating, and a Review for the
* summary, exist ONLY when the aggregate exists — i.e. when real signals back it. Without
* summary, exist ONLY when the aggregate exists — i.e. when real signals back it. Without
* signals the page carries breadcrumbs and nothing that could be read as a rating.
* signals the page carries breadcrumbs and nothing that could be read as a rating.
*/
*/
-function structuredData(p, config) {
+function structuredData(p, config, members = null) {
const en = p.entity;
const en = p.entity;
const origin = config.baseUrl;
const origin = config.baseUrl;
const out = [seo.structuredData.breadcrumbs([{ name: 'OpenVibe.Reviews', url: `${origin}/` }, { name: en.name, url: en.url }])];
const out = [seo.structuredData.breadcrumbs([{ name: 'OpenVibe.Reviews', url: `${origin}/` }, { name: en.name, url: en.url }])];
- const rating = ratingForStructuredData(p.aggregate && p.aggregate.result);
+ // The sources' aggregate, else the members' own reviews shown on the page (1–5 stars, their real count).
+ const rating = ratingForStructuredData(p.aggregate && p.aggregate.result)
+ || (members && members.stats.count ? { value: members.stats.average, best: 5, worst: 1, count: members.stats.count } : null);
if (!rating) return out;
if (!rating) return out;
const type = SCHEMA_TYPE[en.kind] || 'Thing';
const type = SCHEMA_TYPE[en.kind] || 'Thing';
out.push(type === 'Product'
out.push(type === 'Product'
@@ -146,7 +149,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -146,7 +149,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
// ── Public ───────────────────────────────────────────────
// ── Public ───────────────────────────────────────────────
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const all = await svc.listEntities({ limit: 1 });
const all = await svc.listEntities({ limit: 1 });
- send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req) }), {
+ send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req), recent: await svc.recentMemberReviews(6), kinds: await svc.kindCounts() }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET],
robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET],
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),
});
});
@@ -162,6 +165,55 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -162,6 +165,55 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
title: q ? `“${q}” on every review site` : 'Every review site, one search', robots: q ? 'noindex, follow' : 'index, follow', cache: 'public', active: 'everywhere', localStyles: ['css/everywhere.css'],
title: q ? `“${q}” on every review site` : 'Every review site, one search', robots: q ? 'noindex, follow' : 'index, follow', cache: 'public', active: 'everywhere', localStyles: ['css/everywhere.css'],
});
});
});
});
+ // ── Members' reviews ─────────────────────────────────────
+ // /review: name what you want to review (found, or added by you), then write about it on its page.
+ router.get('/review', (req, res) => {
+ const kind = svc.MEMBER_KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'employer';
+ send(req, res, 200, views.reviewStartPage({ values: { kind, name: String(req.query.name || '').slice(0, 200) }, kinds: svc.MEMBER_KINDS, local: [...svc.LOCAL_KINDS], actor: req.actor }), {
+ title: 'Write a review', robots: 'index, follow', cache: req.actor.kind === 'anonymous' ? 'public' : null, active: 'review',
+ });
+ });
+ router.post('/review', F('reviews.entity.suggest'), form, wrap(async (req, res) => {
+ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to write a review.');
+ const b = req.body || {};
+ try {
+ const { entity } = await svc.memberEntity({ kind: b.kind, name: b.name, place: b.place, url: b.url }, req.actor);
+ res.redirect(303, `${svc.entityPath(entity)}#write`);
+ } catch (err) {
+ if (!err.status || err.status >= 500) throw err;
+ send(req, res, err.status, views.reviewStartPage({ values: b, kinds: svc.MEMBER_KINDS, local: [...svc.LOCAL_KINDS], actor: req.actor, error: err.message }), { title: 'Write a review', robots: 'noindex, nofollow' });
+ }
+ }));
+ router.post('/e/:slug/reviews', F('reviews.review.write'), form, wrap(async (req, res) => {
+ const e = await locate(req, res, '/reviews');
+ if (!e) return;
+ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to write a review.');
+ const b = req.body || {};
+ try {
+ await svc.writeMemberReview(e.id, { rating: b.rating, title: b.title, body: b.body, relation: b.relation }, req.actor);
+ res.redirect(303, `${svc.entityPath(e)}?saved=1#member-reviews`);
+ } catch (err) {
+ if (!err.status || err.status >= 500) throw err;
+ await sendEntity(req, res, e, { status: err.status, reviewError: err.message, reviewValues: b });
+ }
+ }));
+ router.post('/e/:slug/reviews/:id/remove', F('reviews.review.write'), form, wrap(async (req, res) => {
+ const e = await locate(req, res, `/reviews/${req.params.id}/remove`);
+ if (!e) return;
+ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account.');
+ await svc.removeMemberReview(req.params.id, req.actor, (req.body || {}).reason);
+ res.redirect(303, `${svc.entityPath(e)}#member-reviews`);
+ }));
+ // /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);
+ if (!svc.MEMBER_KINDS.includes(kind)) return errorPage(req, res, 404, 'Not found', 'There is no such kind.');
+ const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
+ const data = await svc.entitiesOfKind(kind, { limit: 60, offset: (page - 1) * 60 });
+ send(req, res, 200, views.kindPage({ kind, page, ...data }), {
+ title: views.KIND_PLURAL[kind], robots: data.total ? 'index, follow' : 'noindex, follow', cache: 'public', path: `/k/${kind}`,
+ });
+ }));
router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));
router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));
router.get('/search', async (req, res) => {
router.get('/search', async (req, res) => {
const query = String(req.query.q || '').slice(0, 200);
const query = String(req.query.q || '').slice(0, 200);
@@ -188,16 +240,24 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -188,16 +240,24 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
}
}
}
- router.get('/e/:slug', wrap(async (req, res) => {
- const e = await locate(req, res);
- if (!e) return;
+ // The entity page: source signals, the members' reviews (with the viewer's own form) and where else to read about it.
+ async function sendEntity(req, res, e, { status = 200, reviewError = null, reviewValues = null } = {}) {
const p = await svc.page(e, req.actor);
const p = await svc.page(e, req.actor);
const discussion = await discussionFor(e, req.actor);
const discussion = await discussionFor(e, req.actor);
- send(req, res, 200, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null }), {
- decision: p.decision, title: e.name, cache: discussion.state === 'ok' ? null : 'public', path: svc.entityPath(e),
- description: e.description || `${e.name}: review signals from ${p.sources.length ? p.sources.map((s) => s.name || s.key).join(', ') : 'no source yet'}, with provenance.`,
- canonical: p.entity.url, jsonLd: structuredData(p, config),
+ const members = await svc.memberReviews(e, req.actor);
+ const elsewhere = views.elsewhereLinks(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 }), {
+ 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.`,
+ canonical: p.entity.url, jsonLd: structuredData(p, config, members),
});
});
+ }
+ router.get('/e/:slug', wrap(async (req, res) => {
+ const e = await locate(req, res);
+ if (!e) return;
+ await sendEntity(req, res, e);
}));
}));
router.get('/e/:slug/history', async (req, res) => {
router.get('/e/:slug/history', async (req, res) => {
@@ -38,7 +38,8 @@ let RELEASE = 'dev';
@@ -38,7 +38,8 @@ let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
function setRelease(id) { if (id) RELEASE = String(id); }
const LINKS = [
const LINKS = [
- { label: 'Entities', href: '/', key: 'home' },
+ { label: 'Reviews', href: '/', key: 'home' },
+ { label: 'Write a review', href: '/review', key: 'review' },
{ label: 'Every review site', href: '/everywhere', key: 'everywhere' },
{ label: 'Every review site', href: '/everywhere', key: 'everywhere' },
{ label: 'How it works', href: '/about', key: 'about' },
{ label: 'How it works', href: '/about', key: 'about' },
];
];
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
* Page bodies. Every interpolated value goes through openvibe-publishing/ssr's html`` (escaped
* Page bodies. Every interpolated value goes through openvibe-publishing/ssr's html`` (escaped
* unless raw()). Forms are plain HTML posts; nothing here needs JavaScript.
* unless raw()). Forms are plain HTML posts; nothing here needs JavaScript.
*
*
- * Numbers shown here come from review_signals (with their provenance) or from the aggregate
- * computed over them. There is no place in these templates that prints a rating from anywhere
- * else, and no star glyphs: values are printed as the source stated them.
+ * Numbers shown here come from review_signals (with their provenance), from the aggregate computed over them, or from
+ * reviews OpenVibe members wrote here (1 to 5 stars, always labelled as theirs and never mixed into the sources'
+ * aggregate). Source values are printed as the source stated them, without star glyphs.
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
@@ -16,7 +16,22 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().repl
@@ -16,7 +16,22 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().repl
const e = encodeURIComponent;
const e = encodeURIComponent;
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
-const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other' };
+const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',
+ employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle' };
+const KIND_PLURAL = { employer: 'Employers', landlord: 'Landlords', restaurant: 'Restaurants', business: 'Local businesses', hosting: 'Hosting providers', product: 'Products',
+ app: 'Apps', software: 'Software', game: 'Games', vehicle: 'Vehicles', service: 'Services', organization: 'Organizations', place: 'Places', media: 'Media', other: 'Other' };
+// How a member knew it, offered on the form (their choice, or none).
+const RELATIONS = {
+ employer: ['Current employee', 'Former employee', 'Interviewed there', 'Contractor'], landlord: ['Current tenant', 'Former tenant', 'Applied to rent'],
+ restaurant: ['Ate there', 'Ordered takeout', 'Ordered delivery'], business: ['Customer', 'Former customer'], hosting: ['Customer', 'Former customer', 'Tried it'],
+ product: ['Own it', 'Owned it', 'Tried it'], vehicle: ['Own one', 'Owned one', 'Test-drove it', 'Rented one'], app: ['Use it', 'Used it'], software: ['Use it', 'Used it'],
+ game: ['Played it'], service: ['Customer', 'Former customer'], organization: ['Customer', 'Member', 'Former member'], place: ['Visited'],
+};
+const LOCAL = new Set(['restaurant', 'business', 'landlord', 'place']);
+// Which sites /everywhere offers for a kind (server/reviews/everywhere.js).
+const EW_KIND = { employer: 'employer', landlord: 'landlord', restaurant: 'business', business: 'business', place: 'business', hosting: 'hosting', product: 'product',
+ vehicle: 'product', app: 'software', software: 'software', game: 'software', service: 'company', organization: 'company', media: 'product', other: 'company' };
+const everywhere = require('../reviews/everywhere');
const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };
const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };
const REASON_LABEL = {
const REASON_LABEL = {
scale_not_stated: 'the source did not state the rating scale',
scale_not_stated: 'the source did not state the rating scale',
@@ -73,13 +88,16 @@ function aggregateLine(agg) {
@@ -73,13 +88,16 @@ function aggregateLine(agg) {
}
}
/** The front page opens with what OpenVibe.Reviews is for (openvibe-shared/showcase), then the entities. */
/** The front page opens with what OpenVibe.Reviews is for (openvibe-shared/showcase), then the entities. */
-function reviewsShowcase() {
+function reviewsHero() {
return showcase.hero({
return showcase.hero({
eyebrow: 'OpenVibe.Reviews',
eyebrow: 'OpenVibe.Reviews',
- title: 'Review scores you can', accent: 'trace',
- lede: 'Ratings and recommendations gathered from named sources. Every number shows where it came from and when it was read, and when no source has said anything there is no score at all.',
- actions: [{ label: 'Browse what is reviewed', href: '#entities', primary: true }, { label: 'Search every review site', href: '/everywhere' }, { label: 'How it works', href: '/about' }],
- }) + showcase.features({
+ title: 'Employers, landlords, restaurants, hosts.', accent: 'Every review in one place.',
+ lede: 'Read and write reviews of where you work, rent, eat and host, beside the scores other sites publish and every review site\'s own page for it. Every number says where it came from.',
+ actions: [{ label: 'Write a review', href: '/review', primary: true }, { label: 'Search every review site', href: '/everywhere' }, { label: 'How it works', href: '/about' }],
+ });
+}
+function reviewsFeatures() {
+ return showcase.features({
title: 'What every page shows',
title: 'What every page shows',
items: [
items: [
{ icon: 'ov:search', title: 'Where each number came from', text: 'Every signal keeps its source, the item it came from, and when it was read.' },
{ icon: 'ov:search', title: 'Where each number came from', text: 'Every signal keeps its source, the item it came from, and when it was read.' },
@@ -118,9 +136,99 @@ function boardCard(x) {
@@ -118,9 +136,99 @@ function boardCard(x) {
: html`<span class="rv-basis">No score yet: no source has said enough.</span>`}</a></li>`;
: html`<span class="rv-basis">No score yet: no source has said enough.</span>`}</a></li>`;
}
}
-function home({ entities, total, editor }) {
- return html`${raw(reviewsShowcase())}
-<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">What is reviewed</h2>
+/** Every other review site's own page for an entity (the /everywhere links for its kind, and its place). */
+function elsewhereLinks(entity) {
+ return everywhere.linksFor(entity.name, { kind: EW_KIND[entity.kind] || 'company', near: LOCAL.has(entity.kind) ? entity.description || '' : '' });
+}
+
+function stars(n) {
+ const v = Math.max(0, Math.min(5, Math.round(Number(n) || 0)));
+ return html`<span class="rv-stars" role="img" aria-label="${v} out of 5 stars">${'★'.repeat(v)}<span class="rv-stars-off">${'★'.repeat(5 - v)}</span></span>`;
+}
+function paragraphs(text) {
+ return String(text || '').split(/\n{2,}/).map((para) => html`<p>${raw(para.split('\n').map((line) => ssr.escapeHtml(line)).join('<br>'))}</p>`);
+}
+
+function memberReviewItem(r, { entity, editor }) {
+ return html`<li><article class="rv-mr" id="${r.id}"><header>${stars(r.rating)} <b>${r.title}</b></header>
+<p class="rv-muted">${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${t(r.created_at)}${r.edited ? ' (edited)' : ''}</p>
+<div class="rv-mr-body">${paragraphs(r.body)}</div>
+${r.mine ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><button type="submit" class="rv-link-button">Delete my review</button></form>`
+ : editor ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><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">Remove</button></form>` : ''}
+</article></li>`;
+}
+
+/** The members' reviews of an entity: their numbers, the reviews, and the viewer's own form. */
+function memberSection(members, { entity, actor, editor, error, values }) {
+ const st = members.stats;
+ const mine = members.mine;
+ const v = values || (mine ? { rating: mine.rating, title: mine.title, body: mine.body, relation: mine.relation } : {});
+ const rels = RELATIONS[entity.kind] || [];
+ return html`<section id="member-reviews" aria-labelledby="mr-h"><h2 id="mr-h">Reviews from OpenVibe members${st.count ? ` (${st.count})` : ''}</h2>
+${st.count ? html`<div class="rv-mr-summary"><p><span class="rv-big">${String(st.average)}</span> <span class="rv-muted">out of 5</span> ${stars(st.average)}<br><span class="rv-muted">from ${n(st.count)} review${st.count === 1 ? '' : 's'} written here by people with an OpenVibe account</span></p>
+<ul class="rv-dist" aria-label="How many members gave each rating">${[5, 4, 3, 2, 1].map((k) => { const c = st.distribution[k - 1]; return html`<li><span>${String(k)}★</span><span class="rv-meter"><span style="width:${(st.count ? (100 * c) / st.count : 0).toFixed(1)}%"></span></span><span class="rv-muted">${n(c)}</span></li>`; })}</ul></div>
+<ol class="rv-mr-list">${members.reviews.map((r) => memberReviewItem(r, { entity, editor }))}</ol>`
+ : html`<p class="rv-muted">No one has reviewed ${entity.name} here yet.${actor && actor.subject ? ' Be the first.' : ''}</p>`}
+<div id="write" class="rv-mr-write">${actor && actor.subject ? html`<h3>${mine ? 'Your review' : `Review ${entity.name}`}</h3>
+${error ? notice('error', error) : ''}
+<form method="post" action="${epath(entity)}/reviews" class="rv-form">
+<fieldset class="rv-rate"><legend>Your rating</legend>${[1, 2, 3, 4, 5].map((k) => html`<label><input type="radio" name="rating" value="${String(k)}" required${String(v.rating) === String(k) ? raw(' checked') : ''}> ${String(k)} star${k === 1 ? '' : 's'}</label>`)}</fieldset>
+${rels.length ? html`<label>How you know it <select name="relation"><option value="">Prefer not to say</option>${rels.map((x) => html`<option${v.relation === x ? raw(' selected') : ''}>${x}</option>`)}</select></label>` : ''}
+<label>Title <input type="text" name="title" maxlength="120" required value="${v.title || ''}" placeholder="One line about it"></label>
+<label>Your review <textarea name="body" rows="6" minlength="30" maxlength="5000" required placeholder="What was it like? What happened?">${v.body || ''}</textarea></label>
+<p class="rv-muted">Posted under your OpenVibe name. Say what happened and what it was like; reviews that name private people, threaten anyone or are not about ${entity.name} are removed.</p>
+<button type="submit">${mine ? 'Update your review' : 'Post review'}</button></form>`
+ : html`<p><a class="rv-button" href="/auth/login?next=${encodeURIComponent(`${epath(entity)}#write`)}">Sign in to review ${entity.name}</a></p>`}</div>
+</section>`;
+}
+
+function elsewhereSection(links, entity) {
+ if (!links.length) return '';
+ 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>`;
+}
+
+/** /review: name what to review; found, or added by the member. */
+function reviewStartPage({ values = {}, kinds, local, actor, error = null }) {
+ const k = values.kind || 'employer';
+ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: 'Write a review' }])}
+<article class="rv-article"><h1>Write a review</h1>
+<p>Employers, landlords, restaurants, hosting providers, products, apps: name it, and if it is already here you go straight to its page; if not, you add it.</p>
+${error ? notice('error', error) : ''}
+${actor && actor.subject ? html`<form method="post" action="/review" class="rv-form">
+<label>What is it? <select name="kind">${kinds.map((x) => html`<option value="${x}"${x === k ? raw(' selected') : ''}>${KIND_LABEL[x]}</option>`)}</select></label>
+<label>Its name <input type="text" name="name" maxlength="200" required value="${values.name || ''}" placeholder="e.g. Acme Corp, Joe's Pizza, Hetzner"></label>
+<label>Where it is <input type="text" name="place" maxlength="120" value="${values.place || ''}" placeholder="City, and state or country"> <span class="rv-muted">Needed for ${local.map((x) => KIND_PLURAL[x].toLowerCase()).join(', ')}.</span></label>
+<label>Its website (optional) <input type="text" name="url" maxlength="300" value="${values.url || ''}" placeholder="example.com"> <span class="rv-muted">So the same one is found again.</span></label>
+<button type="submit">Continue</button></form>`
+ : html`<p><a class="rv-button" href="/auth/login?next=%2Freview">Sign in with OpenVibe to write a review</a></p><p class="rv-muted">Reading needs no account.</p>`}
+<p class="rv-muted">Looking for what other sites say? <a href="/everywhere">Search every review site</a>.</p></article>`;
+}
+
+/** /k/:kind — everything of one kind, the most reviewed by members first. */
+function kindPage({ kind, page, total, entities }) {
+ const pages = Math.max(1, Math.ceil(total / 60));
+ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: KIND_PLURAL[kind] }])}
+<article class="rv-article"><h1>${KIND_PLURAL[kind]}</h1>
+<p><a class="rv-button" href="/review?kind=${kind}">Review ${LOCAL.has(kind) ? 'one near you' : `a${/^[aeiou]/i.test(KIND_LABEL[kind]) ? 'n' : ''} ${KIND_LABEL[kind].toLowerCase()}`}</a></p>
+${entities.length ? html`<ul class="rv-kind-list">${entities.map((x) => html`<li><a href="${epath(x)}"><b>${x.name}</b></a>${x.description && LOCAL.has(x.kind) ? html` <span class="rv-muted">${x.description}</span>` : ''}
+<br>${x.member_count ? html`${stars(x.member_avg)} <span class="rv-muted">${String(x.member_avg)} from ${n(x.member_count)} review${x.member_count === 1 ? '' : 's'}</span>` : html`<span class="rv-muted">No member reviews yet</span>`}</li>`)}</ul>`
+ : html`<p class="rv-muted">None yet. The first review adds one.</p>`}
+${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a href="/k/${kind}?page=${String(page - 1)}">Newer</a> ` : ''}Page ${String(page)} of ${String(pages)}${page < pages ? html` <a href="/k/${kind}?page=${String(page + 1)}">More</a>` : ''}</nav>` : ''}
+</article>`;
+}
+
+function home({ entities, total, editor, recent = [], kinds = {} }) {
+ const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'product', 'app', 'game', 'vehicle'];
+ return html`${raw(reviewsHero())}
+<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
+<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
+<p><a class="rv-button" href="/review">Write a review</a> <a href="/everywhere">Search every review site</a></p></section>
+${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2 id="rv-recent-h">Latest reviews from members</h2>
+<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span></a></li>`)}</ul></section>` : ''}
+${raw(reviewsFeatures())}
+<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">Scores from sources</h2>
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;
<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;
@@ -269,7 +377,7 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
@@ -269,7 +377,7 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
${form}</section>`;
${form}</section>`;
}
}
-function entityPage(p, { discussion, actor, flash }) {
+function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], 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) {
@@ -281,13 +389,14 @@ function entityPage(p, { discussion, actor, flash }) {
@@ -281,13 +389,14 @@ function entityPage(p, { discussion, actor, flash }) {
const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);
const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}
<article class="rv-entity">
<article class="rv-entity">
-<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span> · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>
+<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span>${LOCAL.has(en.kind) && en.description ? html` · ${en.description}` : ''} · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>
${flash ? notice('ok', flash) : ''}
${flash ? notice('ok', flash) : ''}
-${en.description ? html`<p>${en.description}</p>` : ''}
+${en.description && !LOCAL.has(en.kind) ? html`<p>${en.description}</p>` : ''}
+${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
-${aggregateSection(p)}
-${summarySection(p)}
-<section><h2 id="signals">Signals (${String(p.signals.length)})</h2>
+${p.aggregate || p.signals.length || p.sources.length || !(members && members.stats.count) ? aggregateSection(p) : ''}
+${p.summary || p.editor || !(members && members.stats.count) ? summarySection(p) : ''}
+${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>
${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
</section>
</section>
@@ -295,7 +404,8 @@ ${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signa
@@ -295,7 +404,8 @@ ${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signa
${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}
${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}
${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)}
${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>` : ''}
@@ -485,4 +595,5 @@ module.exports = {
@@ -485,4 +595,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,
};
};
@@ -80,6 +80,32 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -80,6 +80,32 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
nameAliases: db.prepare("SELECT * FROM review_entity_aliases WHERE type = 'name' AND norm = ? AND removed_at IS NULL"),
nameAliases: db.prepare("SELECT * FROM review_entity_aliases WHERE type = 'name' AND norm = ? AND removed_at IS NULL"),
insertAlias: db.prepare(`INSERT INTO review_entity_aliases (id, entity_id, type, value, norm, created_by, created_at) VALUES (@id, @entity_id, @type, @value, @norm, @by, @now)`),
insertAlias: db.prepare(`INSERT INTO review_entity_aliases (id, entity_id, type, value, norm, created_by, created_at) VALUES (@id, @entity_id, @type, @value, @norm, @by, @now)`),
removeAlias: db.prepare('UPDATE review_entity_aliases SET removed_at = ?, removed_by = ? WHERE id = ? AND removed_at IS NULL'),
removeAlias: db.prepare('UPDATE review_entity_aliases SET removed_at = ?, removed_by = ? WHERE id = ? AND removed_at IS NULL'),
+ // Members' own reviews (migrations/0004): one per person per entity, beside the source signals.
+ memberReviewsOf: db.prepare("SELECT * FROM review_member_reviews WHERE entity_id = ? AND status = 'published' ORDER BY created_at DESC LIMIT ?"),
+ memberStatsOf: db.prepare(`SELECT COUNT(*) AS n, AVG(rating)::float8 AS avg,
+ COUNT(*) FILTER (WHERE rating = 1) AS r1, COUNT(*) FILTER (WHERE rating = 2) AS r2, COUNT(*) FILTER (WHERE rating = 3) AS r3,
+ COUNT(*) FILTER (WHERE rating = 4) AS r4, COUNT(*) FILTER (WHERE rating = 5) AS r5,
+ COALESCE(SUM(array_length(regexp_split_to_array(trim(body), '\\s+'), 1)), 0) AS words
+ FROM review_member_reviews WHERE entity_id = ? AND status = 'published'`),
+ memberReview: db.prepare('SELECT * FROM review_member_reviews WHERE id = ?'),
+ memberReviewBy: db.prepare('SELECT * FROM review_member_reviews WHERE entity_id = ? AND author = ?'),
+ insertMemberReview: db.prepare(`INSERT INTO review_member_reviews (id, entity_id, author, author_name, rating, title, body, relation, created_at, updated_at)
+ VALUES (@id, @entity_id, @author, @author_name, @rating, @title, @body, @relation, @now, @now)`),
+ updateMemberReview: db.prepare(`UPDATE review_member_reviews SET rating = @rating, title = @title, body = @body, relation = @relation, author_name = @author_name,
+ status = 'published', removed_by = NULL, removed_reason = NULL, removed_at = NULL, updated_at = @now WHERE id = @id`),
+ removeMemberReview: db.prepare("UPDATE review_member_reviews SET status = 'removed', removed_by = ?, removed_reason = ?, removed_at = ?, updated_at = ? WHERE id = ? AND status = 'published'"),
+ recentMemberReviews: db.prepare(`SELECT r.*, e.slug AS entity_slug, e.name AS entity_name, e.kind AS entity_kind, e.description AS entity_place
+ FROM review_member_reviews r JOIN review_entities e ON e.id = r.entity_id
+ WHERE r.status = 'published' AND e.state = 'active' ORDER BY r.created_at DESC LIMIT ?`),
+ entitiesOfKind: db.prepare(`SELECT e.*, (SELECT COUNT(*) FROM review_member_reviews m WHERE m.entity_id = e.id AND m.status = 'published') AS member_count,
+ (SELECT AVG(m.rating)::float8 FROM review_member_reviews m WHERE m.entity_id = e.id AND m.status = 'published') AS member_avg
+ FROM review_entities e WHERE e.state = 'active' AND e.kind = ? ORDER BY member_count DESC, e.updated_at DESC LIMIT ? OFFSET ?`),
+ countOfKind: db.prepare("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active' AND kind = ?"),
+ kindCounts: db.prepare("SELECT kind, COUNT(*) AS n FROM review_entities WHERE state = 'active' GROUP BY kind"),
+ memberReviewsSince: db.prepare('SELECT COUNT(*) AS n FROM review_member_reviews WHERE author = ? AND created_at > ?'),
+ memberEntitiesSince: db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE created_by = ? AND created_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 = ?'),
link: db.prepare('SELECT * FROM review_entity_links WHERE id = ?'),
linksFrom: db.prepare("SELECT * FROM review_entity_links WHERE from_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),
linksFrom: db.prepare("SELECT * FROM review_entity_links WHERE from_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),
linksTo: db.prepare("SELECT * FROM review_entity_links WHERE to_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),
linksTo: db.prepare("SELECT * FROM review_entity_links WHERE to_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),
@@ -478,6 +504,42 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -478,6 +504,42 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return out;
return out;
}
}
+ // ── Members' reviews ─────────────────────────────────────
+ // What members may add and review. Local kinds are told apart by their place (a city), kept as the description.
+ const MEMBER_KINDS = ['employer', 'landlord', 'restaurant', 'business', 'hosting', 'product', 'app', 'software', 'game', 'vehicle', 'service', 'organization', 'place'];
+ const LOCAL_KINDS = new Set(['restaurant', 'business', 'landlord', 'place']);
+ const MEMBER_REVIEWS_PER_DAY = 30;
+ const MEMBER_ENTITIES_PER_DAY = 20;
+
+ function requirePerson(actor, what) {
+ if (!access.isPerson(actor)) fail(403, 'reviews.person_required', `${what} needs a signed-in OpenVibe account`);
+ return actor.subject;
+ }
+ function memberName(actor) {
+ const u = actor && actor.user;
+ return u ? String(u.display_name || u.username || '').trim().slice(0, 60) || null : null;
+ }
+ /** Members' numbers over a set of entity ids (a canonical entity and the ones merged into it). */
+ async function memberStats(ids) {
+ let count = 0, sum = 0, words = 0;
+ const distribution = [0, 0, 0, 0, 0];
+ for (const id of ids) {
+ const r = await q.memberStatsOf.get(id);
+ const n = Number(r.n) || 0;
+ if (!n) continue;
+ count += n; sum += (Number(r.avg) || 0) * n; words += Number(r.words) || 0;
+ [r.r1, r.r2, r.r3, r.r4, r.r5].forEach((v, i) => { distribution[i] += Number(v) || 0; });
+ }
+ return { count, average: count ? Math.round((sum / count) * 10) / 10 : null, distribution, words };
+ }
+ function memberReviewView(r, actor) {
+ return {
+ id: r.id, rating: Number(r.rating), title: r.title, body: r.body, relation: r.relation || null,
+ author_name: r.author_name || 'An OpenVibe member', created_at: toIso(Number(r.created_at)), updated_at: toIso(Number(r.updated_at)),
+ edited: Number(r.updated_at) > Number(r.created_at) + 60000, mine: !!(actor && actor.subject && actor.subject === r.author),
+ };
+ }
+
// ── The gate, the Search document ────────────────────────
// ── The gate, the Search document ────────────────────────
async function publishedSummaryRevision(entityId) {
async function publishedSummaryRevision(entityId) {
const s = await q.summary.get(entityId);
const s = await q.summary.get(entityId);
@@ -490,12 +552,14 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -490,12 +552,14 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const active = await activeSignalsIn(members);
const active = await activeSignalsIn(members);
const { summary, rev } = await publishedSummaryRevision(e.id);
const { summary, rev } = await publishedSummaryRevision(e.id);
const rec = rev && rev.meta && rev.meta.authorship;
const rec = rev && rev.meta && rev.meta.authorship;
+ // Members' reviews count as substance: their words (from three reviews up) and each review as a citation.
+ const mem = await memberStats(members);
return {
return {
state: e.state === 'deleted' ? 'deleted' : 'published',
state: e.state === 'deleted' ? 'deleted' : 'published',
visibility: 'public',
visibility: 'public',
canonicalUrl: entityUrl(e),
canonicalUrl: entityUrl(e),
- wordCount: rev ? ssr.wordCount(summaryText(rev)) : 0,
- citationCount: active.length,
+ wordCount: (rev ? ssr.wordCount(summaryText(rev)) : 0) + (mem.count >= 3 ? mem.words : 0),
+ citationCount: active.length + mem.count,
unsupportedClaims: rev ? await unsupportedCount(e.id, rev) : 0,
unsupportedClaims: rev ? await unsupportedCount(e.id, rev) : 0,
noindex: !!e.noindex,
noindex: !!e.noindex,
...(rec ? authorship.gateFacts(rec, await reviews.latest(summary.id, rev.number)) : {}),
...(rec ? authorship.gateFacts(rec, await reviews.latest(summary.id, rev.number)) : {}),
@@ -523,6 +587,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -523,6 +587,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
const signals = live ? await activeSignalsIn(await closure(e.id)) : [];
const signals = live ? await activeSignalsIn(await closure(e.id)) : [];
const lines = [];
const lines = [];
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 };
+ if (mem.count) lines.push(`OpenVibe members: ${mem.average} out of 5 from ${mem.count} review${mem.count === 1 ? '' : 's'}`);
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',
@@ -1112,6 +1178,118 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1112,6 +1178,118 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
});
});
},
},
+ // Members' reviews ---------------------------------------------------------------------
+ MEMBER_KINDS, LOCAL_KINDS,
+
+ /** Reviews by OpenVibe members of an entity (merged ones included): their numbers, the newest first, the viewer's own. */
+ async memberReviews(e, actor, { limit = 50 } = {}) {
+ const ids = await closure(e.id);
+ const rows = [];
+ for (const id of ids) rows.push(...await q.memberReviewsOf.all(id, limit));
+ rows.sort((a, b) => Number(b.created_at) - Number(a.created_at));
+ const mine = actor && actor.subject ? await q.memberReviewBy.get(e.id, actor.subject) : null;
+ return { stats: await memberStats(ids), reviews: rows.slice(0, limit).map((r) => memberReviewView(r, actor)), mine: mine && mine.status === 'published' ? memberReviewView(mine, actor) : null };
+ },
+
+ /**
+ * What a member wants to review, found or added: { kind, name, place?, url? } → { entity, created }. The same
+ * thing is found by its website, else by its name and kind (and place, for local kinds); otherwise it is added
+ * with the member as its creator, and an editor can merge a duplicate later.
+ */
+ async memberEntity({ kind, name, place = null, url = null } = {}, actor) {
+ const who = requirePerson(actor, 'Adding something to review');
+ if (!MEMBER_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${MEMBER_KINDS.join(', ')}`);
+ const clean = checkName(name);
+ const where = place ? String(place).replace(/\s+/g, ' ').trim().slice(0, 120) || null : null;
+ if (LOCAL_KINDS.has(kind) && !where) fail(422, 'entity.place_required', 'Say where it is: a city, and a state or country');
+ let site = null;
+ if (url && String(url).trim()) {
+ try {
+ const raw = String(url).trim();
+ const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
+ if (!/^https?:$/.test(u.protocol) || !u.hostname.includes('.')) throw new Error('not a web address');
+ site = `https://${u.hostname.toLowerCase().replace(/^www\./, '')}/`;
+ } catch { fail(422, 'entity.invalid_url', 'The website is a web address like example.com'); }
+ }
+ return await tx(async () => {
+ if (site) {
+ const a = await q.strongAlias.get('url', norm.alias('url', site));
+ if (a) return { entity: await q.entity.get(await canonicalOf(a.entity_id)), created: false };
+ }
+ const same = (await q.sameNamedOfKind.all(norm.alias('name', clean), kind))
+ .filter((x) => !LOCAL_KINDS.has(kind) || String(x.description || '').toLowerCase() === String(where || '').toLowerCase());
+ if (same.length) return { entity: await q.entity.get(await canonicalOf(same[0].id)), created: false };
+ const t = now();
+ if ((await q.memberEntitiesSince.get(who, t - DAY_MS)).n >= MEMBER_ENTITIES_PER_DAY) fail(429, 'entity.member_limit', `You can add ${MEMBER_ENTITIES_PER_DAY} things a day`);
+ const id = `ent_${ulid(t)}`;
+ const slug = await uniqueSlug(LOCAL_KINDS.has(kind) ? `${clean} ${where}` : clean);
+ await redirects.release(`/e/${slug}`);
+ await q.insertEntity.run({ id, slug, name: clean, kind, description: where, created_by: who, now: t });
+ await insertAlias(id, 'name', clean, actor);
+ if (site) await insertAlias(id, 'url', site, actor);
+ await audit(actor, 'entity.added_by_member', id, null, { name: clean, kind, place: where, url: site });
+ await syncEntity(id);
+ return { entity: await q.entity.get(id), created: true };
+ });
+ },
+
+ /** Write, or rewrite, the viewer's review of an entity: { rating 1–5, title, body, relation? }. */
+ async writeMemberReview(ref, { rating, title, body, relation = null } = {}, actor) {
+ const who = requirePerson(actor, 'Writing a review');
+ const r = Number(rating);
+ if (!Number.isInteger(r) || r < 1 || r > 5) fail(422, 'review.invalid_rating', 'Pick a rating from 1 to 5 stars');
+ const tt = String(title == null ? '' : title).replace(/\s+/g, ' ').trim();
+ if (!tt || tt.length > 120) fail(422, 'review.invalid_title', 'Give it a title of up to 120 characters');
+ const bb = String(body == null ? '' : body).replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+ if (bb.length < 30 || bb.length > 5000) fail(422, 'review.invalid_body', 'Say what it was like in 30 to 5,000 characters');
+ const rel = relation ? String(relation).replace(/\s+/g, ' ').trim().slice(0, 60) || null : null;
+ return await tx(async () => {
+ const e = await entityOrFail(ref);
+ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
+ const t = now();
+ const prior = await q.memberReviewBy.get(e.id, who);
+ if (!prior && (await q.memberReviewsSince.get(who, t - DAY_MS)).n >= MEMBER_REVIEWS_PER_DAY) fail(429, 'review.daily_limit', `You can write ${MEMBER_REVIEWS_PER_DAY} reviews a day`);
+ const row = { rating: r, title: tt, body: bb, relation: rel, author_name: memberName(actor), now: t };
+ let id;
+ if (prior) { id = prior.id; await q.updateMemberReview.run({ ...row, id }); }
+ else { id = `mrv_${ulid(t)}`; await q.insertMemberReview.run({ ...row, id, entity_id: e.id, author: who }); }
+ await touchEntity(e.id);
+ await audit(actor, prior ? 'member_review.edited' : 'member_review.written', e.id, id, { rating: r });
+ await syncEntity(await canonicalOf(e.id));
+ return memberReviewView(await q.memberReview.get(id), actor);
+ });
+ },
+
+ /** Take a review down: its author, or an editor (with the reason, kept in the audit log). */
+ async removeMemberReview(id, actor, reason = null) {
+ const who = requirePerson(actor, 'Removing a review');
+ return await tx(async () => {
+ const r = await q.memberReview.get(String(id || ''));
+ if (!r || r.status !== 'published') fail(404, 'review.not_found', 'No such review');
+ const own = r.author === who;
+ if (!own && !access.isEditor(actor)) fail(403, 'reviews.editor_required', 'Only its author or an editor can remove a review');
+ const why = own ? 'removed by its author' : String(reason || '').trim().slice(0, 300) || 'removed by an editor';
+ const t = now();
+ await q.removeMemberReview.run(who, why, t, t, r.id);
+ await audit(actor, 'member_review.removed', r.entity_id, r.id, { reason: why });
+ await syncEntity(await canonicalOf(r.entity_id));
+ 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 } }));
+ },
+ async entitiesOfKind(kind, { limit = 60, offset = 0 } = {}) {
+ if (!ENTITY_KINDS.includes(kind)) fail(404, 'kind.not_found', 'No such kind');
+ const rows = await q.entitiesOfKind.all(kind, limit, offset);
+ return {
+ total: Number((await q.countOfKind.get(kind)).n),
+ entities: rows.map((x) => ({ ...x, member_count: Number(x.member_count) || 0, member_avg: x.member_avg == null ? null : Math.round(Number(x.member_avg) * 10) / 10 })),
+ };
+ },
+ async kindCounts() { return Object.fromEntries((await q.kindCounts.all()).map((x) => [x.kind, Number(x.n)])); },
+
async updateEntity(ref, { name, kind, description, slug, noindex } = {}, actor) {
async updateEntity(ref, { name, kind, description, slug, noindex } = {}, actor) {
requireEditorPerson(actor, 'Editing an entity');
requireEditorPerson(actor, 'Editing an entity');
return await tx(async () => {
return await tx(async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * Reviews by OpenVibe members (owner, 2026-10-17: "collect reviews for all kinds of things like glassdoor reviews of
+ * employers, yelp, server hosting providers, BBB…"): a signed-in person names what to review (found, or added by them),
+ * writes one review of it (1–5 stars, a title, text), rewrites or deletes it; editors remove one with a reason. The
+ * page shows the members' numbers beside (never inside) the sources' aggregate, links every other review site for that
+ * kind, and the home and /k/<kind> list them.
+ */
+const assert = require('assert');
+const { boot, req, suite, userToken, editorToken, cookieFor, subject, outbox } = require('./helpers');
+
+const t = suite('member-reviews');
+const BODY = 'Managers listened, the pay was on time, and the on-call rota was fair. Promotions were slow though.';
+
+t('a member adds an employer, reviews it, rewrites it; others see it; the numbers and the other sites are on the page', async () => {
+ const h = await boot();
+ try {
+ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
+ const ben = cookieFor(userToken({ subject: subject(), username: 'ben' }));
+
+ // Signed out: the form asks to sign in, posting adds nothing.
+ assert.match((await req(h, 'GET', '/review')).text, /Sign in with OpenVibe to write a review/);
+ assert.strictEqual((await req(h, 'POST', '/review', { form: { kind: 'employer', name: 'Acme Corp' } })).status, 401);
+
+ // Added once; the same name (or website) finds it again.
+ const add = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'employer', name: 'Acme Corp', url: 'https://www.acme.example/jobs' } });
+ assert.strictEqual(add.status, 303, add.text);
+ assert.match(add.headers.get('location'), /^\/e\/acme-corp#write$/);
+ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ben, form: { kind: 'employer', name: 'acme corp' } })).headers.get('location'), '/e/acme-corp#write', 'the same name finds it');
+ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ben, form: { kind: 'employer', name: 'ACME Corporation', url: 'acme.example' } })).headers.get('location'), '/e/acme-corp#write', 'its website finds it');
+
+ // A local business needs its place; the same name elsewhere is another business.
+ assert.strictEqual((await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza" } })).status, 422);
+ const nyc = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", place: 'New York, NY' } });
+ const atx = await req(h, 'POST', '/review', { cookie: ana, form: { kind: 'restaurant', name: "Joe's Pizza", place: 'Austin, TX' } });
+ assert.notStrictEqual(nyc.headers.get('location'), atx.headers.get('location'));
+
+ // Reviews: validation, then posted; one per person, rewritten in place.
+ assert.strictEqual((await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '6', title: 'x', body: BODY } })).status, 422);
+ assert.strictEqual((await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '4', title: 'Fine', body: 'too short' } })).status, 422);
+ const posted = await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '4', title: 'Solid place to work', body: BODY, relation: 'Former employee' } });
+ assert.strictEqual(posted.status, 303, posted.text);
+ await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ben, 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: ana, form: { rating: '5', title: 'Solid place to work', body: BODY, relation: 'Former employee' } });
+
+ const page = await req(h, 'GET', '/e/acme-corp');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /Reviews from OpenVibe members \(2\)/);
+ assert.match(page.text, /<span class="rv-big">3\.5<\/span>/, 'average of 5 and 2');
+ assert.match(page.text, /Solid place to work/);
+ assert.match(page.text, /ana · Former employee/);
+ assert.match(page.text, /Sign in to review Acme Corp/, 'a signed-out reader is offered sign-in, not the form');
+ // Every other review site for an employer, opening its own search.
+ for (const site of ['Glassdoor', 'Indeed', 'Blind', 'Reddit']) assert.ok(page.text.includes(`>${site}</a>`), site);
+ assert.match(page.text, /https:\/\/www\.glassdoor\.com\/Search\/results\.htm\?keyword=Acme%20Corp/);
+ assert.ok(!/Signals from sources/.test(page.text), 'no empty sources section on a members-only page');
+ // JSON-LD carries the members' rating (it is on the page).
+ assert.match(page.text, /"ratingValue":\s*3\.5/);
+
+ // Ana sees her own review in the form, and can delete it.
+ const mine = await req(h, 'GET', '/e/acme-corp', { cookie: ana });
+ assert.match(mine.text, /Update your review/);
+ const id = /<article class="rv-mr" id="(mrv_[0-9A-Z]{26})">(?:(?!<\/article>)[\s\S])*Delete my review/.exec(mine.text)[1];
+ assert.strictEqual((await req(h, 'POST', `/e/acme-corp/reviews/${id}/remove`, { cookie: ben })).status, 403, 'not someone else\'s');
+ assert.strictEqual((await req(h, 'POST', `/e/acme-corp/reviews/${id}/remove`, { cookie: ana })).status, 303);
+ assert.match((await req(h, 'GET', '/e/acme-corp')).text, /Reviews from OpenVibe members \(1\)/);
+
+ // An editor removes Ben's with a reason (kept in the audit log, not shown as a review).
+ const page2 = await req(h, 'GET', '/e/acme-corp', { cookie: cookieFor(editorToken()) });
+ const benId = /id="(mrv_[0-9A-Z]{26})"/.exec(page2.text)[1];
+ assert.strictEqual((await req(h, 'POST', `/e/acme-corp/reviews/${benId}/remove`, { cookie: cookieFor(editorToken()), form: { reason: 'Not about this employer' } })).status, 303);
+ const audit = await h.db.prepare("SELECT detail FROM review_audit WHERE action = 'member_review.removed' ORDER BY id DESC LIMIT 1").get();
+ assert.match(audit.detail, /Not about this employer/);
+
+ // Lists: /k/employer and the home's latest reviews; Search gets the members' line.
+ await req(h, 'POST', '/e/acme-corp/reviews', { cookie: ana, form: { rating: '4', title: 'Back, and still good', body: BODY } });
+ assert.match((await req(h, 'GET', '/k/employer')).text, /Acme Corp[\s\S]*4 from 1 review/);
+ const home = await req(h, 'GET', '/');
+ assert.match(home.text, /Latest reviews from members[\s\S]*Back, and still good/);
+ assert.match(home.text, /href="\/k\/employer"/);
+ const docs = (await outbox(h)).filter((x) => x.event_type === 'reviews.index_document.upserted' && x.payload.id && x.payload.title === 'Acme Corp');
+ assert.ok(docs.some((d) => /OpenVibe members: 4 out of 5 from 1 review/.test(d.payload.body || '')), 'the Search document says what members gave it');
+ assert.strictEqual((await req(h, 'GET', '/k/nonsense')).status, 404);
+ } finally { await h.stop(); }
+});
@@ -18,8 +18,8 @@ const BUDGETS = {
@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)
cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)
- cssRawKB: 20.5, // 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)
- cssBrotliKB: 5, // 4.6 (4.3, 1.7 before showcase.css)
+ cssRawKB: 23, // 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)
+ cssBrotliKB: 5.5, // 5.1 with members' reviews (2026-10-17); 4.6 (4.3, 1.7 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};