openvibers/openvibe.community Commits

Section reactions: a reader says whether a SECTION of a post was good (#315)

OP OpenVibers committed 4 days ago · 71adf03e3c · 8 files, +156 −2

* Section reactions: a reader says whether a SECTION of a post was good

One thread per post (comment_threads), so the section id is the key: a row is
(thread, section, person, value). Reacting again replaces the row; 0 takes it back. The counts
are recomputed from the rows, never incremented, so they cannot drift — the same rule the
comment votes follow.

  POST /api/v1/comments/threads/:id/section-reactions { section_id, value: 1|-1|0 }
  GET  /api/v1/comments/threads/:id/section-reactions

People only, on a public thread; a service must name the person (X-OV-Subject).

Expand only: a new table and its index; nothing is dropped or rewritten.

* Merge remote-tracking branch 'origin/main' into feature/b1-rich-blocks-community

* Section reactions: migration 0026 (0024 and 0025 are taken), exported and erased with the person

* OpenAPI: the section reaction routes

Changes

ddiff --git a/migrations/0026_thread_section_reactions.sql b/migrations/0026_thread_section_reactions.sql
nnew file mode 100644
iindex 0000000..3bcd33e
---- /dev/null
++++ b/migrations/0026_thread_section_reactions.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- Section reactions (rich content, stage B1): a reader says whether a SECTION of a post was good.
++-- One thread per post (comment_threads), so the section id is the key: a section reaction is a row
++-- of (thread, section, person, value). The counts live here, in Community, beside the comments of the
++-- same post: one place holds what the readers said about a post.
++--
++-- Expand only: a new table and its index; nothing is dropped or rewritten. The value is 1 (good) or
++-- -1 (not good); the same person reacting again replaces their row.
++CREATE TABLE IF NOT EXISTS thread_section_reactions (
++ thread_id bigint NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
++ section_id text COLLATE "C" NOT NULL,
++ subject_id text COLLATE "C" NOT NULL,
++ value smallint NOT NULL CHECK (value IN (1, -1)),
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (thread_id, section_id, subject_id)
++);
++CREATE INDEX IF NOT EXISTS idx_thread_section_reactions_thread ON thread_section_reactions(thread_id);
ddiff --git a/server/comments/api.js b/server/comments/api.js
iindex 0f915c4..3291a19 100644
---- a/server/comments/api.js
++++ b/server/comments/api.js
@@@ -12,6 +12,8 @@
* PATCH /:commentId { message } the author only
* DELETE /:commentId author or moderator
* POST /:commentId/votes { value: 1|-1|0 }
++ * POST /threads/:id/section-reactions { section_id, value: 1|-1|0 } a reader's read on a section
++ * GET /threads/:id/section-reactions the counts of every section, and mine
*
* Callers: browsers (the Network user JWT as ov_token cookie or Bearer — other OpenVibe sites use
* Bearer through CORS), anonymous browsers (comments with an anon_name, under the shared
@@@ -45,6 +47,9 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,
router.get('/threads/:id', read, run(async (req) => await service.get(req.viewer, req.params.id, req.query)));
router.post('/threads/:id/comments', serviceCap(WRITE), limits('community.comment.create', { minute: 20, hour: 300 }), anonWriteLimiter || passAnon, jsonBody,
run(async (req) => await service.add(req.viewer, req.params.id, req.body || {}), 201));
++ router.post('/threads/:id/section-reactions', serviceCap(WRITE), limits('community.comment.vote', { minute: 120, hour: 1200 }), jsonBody,
++ run(async (req) => await service.sectionReaction(req.viewer, req.params.id, req.body || {})));
++ router.get('/threads/:id/section-reactions', read, run(async (req) => await service.sectionReactionCounts(req.viewer, req.params.id)));
router.put('/threads/:id/visibility', serviceCap(MOD), moderate, jsonBody, run(async (req) => await service.setVisibility(req.viewer, req.params.id, req.body || {})));
router.get('/:commentId', read, run(async (req) => await service.getComment(req.viewer, req.params.commentId)));
router.patch('/:commentId', serviceCap(WRITE), limits('community.comment.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await service.edit(req.viewer, req.params.commentId, req.body || {})));
ddiff --git a/server/comments/section-reactions.js b/server/comments/section-reactions.js
nnew file mode 100644
iindex 0000000..4dab90c
---- /dev/null
++++ b/server/comments/section-reactions.js
@@@ -0,0 +1,62 @@
++'use strict';
++
++/**
++ * Section reactions: a reader says whether a SECTION of a post was good (👍 or 👎).
++ *
++ * One thread per post (comment_threads), so the section id is the key: a row is
++ * (thread, section, person, value). The same person reacting again replaces their row; reacting
++ * with 0 takes it back. The counts are recomputed from the rows, never incremented, so they cannot
++ * drift (the same rule the comment votes follow, server/votes.js).
++ *
++ * Nothing here decides who may react: service.js does that.
++ */
++
++/** A person's reaction to a section (1, -1 or 0 to take it back). → { section_id, value, upvotes, downvotes } */
++async function setSectionReaction(db, threadId, sectionId, subject, value) {
++ if (![1, -1, 0].includes(value)) throw new Error('value must be 1, -1 or 0');
++ return await db.tx(async () => {
++ await db.prepare('SELECT id FROM comment_threads WHERE id = ? FOR UPDATE').get(threadId);
++ if (value === 0) {
++ await db.prepare('DELETE FROM thread_section_reactions WHERE thread_id = ? AND section_id = ? AND subject_id = ?')
++ .run(threadId, sectionId, subject);
++ } else {
++ await db.prepare(`INSERT INTO thread_section_reactions (thread_id, section_id, subject_id, value) VALUES (?, ?, ?, ?)
++ ON CONFLICT (thread_id, section_id, subject_id) DO UPDATE SET value = excluded.value, updated_at = now()`)
++ .run(threadId, sectionId, subject, value);
++ }
++ const agg = await db.prepare(`SELECT COUNT(*) FILTER (WHERE value = 1) AS upvotes,
++ COUNT(*) FILTER (WHERE value = -1) AS downvotes
++ FROM thread_section_reactions WHERE thread_id = ? AND section_id = ?`)
++ .get(threadId, sectionId);
++ return {
++ section_id: sectionId,
++ value,
++ upvotes: Number(agg.upvotes) || 0,
++ downvotes: Number(agg.downvotes) || 0,
++ };
++ });
++}
++
++/** The counts of every section of a thread. → Map sectionId → { up, down } */
++async function sectionReactions(db, threadId) {
++ const rows = await db.prepare(`SELECT section_id,
++ COUNT(*) FILTER (WHERE value = 1) AS up,
++ COUNT(*) FILTER (WHERE value = -1) AS down
++ FROM thread_section_reactions WHERE thread_id = ?
++ GROUP BY section_id`).all(threadId);
++ const out = new Map();
++ for (const r of rows) out.set(r.section_id, { up: Number(r.up) || 0, down: Number(r.down) || 0 });
++ return out;
++}
++
++/** What one person said about each section. → Map sectionId → 1 | -1 */
++async function mySectionReactions(db, threadId, subject) {
++ const out = new Map();
++ if (!subject) return out;
++ const rows = await db.prepare('SELECT section_id, value FROM thread_section_reactions WHERE thread_id = ? AND subject_id = ?')
++ .all(threadId, subject);
++ for (const r of rows) out.set(r.section_id, r.value);
++ return out;
++}
++
++module.exports = { setSectionReaction, sectionReactions, mySectionReactions };
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 297d9c4..3ccd426 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -38,6 +38,7 @@ const contracts = require('openvibe-contracts');
const store = require('./store');
const pasteStore = require('../pastes/store');
const { applyVote, myVotes, parseVote } = require('../votes');
++const sectionReactions = require('./section-reactions');
const { fail, isoTime } = require('../http/v1');
const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
@@@ -335,6 +336,35 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
return { comment_id: c.id, ...out };
},
++ /**
++ * POST /comments/threads/:id/section-reactions { section_id, value: 1 | -1 | 0 }
++ * A reader says whether a SECTION of the post was good. The section id is the key.
++ */
++ async sectionReaction(v, threadId, body = {}) {
++ const sectionId = String(body.section_id || body.sectionId || '').trim();
++ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(sectionId)) fail(400, 'section.invalid_id', 'section_id must be a lowercase slug');
++ const value = parseVote(body.value);
++ if (value === null) fail(400, 'section.invalid_value', 'value must be 1, -1 or 0');
++ if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to react');
++ const t = await visibleThread(v, threadId);
++ if (t.visibility !== 'public') fail(403, 'thread.locked', 'This comment thread is locked');
++ voteLimiter.check(`s:${v.subject}`);
++ const out = await sectionReactions.setSectionReaction(db, t.id, sectionId, v.subject, value);
++ voteLimiter.record(`s:${v.subject}`);
++ return out;
++ },
++
++ /** GET /comments/threads/:id/section-reactions — the counts of every section, and mine. */
++ async sectionReactionCounts(v, threadId) {
++ const t = await visibleThread(v, threadId);
++ const counts = await sectionReactions.sectionReactions(db, t.id);
++ const mine = await sectionReactions.mySectionReactions(db, t.id, v && v.subject);
++ const sections = {};
++ for (const [id, c] of counts) sections[id] = { up: c.up, down: c.down, mine: mine.get(id) || 0 };
++ for (const [id, value] of mine) if (!sections[id]) sections[id] = { up: 0, down: 0, mine: value };
++ return { thread_id: t.id, sections };
++ },
++
/** PUT /threads/:id/visibility { visibility } — moderators. */
async setVisibility(v, id, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 3f70c0b..3f86e5e 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -36,7 +36,7 @@ const EXPORTS = [
['pastes.json', 'pastes', 'owner_subject'], ['paste_comments.json', 'paste_comments', 'author_subject'], ['comments.json', 'comments', 'author_subject'],
['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
-- ['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
++ ['post_reactions.json', 'post_reactions', 'subject_id'], ['section_reactions.json', 'thread_section_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
['space_moderators.json', 'space_moderators', 'subject_id'], ['quest_checks.json', 'pulse_quest_people', 'subject'],
];
@@@ -95,7 +95,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
await db.tx(async () => {
// Likes, votes and reactions first, so the counts recomputed below see only what stays.
const recount = [];
-- for (const [table, item] of [['paste_likes', 'paste_id'], ['comment_votes', 'comment_id'], ['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {
++ for (const [table, item] of [['paste_likes', 'paste_id'], ['comment_votes', 'comment_id'], ['thread_votes', 'thread_id'], ['post_reactions', 'post_id'], ['thread_section_reactions', 'thread_id']]) {
if (!await hasTable(db, table)) continue;
const items = (await db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects)).map((r) => r.i);
add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 0855ac6..c62e064 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -285,6 +285,19 @@ route('post', '/api/v1/comments/threads/{id}/comments', {
} }),
responses: { 201: json('The comment', { type: 'object', properties: { comment: ref('Comment') } }), ...errors() },
});
++route('post', '/api/v1/comments/threads/{id}/section-reactions', {
++ operationId: 'reactToSection', summary: 'Say whether a section of a post was good', tags: ['comments'], security: PERSON, parameters: [path('id', str())],
++ description: 'community.comment.create. One row per person and section: 1 (good), -1 (not good), 0 takes it back. The counts are recomputed from the rows.',
++ requestBody: body({ type: 'object', required: ['section_id', 'value'], properties: {
++ section_id: str({ maxLength: 64 }), value: { type: 'integer', enum: [1, -1, 0] },
++ } }),
++ responses: { 200: json('The section\'s counts', { type: 'object', properties: { section_id: str(), value: { type: 'integer' }, upvotes: { type: 'integer' }, downvotes: { type: 'integer' } } }), ...errors() },
++});
++route('get', '/api/v1/comments/threads/{id}/section-reactions', {
++ operationId: 'sectionReactions', summary: 'The counts of every section of a post, and mine', tags: ['comments'], parameters: [path('id', str())],
++ description: 'community.comment.read. Anyone may read the counts; a signed-in reader also gets their own reaction per section.',
++ responses: { 200: json('Counts by section', { type: 'object' }), ...errors() },
++});
route('put', '/api/v1/comments/threads/{id}/visibility', {
operationId: 'setThreadVisibility', summary: 'Hide or lock a thread (moderators)', tags: ['comments'], security: PERSON, parameters: [path('id', str())],
description: 'community.comment.moderate.',
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 8230e68..283377b 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -51,6 +51,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
await db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject, is_opening) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'opening', ?, 1), (2, 1, 'otto answers', ?, 0), (3, 2, 'quiet opening', ?, 1)").run(DANA, OTTO, DANA);
await db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (2, ?, 1)').run(OTTO);
await db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);
++ await db.prepare("INSERT INTO thread_section_reactions (thread_id, section_id, subject_id, value) VALUES (1, 'intro', ?, 1)").run(DANA);
await db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);
await db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);
// Sharing: DANA owns a paste shared as people, and is a member of OTTO's paste with a pending ask.
@@@ -106,6 +107,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);
assert.strictEqual((await all('SELECT * FROM post_reactions')).length + (await all('SELECT * FROM paste_likes')).length, 0);
++ assert.strictEqual((await all('SELECT * FROM thread_section_reactions')).length, 0, 'section reactions go with the person');
assert.strictEqual((await db.prepare('SELECT likes FROM pastes WHERE id = 3').get()).likes, 0, 'the like count follows');
assert.deepStrictEqual(await all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);
assert.strictEqual((await all('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex 862df1f..9c7fdd4 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -180,6 +180,30 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
});
++ await check('section reactions: one thread per post, the section id is the key; people only', async () => {
++ const ref = { service: 'blog', type: 'post', id: 'pst_1' };
++ const thread = (await resolve(ref, { cookie: alexJwt })).json().thread;
++ const react = (json, who) => call(`/api/v1/comments/threads/${thread.id}/section-reactions`, { method: 'POST', json, ...who });
++ const counts = (who = {}) => call(`/api/v1/comments/threads/${thread.id}/section-reactions`, { ...who });
++
++ const first = await react({ section_id: 'blueprint-rework', value: 1 }, { cookie: alexJwt });
++ assert.strictEqual(first.status, 200, first.text);
++ assert.strictEqual((await react({ section_id: 'blueprint-rework', value: 1 }, { cookie: samJwt })).status, 200);
++ assert.strictEqual((await react({ section_id: 'blueprint-rework', value: -1 }, { cookie: samJwt })).status, 200, 'reacting again replaces the row');
++ let out = (await counts({ cookie: alexJwt })).json();
++ assert.deepStrictEqual(out.sections['blueprint-rework'], { up: 1, down: 1, mine: 1 });
++ assert.strictEqual(out.sections['other-section'], undefined, 'a section nobody reacted to is not a section');
++
++ assert.strictEqual((await react({ section_id: 'blueprint-rework', value: 0 }, { cookie: samJwt })).status, 200);
++ out = (await counts()).json();
++ assert.deepStrictEqual(out.sections['blueprint-rework'], { up: 1, down: 0, mine: 0 });
++
++ assert.strictEqual((await react({ section_id: 'Not A Slug', value: 1 }, { cookie: alexJwt })).status, 400, 'the section id must be a slug');
++ assert.strictEqual((await react({ section_id: 'ok', value: 2 }, { cookie: alexJwt })).status, 400);
++ assert.strictEqual((await react({ section_id: 'ok', value: 1 }, {})).status, 401, 'anonymous cannot react');
++ assert.strictEqual((await react({ section_id: 'ok', value: 1 }, { token: svc([WRITE]) })).status, 401, 'a service must name the person');
++ });
++
await check('votes: add, change, remove; score recomputed; my_vote; people only', async () => {
const c = (await post(vod.id, { message: 'vote on me' }, { cookie: alexJwt })).json().comment;
const vote = (value, who) => call(`/api/v1/comments/${c.id}/votes`, { method: 'POST', json: { value }, ...who });