openvibers/openvibe.community Commits
Section reactions: a reader says whether a SECTION of a post was good (#315)
Changes
nnew file mode 100644@@@ -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);@@@ -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 || {})));nnew file mode 100644@@@ -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 };@@@ -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');@@@ -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);@@@ -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.',@@@ -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');@@@ -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 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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);
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
* PATCH /:commentId { message } the author only
* PATCH /:commentId { message } the author only
* DELETE /:commentId author or moderator
* DELETE /:commentId author or moderator
* POST /:commentId/votes { value: 1|-1|0 }
* 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
* 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
* Bearer through CORS), anonymous browsers (comments with an anon_name, under the shared
@@ -45,6 +47,9 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,
@@ -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.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,
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));
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.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.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 || {})));
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 || {})));
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
@@ -38,6 +38,7 @@ const contracts = require('openvibe-contracts');
@@ -38,6 +38,7 @@ const contracts = require('openvibe-contracts');
const store = require('./store');
const store = require('./store');
const pasteStore = require('../pastes/store');
const pasteStore = require('../pastes/store');
const { applyVote, myVotes, parseVote } = require('../votes');
const { applyVote, myVotes, parseVote } = require('../votes');
+const sectionReactions = require('./section-reactions');
const { fail, isoTime } = require('../http/v1');
const { fail, isoTime } = require('../http/v1');
const { createAuthors } = require('../identity/authors');
const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
const { createPersonLimiter } = require('../limits');
@@ -335,6 +336,35 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -335,6 +336,35 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
return { comment_id: c.id, ...out };
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. */
/** PUT /threads/:id/visibility { visibility } — moderators. */
async setVisibility(v, id, body = {}) {
async setVisibility(v, id, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
@@ -36,7 +36,7 @@ const EXPORTS = [
@@ -36,7 +36,7 @@ const EXPORTS = [
['pastes.json', 'pastes', 'owner_subject'], ['paste_comments.json', 'paste_comments', 'author_subject'], ['comments.json', 'comments', 'author_subject'],
['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'],
['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'],
['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'],
['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'],
['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() } = {}) {
@@ -95,7 +95,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
await db.tx(async () => {
await db.tx(async () => {
// Likes, votes and reactions first, so the counts recomputed below see only what stays.
// Likes, votes and reactions first, so the counts recomputed below see only what stays.
const recount = [];
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;
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);
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);
add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);
@@ -285,6 +285,19 @@ route('post', '/api/v1/comments/threads/{id}/comments', {
@@ -285,6 +285,19 @@ route('post', '/api/v1/comments/threads/{id}/comments', {
} }),
} }),
responses: { 201: json('The comment', { type: 'object', properties: { comment: ref('Comment') } }), ...errors() },
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', {
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())],
operationId: 'setThreadVisibility', summary: 'Hide or lock a thread (moderators)', tags: ['comments'], security: PERSON, parameters: [path('id', str())],
description: 'community.comment.moderate.',
description: 'community.comment.moderate.',
@@ -51,6 +51,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -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 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 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 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 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);
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.
// 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;
@@ -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, 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.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 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.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.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');
assert.strictEqual((await all('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
@@ -180,6 +180,30 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -180,6 +180,30 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
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 () => {
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 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 });
const vote = (value, who) => call(`/api/v1/comments/${c.id}/votes`, { method: 'POST', json: { value }, ...who });