openvibers/openvibe.community Commits

Connect public paste comments to Pastes forum threads (#368)

OP OpenVibers committed 2 days ago · 5c389d4856 · 11 files, +267 −5

* Link public paste comments with their forum discussions

* Keep linked comment edits author only and update forum flow test

* Reserve migration 0034 for comment forum backfill

* Merge current Community main into comment forum bridge

Changes

ddiff --git a/migrations/0034_comment_forum_backfill.sql b/migrations/0034_comment_forum_backfill.sql
nnew file mode 100644
iindex 0000000..8baf2e2
---- /dev/null
++++ b/migrations/0034_comment_forum_backfill.sql
@@@ -0,0 +1,44 @@
++-- phase: expand
++-- Existing public paste discussions enter their official board. A comment keeps its id,
++-- timestamp and parent; posts are a forum view linked back to those same comment rows.
++INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key, created_at, last_activity_at)
++SELECT s.id, 'item-' || ct.id, LEFT(COALESCE(NULLIF(p.title, ''), CASE WHEN p.type = 'screenshot' THEN 'Screenshot' ELSE 'Untitled paste' END), 200),
++ p.owner_subject, 'system', 'community:paste:' || p.slug, MIN(c.created_at), MAX(c.created_at)
++FROM comment_threads ct
++JOIN pastes p ON p.slug = ct.ref_id AND p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0
++JOIN comments c ON c.thread_id = ct.id
++JOIN spaces s ON s.slug = 'pastes'
++WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.forum_thread_id IS NULL
++GROUP BY s.id, ct.id, p.slug, p.title, p.type, p.owner_subject
++ON CONFLICT (space_id, external_key) WHERE external_key IS NOT NULL DO NOTHING;
++
++UPDATE comment_threads ct SET forum_thread_id = t.id
++FROM threads t JOIN spaces s ON s.id = t.space_id AND s.slug = 'pastes'
++JOIN pastes p ON t.external_key = 'community:paste:' || p.slug
++WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.ref_id = p.slug
++ AND p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0 AND ct.forum_thread_id IS NULL;
++
++INSERT INTO posts (thread_id, origin, is_opening, body_markdown, created_at)
++SELECT t.id, 'system', 1,
++ '[' || REPLACE(REPLACE(t.title, '[', ''), ']', '') || '](/p/' || p.slug || ')'
++ || CASE WHEN p.type = 'screenshot' THEN E'\n\n![' || REPLACE(REPLACE(t.title, '[', ''), ']', '') || '](/p/' || p.slug || '/screenshot)' ELSE '' END,
++ t.created_at
++FROM comment_threads ct
++JOIN threads t ON t.id = ct.forum_thread_id
++JOIN pastes p ON p.slug = ct.ref_id
++WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste'
++ AND NOT EXISTS (SELECT 1 FROM posts opening WHERE opening.thread_id = t.id AND opening.is_opening = 1);
++
++INSERT INTO posts (thread_id, author_subject, origin, body_markdown, created_at, updated_at, deleted_at, comment_id)
++SELECT ct.forum_thread_id, c.author_subject, c.origin, c.message, c.created_at, c.updated_at, c.deleted_at, c.id
++FROM comments c JOIN comment_threads ct ON ct.id = c.thread_id
++WHERE ct.forum_thread_id IS NOT NULL AND c.forum_post_id IS NULL
++ORDER BY ct.forum_thread_id, c.id
++ON CONFLICT (comment_id) WHERE comment_id IS NOT NULL DO NOTHING;
++
++UPDATE comments c SET forum_post_id = p.id FROM posts p
++WHERE p.comment_id = c.id AND c.forum_post_id IS NULL;
++
++UPDATE threads t SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL),
++ last_activity_at = (SELECT COALESCE(MAX(p.created_at), t.created_at) FROM posts p WHERE p.thread_id = t.id AND p.deleted_at IS NULL)
++WHERE t.external_key LIKE 'community:paste:%';
ddiff --git a/server/app.js b/server/app.js
iindex f01df29..be79676 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -263,6 +263,7 @@ async function createApp(opts = {}) {
// gate. Created before comments so a thread on a paste nobody may see is never readable.
const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });
const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects });
++ forum.setCommentService(comments);
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
discovery.useForum(forum);
ddiff --git a/server/comments/forum-links.js b/server/comments/forum-links.js
nnew file mode 100644
iindex 0000000..dcb7ef0
---- /dev/null
++++ b/server/comments/forum-links.js
@@@ -0,0 +1,69 @@
++'use strict';
++
++// A forum post is a view of a comment. The comment remains the source of its text,
++// author, order and deletion state; the mapped post gives the forum its usual URLs.
++const PUBLIC_PASTE = "p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0";
++
++async function eligiblePaste(db, thread) {
++ if (thread.ref_service !== 'community' || thread.ref_type !== 'paste') return null;
++ return await db.prepare(`SELECT p.slug, p.title, p.type, p.owner_subject FROM pastes p WHERE p.slug = ? AND ${PUBLIC_PASTE}`)
++ .get(thread.ref_id) || null;
++}
++
++async function ensureForumThread(db, thread) {
++ const paste = await eligiblePaste(db, thread);
++ if (!paste) return null;
++ if (thread.forum_thread_id) return thread.forum_thread_id;
++ const space = await db.prepare("SELECT id FROM spaces WHERE slug = 'pastes'").get();
++ if (!space) return null;
++ const key = `community:paste:${paste.slug}`;
++ const title = String(paste.title || (paste.type === 'screenshot' ? 'Screenshot' : 'Untitled paste')).slice(0, 200);
++ const slug = `item-${thread.id}`;
++ const info = await db.prepare(`INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key)
++ VALUES (?, ?, ?, ?, 'system', ?) ON CONFLICT (space_id, external_key) WHERE external_key IS NOT NULL DO NOTHING RETURNING id`)
++ .run(space.id, slug, title, paste.owner_subject, key);
++ const row = await db.prepare('SELECT id FROM threads WHERE space_id = ? AND external_key = ?').get(space.id, key);
++ if (info.changes) {
++ const link = `/p/${encodeURIComponent(paste.slug)}`;
++ const image = paste.type === 'screenshot' ? `\n\n![${title.replace(/[\[\]]/g, '')}](${link}/screenshot)` : '';
++ await db.prepare("INSERT INTO posts (thread_id, origin, is_opening, body_markdown) VALUES (?, 'system', 1, ?)")
++ .run(row.id, `[${title.replace(/[\[\]]/g, '')}](${link})${image}`);
++ }
++ await db.prepare('UPDATE comment_threads SET forum_thread_id = ? WHERE id = ?').run(row.id, thread.id);
++ return row.id;
++}
++
++async function syncComment(db, comment) {
++ const thread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(comment.thread_id);
++ if (!thread) return;
++ const forumId = await ensureForumThread(db, thread);
++ if (!forumId) return;
++ const existing = await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
++ if (existing) return existing.id;
++ const info = await db.prepare(`INSERT INTO posts (thread_id, author_subject, origin, body_markdown, created_at, updated_at, deleted_at, comment_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (comment_id) WHERE comment_id IS NOT NULL DO NOTHING RETURNING id`)
++ .run(forumId, comment.author_subject, comment.origin, comment.message, comment.created_at, comment.updated_at, comment.deleted_at, comment.id);
++ const post = info.changes ? { id: info.lastInsertRowid } : await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
++ await db.prepare('UPDATE comments SET forum_post_id = ? WHERE id = ?').run(post.id, comment.id);
++ await db.prepare(`UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL),
++ last_activity_at = (SELECT COALESCE(MAX(created_at), threads.created_at) FROM posts WHERE thread_id = ? AND deleted_at IS NULL) WHERE id = ?`)
++ .run(forumId, forumId, forumId);
++ return post.id;
++}
++
++async function updatePost(db, comment) {
++ if (!comment.forum_post_id) return;
++ await db.prepare('UPDATE posts SET body_markdown = ?, updated_at = ?, deleted_at = ? WHERE id = ?')
++ .run(comment.message, comment.updated_at, comment.deleted_at, comment.forum_post_id);
++ const post = await db.prepare('SELECT thread_id FROM posts WHERE id = ?').get(comment.forum_post_id);
++ if (post) await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?')
++ .run(post.thread_id, post.thread_id);
++}
++
++async function hidePasteThread(db, slug, visible) {
++ await db.prepare(`UPDATE threads SET deleted_at = CASE WHEN ? THEN NULL ELSE ov_now() END
++ WHERE external_key = ? AND space_id = (SELECT id FROM spaces WHERE slug = 'pastes')`)
++ .run(visible ? 1 : 0, `community:paste:${slug}`);
++}
++
++module.exports = { ensureForumThread, syncComment, updatePost, hidePasteThread };
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex f2bdbd4..2d9726d 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -89,7 +89,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
function shapeThread(t, v) {
const id = threadIdFor(t, v);
if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };
-- return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
++ return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count,
++ forum_url: t.forum_thread_id && t.ref_service === 'community' && t.ref_type === 'paste' ? `/forum/pastes/item-${t.id}` : null,
++ created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
function shapeComment(c, v, projections, votes, threadId) {
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 38efa94..1ff6958 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -10,6 +10,7 @@
* tombstone so the replies keep their context.
*/
const { newThreadAccessId } = require('../db');
++const forumLinks = require('./forum-links');
async function getThread(db, id) {
return await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
@@@ -112,6 +113,7 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =
await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);
if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
const comment = await getComment(db, info.lastInsertRowid);
++ await forumLinks.syncComment(db, comment);
const cthread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
if (cthread) await require('../events').commentCreated(comment, cthread, { itemPublic: await refIsPublic(db, cthread) }); // community.comment.created
return comment;
@@@ -121,7 +123,9 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =
/** The author's new text; edited_at records that it changed. → the row */
async function editComment(db, id, message) {
await db.prepare('UPDATE comments SET message = ?, edited_at = ov_now(), updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(message, id);
-- return await getComment(db, id);
++ const comment = await getComment(db, id);
++ if (comment) await forumLinks.updatePost(db, comment);
++ return comment;
}
/** Soft delete; counters recomputed from the rows. → changes */
@@@ -130,7 +134,9 @@ async function softDeleteComment(db, id, deletedBy = null) {
const c = await getComment(db, id);
if (!c || c.deleted_at) return 0;
await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);
++ await forumLinks.updatePost(db, await getComment(db, id));
await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);
++ if (c.forum_post_id) await db.prepare('DELETE FROM attachments WHERE post_id = ?').run(c.forum_post_id);
await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);
if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
return 1;
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 93cbaea..e159c3c 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -53,6 +53,7 @@ const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const moderation = require('./moderation');
const blocks = require('../identity/blocks');
++const commentStore = require('../comments/store');
const STYLES = ['feed', 'forum'];
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
@@@ -97,6 +98,7 @@ const THREADS_PER_DAY = 20;
const contentLabels = require('./labels');
function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {
++ let commentService = null;
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
@@@ -275,6 +277,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!postIds.length) return out;
const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
++ const linked = await db.prepare(`SELECT p.id AS post_id, a.media_id, a.filename, a.mime, a.size_bytes
++ FROM posts p JOIN comment_attachments a ON a.comment_id = p.comment_id
++ WHERE p.id IN (${postIds.map(() => '?').join(', ')}) ORDER BY p.id, a.created_at, a.media_id`).all(...postIds);
++ for (const r of linked) {
++ if (!out.has(r.post_id)) out.set(r.post_id, []);
++ if (!out.get(r.post_id).some((a) => a.media_id === r.media_id)) out.get(r.post_id).push(shapeAttachment({ ...r, url: `${String(config.mediaUrl || '').replace(/\/$/, '')}/o/${r.media_id}` }));
++ }
return out;
}
@@@ -365,7 +374,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async function attach(rows, postId) {
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
-- for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);
++ const linked = await db.prepare(`SELECT p.comment_id, ct.ref_service, ct.ref_type, ct.ref_id FROM posts p
++ JOIN comments c ON c.id = p.comment_id JOIN comment_threads ct ON ct.id = c.thread_id WHERE p.id = ?`).get(postId);
++ for (const [i, r] of rows.entries()) {
++ await set.run(postId, i, r.media_id);
++ if (linked) await db.prepare(`INSERT INTO comment_attachments
++ (media_id, ref_service, ref_type, ref_id, comment_id, owner_subject, filename, mime, size_bytes)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (media_id) DO NOTHING`)
++ .run(r.media_id, linked.ref_service, linked.ref_type, linked.ref_id, linked.comment_id, r.owner_subject,
++ r.filename || 'image', r.mime, r.size_bytes);
++ }
}
function shapePost(p, v, projections, attachments = null, pastes = null, mod = moderator(v)) {
@@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// ═════════════════════════════════════════════════════════
return {
++ setCommentService(service) { commentService = service; },
SORTS: store.SORTS,
THREADS_PER_PAGE,
POSTS_PER_PAGE,
@@@ -973,6 +992,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
*/
async createThread(v, spaceSlug, body = {}) {
const space = await spaceFor(v, spaceSlug);
++ if (['pictures', 'videos', 'clips', 'pastes'].includes(space.slug)) fail(403, 'space.item_threads_only', 'Item discussions begin with a comment on the item');
const w = writer(v);
mayPostIn(v, space);
await requireMembership(v, space);
@@@ -1026,6 +1046,24 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// Platform blocks: nobody replies in a thread whose author blocked them.
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ const linked = await db.prepare('SELECT access_id FROM comment_threads WHERE forum_thread_id = ?').get(thread.id);
++ if (linked) {
++ if (!commentService) fail(503, 'comments.unavailable', 'Comments are unavailable');
++ const pictureRows = await preparePictures(w, body.pictures);
++ const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
++ const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
++ postLimiter.check(w.key, text);
++ const out = await commentService.add(v, linked.access_id, { message: text });
++ postLimiter.record(w.key, text);
++ const post = await store.getPost(db, (await commentStore.getComment(db, out.comment.id)).forum_post_id);
++ await attach(images, post.id);
++ const projections = await authors.projectionsFor([post.author_subject]);
++ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
++ const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
++ pingThread(space, thread);
++ return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), null), page,
++ url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
++ }
const pictureRows = await preparePictures(w, body.pictures);
const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
@@@ -1053,6 +1091,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mod = await moderates(v, space);
if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');
++ if (post.comment_id) {
++ if (!mine) fail(403, 'comment.not_yours', 'Only the author can edit a comment');
++ const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ await store.editPost(db, post.id, text, v.subject || v.service || null);
++ await commentStore.editComment(db, post.comment_id, text);
++ const next = await store.getPost(db, post.id);
++ const projections = await authors.projectionsFor([next.author_subject]);
++ pingThread(space, thread);
++ return { post: shapePost(next, v, projections, null, null, mod) };
++ }
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
const projections = await authors.projectionsFor([next.author_subject]);
@@@ -1075,6 +1123,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mine = person(v) && post.author_subject === v.subject;
if (!mine && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (post.is_opening) return await removeThread(v, thread, space, body);
++ if (post.comment_id) {
++ const refs = await db.prepare('SELECT media_id FROM comment_attachments WHERE comment_id = ?').all(post.comment_id);
++ await commentStore.softDeleteComment(db, post.comment_id, v.subject || v.service || null);
++ if (media && typeof media.deleteObject === 'function') for (const ref of refs) {
++ try { await media.deleteObject(ref.media_id); } catch (err) { console.warn('[Forum] linked attachment cleanup failed:', err.message); }
++ }
++ pingThread(space, thread);
++ return { ok: true, id: post.id };
++ }
const fields = moderation.fields(body);
await db.tx(async () => {
await store.softDeletePost(db, post.id);
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 0b0f57c..37bb5ae 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -219,6 +219,7 @@ async function updatePaste(db, id, patch, editedBy = null) {
sets.push('updated_at = ov_now()');
await db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
const next = await getById(db, id);
++ if (patch.visibility !== undefined) await require('../comments/forum-links').hidePasteThread(db, next.slug, next.visibility === 'public' && !next.burn_after_read);
if (textChanged) {
await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, next.revision, next.title, next.content, next.language, editedBy);
@@@ -239,6 +240,7 @@ async function softDelete(db, id) {
const cur = await getById(db, id);
const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), content = NULL, screenshot_url = NULL, metadata = NULL,
ai_summary = NULL, ai_tags = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(id)).changes;
++ if (n && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, false);
if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deleted
return n;
});
@@@ -373,6 +375,7 @@ async function setVisibility(db, pasteId, visibility) {
return await db.tx(async () => {
const cur = await getById(db, pasteId);
const r = await db.prepare('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
++ if (r.changes && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, visibility === 'public' && !cur.burn_after_read);
if (r.changes && cur && cur.visibility !== visibility) await require('../events').pasteUpdated(await getById(db, pasteId), ['visibility']);
return r.changes;
});
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 118d6b0..6848bf1 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -511,7 +511,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
<a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
-- ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/forum/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
++ ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
ddiff --git a/server/sharing.js b/server/sharing.js
iindex 07d84a0..c4f77f9 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -135,6 +135,8 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
}
}
await tx.exec('UPDATE pastes SET visibility = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL', [VISIBILITY[audience] || 'private', ref.id]);
++ await tx.exec(`UPDATE threads SET deleted_at = CASE WHEN $1 = 'public' THEN NULL ELSE ov_now() END
++ WHERE external_key = 'community:paste:' || $2 AND space_id = (SELECT id FROM spaces WHERE slug = 'pastes')`, [audience, ref.id]);
tx.afterCommit(() => refreshSearch(ref.id));
}
ddiff --git a/test/comment-forum-links.test.js b/test/comment-forum-links.test.js
nnew file mode 100644
iindex 0000000..9035af1
---- /dev/null
++++ b/test/comment-forum-links.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: {
++ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
++ forumLimits: { posts: { cooldownSec: 0, perMinute: 1000 } },
++ } });
++ const person = t.network.addUser({ network_user_id: 71, username: 'threader', display_name: 'Threader' });
++ const cookie = t.network.sign({ id: 71, subject_id: person.subject_id, username: 'threader', display_name: 'Threader', role: 'user' });
++ const call = (url, method = 'GET', json = null) => t.get(url, {
++ method, headers: json == null ? {} : { 'content-type': 'application/json' },
++ body: json == null ? undefined : JSON.stringify(json), cookies: [`ov_token=${cookie}`],
++ });
++ const create = async (visibility) => {
++ const response = await call('/api/pastes', 'POST', { title: 'A pictured paste', content: `hello ${visibility} ${Math.random()}`, visibility });
++ assert.strictEqual(response.status, 201, response.text);
++ return response.json().slug;
++ };
++
++ await check('a public item enters its board on first comment and forum replies share the comment', async () => {
++ const slug = await create('public');
++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });
++ const access = resolved.json().thread.id;
++ const before = await call('/api/v1/spaces/pastes/threads');
++ assert.strictEqual(before.json().threads.length, 0);
++ const first = await call(`/api/v1/comments/threads/${access}/comments`, 'POST', { message: 'From the paste' });
++ assert.strictEqual(first.status, 201, first.text);
++ const row = await t.db.prepare('SELECT id, forum_thread_id FROM comment_threads WHERE access_id = ?').get(access);
++ assert.ok(row.forum_thread_id);
++ const url = `/api/v1/spaces/pastes/threads/item-${row.id}`;
++ const forum = await call(url);
++ assert.strictEqual(forum.status, 200, forum.text);
++ assert.strictEqual(forum.json().posts[1].body_markdown, 'From the paste');
++ const canonical = await require('../server/pastes/store').canonicalForRef(t.db, slug);
++ assert.ok((await call(`/p/${canonical}`)).text.includes(`/forum/pastes/item-${row.id}`));
++ const reply = await call(`${url}/posts`, 'POST', { body: 'From the forum' });
++ assert.strictEqual(reply.status, 201, reply.text);
++ const comments = await call(`/api/v1/comments/threads/${access}`);
++ assert.deepStrictEqual(comments.json().comments.map((c) => c.message), ['From the paste', 'From the forum']);
++ const postId = reply.json().post.id;
++ const edited = await call(`/api/v1/posts/${postId}`, 'PUT', { body: 'Edited in the forum' });
++ assert.strictEqual(edited.status, 200, edited.text);
++ assert.ok((await call(`/api/v1/comments/threads/${access}`)).text.includes('Edited in the forum'));
++ const privateResult = await call(`/api/pastes/${slug}`, 'PUT', { visibility: 'private' });
++ assert.strictEqual(privateResult.status, 200, privateResult.text);
++ assert.strictEqual((await call(url)).status, 404);
++ assert.strictEqual((await call('/api/v1/spaces/pastes/threads')).json().threads.length, 0);
++ });
++
++ await check('unlisted comments stay off the public board', async () => {
++ const slug = await create('unlisted');
++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });
++ const created = await call(`/api/v1/comments/threads/${resolved.json().thread.id}/comments`, 'POST', { message: 'Link only' });
++ assert.strictEqual(created.status, 201, created.text);
++ assert.strictEqual((await t.db.prepare('SELECT forum_thread_id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id)).forum_thread_id, null);
++ });
++
++ await check('backfill preserves old comment ids and timestamps and is repeatable', async () => {
++ const slug = await create('public');
++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });
++ const thread = await t.db.prepare('SELECT id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id);
++ const first = await t.db.prepare("INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, 'Older', '2025-01-01 01:00:00') RETURNING id").run(thread.id, person.subject_id);
++ const second = await t.db.prepare("INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, 'Newer', '2025-01-01 02:00:00') RETURNING id").run(thread.id, person.subject_id);
++ const sql = fs.readFileSync(path.join(__dirname, '../migrations/0034_comment_forum_backfill.sql'), 'utf8');
++ await t.db.exec(sql);
++ await t.db.exec(sql);
++ const rows = await t.db.prepare('SELECT comment_id, created_at FROM posts WHERE comment_id IN (?, ?) ORDER BY id').all(first.lastInsertRowid, second.lastInsertRowid);
++ assert.deepStrictEqual(rows.map((r) => r.comment_id), [first.lastInsertRowid, second.lastInsertRowid]);
++ assert.deepStrictEqual(rows.map((r) => r.created_at), ['2025-01-01 01:00:00', '2025-01-01 02:00:00']);
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/forum-style.test.js b/test/forum-style.test.js
iindex 645bea5..a066500 100644
---- a/test/forum-style.test.js
++++ b/test/forum-style.test.js
@@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');
await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
const pasteUrl = await require('../server/pastes/store').canonicalForRef(t.db, 'snip1');
const paste = (await call(`/p/${pasteUrl}`)).text;
-- assert.ok(paste.includes('/forum/discuss?paste=snip1'));
++ assert.ok(paste.includes('href="#comments"'), 'an item with no comments opens its own composer');
const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;
assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));
const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;